From 269fb3462ce3cb740a3ccd1db4f83f8edab7b11b Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 13 Feb 2026 12:55:34 -0600 Subject: [PATCH 1/4] fix(ui): sync BaseRouter state on pushState/replaceState (#7840) Co-authored-by: Claude Opus 4.6 Co-authored-by: Jacek Radko --- .changeset/fix-router-sync-popup-oauth.md | 5 + integration/presets/longRunningApps.ts | 1 + .../react-vite/src/buttons/index.tsx | 9 + integration/templates/react-vite/src/main.tsx | 5 + .../react-vite/src/sign-in-popup/index.tsx | 15 ++ integration/tests/oauth-flows.test.ts | 57 +++++++ packages/clerk-js/src/ui/hooks/index.ts | 1 - .../src/ui/hooks/useWindowEventListener.ts | 15 -- .../clerk-js/src/ui/router/BaseRouter.tsx | 156 +++++++++++++++++- .../clerk-js/src/ui/router/PathRouter.tsx | 2 +- 10 files changed, 243 insertions(+), 23 deletions(-) create mode 100644 .changeset/fix-router-sync-popup-oauth.md create mode 100644 integration/templates/react-vite/src/sign-in-popup/index.tsx delete mode 100644 packages/clerk-js/src/ui/hooks/useWindowEventListener.ts diff --git a/.changeset/fix-router-sync-popup-oauth.md b/.changeset/fix-router-sync-popup-oauth.md new file mode 100644 index 00000000000..cfad3363423 --- /dev/null +++ b/.changeset/fix-router-sync-popup-oauth.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix BaseRouter state not syncing after popup OAuth by observing `pushState`/`replaceState` changes in addition to `popstate` diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index a5acc533fc6..d54b0300211 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -59,6 +59,7 @@ export const createLongRunningApps = () => { { id: 'react.vite.withEmailCodes', config: react.vite, env: envs.withEmailCodes }, { id: 'react.vite.withEmailCodes_persist_client', config: react.vite, env: envs.withEmailCodes_destroy_client }, { id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks }, + { id: 'react.vite.withLegalConsent', config: react.vite, env: envs.withLegalConsent }, { id: 'vue.vite', config: vue.vite, env: envs.withCustomRoles }, /** diff --git a/integration/templates/react-vite/src/buttons/index.tsx b/integration/templates/react-vite/src/buttons/index.tsx index 5aa32d433cf..cb5e6de7be1 100644 --- a/integration/templates/react-vite/src/buttons/index.tsx +++ b/integration/templates/react-vite/src/buttons/index.tsx @@ -11,6 +11,15 @@ export default function Home() { Sign in button (force) + + Sign in button (force, popup) + + , }, + { + path: '/sign-in-popup/*', + element: , + }, { path: '/sign-up/*', element: , diff --git a/integration/templates/react-vite/src/sign-in-popup/index.tsx b/integration/templates/react-vite/src/sign-in-popup/index.tsx new file mode 100644 index 00000000000..b9aeb4f1e96 --- /dev/null +++ b/integration/templates/react-vite/src/sign-in-popup/index.tsx @@ -0,0 +1,15 @@ +import { SignIn } from '@clerk/react'; + +export default function Page() { + return ( +
+ Loading sign in} + /> +
+ ); +} diff --git a/integration/tests/oauth-flows.test.ts b/integration/tests/oauth-flows.test.ts index 6ff96a3cea2..77e97d8f8e5 100644 --- a/integration/tests/oauth-flows.test.ts +++ b/integration/tests/oauth-flows.test.ts @@ -181,6 +181,63 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('oauth flo }); }); +testAgainstRunningApps({ withPattern: ['react.vite.withLegalConsent'] })( + 'oauth popup with path-based routing @react', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const client = createClerkClient({ + secretKey: instanceKeys.get('oauth-provider').sk, + publishableKey: instanceKeys.get('oauth-provider').pk, + }); + const users = createUserService(client); + fakeUser = users.createFakeUser({ + withUsername: true, + }); + await users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + const u = createTestUtils({ app }); + await fakeUser.deleteIfExists(); + await u.services.users.deleteIfExists({ email: fakeUser.email }); + await app.teardown(); + }); + + test('popup OAuth navigates through sso-callback with path-based routing', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToRelative('/sign-in-popup'); + await u.page.waitForClerkJsLoaded(); + await u.po.signIn.waitForMounted(); + + const popupPromise = context.waitForEvent('page'); + await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); + const popup = await popupPromise; + const popupUtils = createTestUtils({ app, page: popup, context }); + await popupUtils.page.getByText('Sign in to oauth-provider').waitFor(); + + // Complete OAuth in the popup + await popupUtils.po.signIn.setIdentifier(fakeUser.email); + await popupUtils.po.signIn.continue(); + await popupUtils.po.signIn.enterTestOtpCode(); + + // Because the user is new to the app and legal consent is required, + // the sign-up can't complete in the popup. The popup sends return_url + // back to the parent, which navigates to /sso-callback via pushState. + await u.page.getByRole('heading', { name: 'Legal consent' }).waitFor(); + await u.page.getByLabel(/I agree to the/).check(); + await u.po.signIn.continue(); + + await u.page.waitForAppUrl('/protected'); + await u.po.expect.toBeSignedIn(); + }); + }, +); + testAgainstRunningApps({ withEnv: [appConfigs.envs.withLegalConsent] })( 'oauth flows with legal consent @nextjs', ({ app }) => { diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts index 7b531d210e4..796773dd1d4 100644 --- a/packages/clerk-js/src/ui/hooks/index.ts +++ b/packages/clerk-js/src/ui/hooks/index.ts @@ -17,4 +17,3 @@ export * from './useSafeState'; export * from './useScrollLock'; export * from './useSearchInput'; export * from './useTotalEnabledAuthMethods'; -export * from './useWindowEventListener'; diff --git a/packages/clerk-js/src/ui/hooks/useWindowEventListener.ts b/packages/clerk-js/src/ui/hooks/useWindowEventListener.ts deleted file mode 100644 index 8426b1b0175..00000000000 --- a/packages/clerk-js/src/ui/hooks/useWindowEventListener.ts +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -type EventType = keyof WindowEventMap; - -export const useWindowEventListener = (eventOrEvents: EventType | EventType[] | undefined, cb: () => void): void => { - React.useEffect(() => { - const events = [eventOrEvents].flat().filter(x => !!x); - if (!events.length) { - return; - } - events.forEach(e => window.addEventListener(e, cb)); - return () => { - events.forEach(e => window.removeEventListener(e, cb)); - }; - }, [eventOrEvents, cb]); -}; diff --git a/packages/clerk-js/src/ui/router/BaseRouter.tsx b/packages/clerk-js/src/ui/router/BaseRouter.tsx index 734fc138937..d62ddbf46eb 100644 --- a/packages/clerk-js/src/ui/router/BaseRouter.tsx +++ b/packages/clerk-js/src/ui/router/BaseRouter.tsx @@ -1,21 +1,138 @@ import { useClerk } from '@clerk/shared/react'; import type { NavigateOptions } from '@clerk/shared/types'; import React from 'react'; +import { flushSync } from 'react-dom'; import { getQueryParams, stringifyQueryParams, trimTrailingSlash } from '../../utils'; -import { useWindowEventListener } from '../hooks'; import { newPaths } from './newPaths'; import { match } from './pathToRegexp'; import { Route } from './Route'; import { RouteContext } from './RouteContext'; +// Custom events that don't exist on WindowEventMap but are handled +// by wrapping history.pushState/replaceState in the fallback path. +type HistoryEvent = 'pushstate' | 'replacestate'; +type RefreshEvent = keyof WindowEventMap | HistoryEvent; +type NavigationType = 'push' | 'replace' | 'traverse'; + +const isWindowRefreshEvent = (event: RefreshEvent): event is keyof WindowEventMap => { + return event !== 'pushstate' && event !== 'replacestate'; +}; + +// Maps refresh events to Navigation API navigationType values. +const eventToNavigationType: Partial> = { + popstate: 'traverse', + pushstate: 'push', + replacestate: 'replace', +}; + +// Global subscription sets for the history monkey-patching fallback. +// Using a single patch with subscriber sets avoids conflicts when +// multiple BaseRouter instances mount simultaneously. +const pushStateSubscribers = new Set<() => void>(); +const replaceStateSubscribers = new Set<() => void>(); +let originalPushState: History['pushState'] | null = null; +let originalReplaceState: History['replaceState'] | null = null; + +function ensurePushStatePatched(): void { + if (originalPushState) { + return; + } + originalPushState = history.pushState.bind(history); + history.pushState = (...args: Parameters) => { + originalPushState!(...args); + pushStateSubscribers.forEach(fn => fn()); + }; +} + +function ensureReplaceStatePatched(): void { + if (originalReplaceState) { + return; + } + originalReplaceState = history.replaceState.bind(history); + history.replaceState = (...args: Parameters) => { + originalReplaceState!(...args); + replaceStateSubscribers.forEach(fn => fn()); + }; +} + +/** + * Observes history changes so the router's internal state stays in sync + * with the URL. Uses the Navigation API when available, falling back to + * monkey-patching history.pushState/replaceState plus native window events. + * + * Note: `events` should be a stable array reference to avoid + * re-subscribing on every render. + */ +function useHistoryChangeObserver(events: Array | undefined, callback: () => void): void { + const callbackRef = React.useRef(callback); + callbackRef.current = callback; + + React.useEffect(() => { + if (!events) { + return; + } + + const notify = () => callbackRef.current(); + const windowEvents = events.filter(isWindowRefreshEvent); + const navigationTypes = events + .map(e => eventToNavigationType[e]) + .filter((type): type is NavigationType => Boolean(type)); + + const hasNavigationAPI = + typeof window !== 'undefined' && + 'navigation' in window && + typeof (window as any).navigation?.addEventListener === 'function'; + + if (hasNavigationAPI) { + const nav = (window as any).navigation; + const allowedTypes = new Set(navigationTypes); + const handler = (e: { navigationType: NavigationType }) => { + if (allowedTypes.has(e.navigationType)) { + Promise.resolve().then(notify); + } + }; + nav.addEventListener('currententrychange', handler); + + // Events without a navigationType mapping (e.g. hashchange) still + // need native listeners even when the Navigation API is available. + const unmappedEvents = windowEvents.filter(e => !eventToNavigationType[e]); + unmappedEvents.forEach(e => window.addEventListener(e, notify)); + + return () => { + nav.removeEventListener('currententrychange', handler); + unmappedEvents.forEach(e => window.removeEventListener(e, notify)); + }; + } + + // Fallback: use global subscriber sets for pushState/replaceState + // so that multiple BaseRouter instances don't conflict. + if (events.includes('pushstate')) { + ensurePushStatePatched(); + pushStateSubscribers.add(notify); + } + if (events.includes('replacestate')) { + ensureReplaceStatePatched(); + replaceStateSubscribers.add(notify); + } + + windowEvents.forEach(e => window.addEventListener(e, notify)); + + return () => { + pushStateSubscribers.delete(notify); + replaceStateSubscribers.delete(notify); + windowEvents.forEach(e => window.removeEventListener(e, notify)); + }; + }, [events]); +} + interface BaseRouterProps { basePath: string; startPath: string; getPath: () => string; getQueryString: () => string; internalNavigate: (toURL: URL, options?: NavigateOptions) => Promise | any; - refreshEvents?: Array; + refreshEvents?: Array; preservedParams?: string[]; urlStateParam?: { startPath: string; @@ -86,7 +203,23 @@ export const BaseRouter = ({ } }, [currentPath, currentQueryString, getPath, getQueryString]); - useWindowEventListener(refreshEvents, refresh); + // Suppresses the history observer during baseNavigate's internal navigation. + // Without this, the observer's microtask triggers a render before setActive's + // #updateAccessors sets clerk.session, causing task guards to see stale state. + const isNavigatingRef = React.useRef(false); + + const observerRefresh = React.useCallback((): void => { + if (isNavigatingRef.current) { + return; + } + const newPath = getPath(); + if (basePath && !newPath.startsWith('/' + basePath)) { + return; + } + refresh(); + }, [basePath, getPath, refresh]); + + useHistoryChangeObserver(refreshEvents, observerRefresh); // TODO: Look into the real possible types of globalNavigate const baseNavigate = async (toURL: URL | undefined): Promise => { @@ -116,9 +249,20 @@ export const BaseRouter = ({ toURL.search = stringifyQueryParams(toQueryParams); } - const internalNavRes = await internalNavigate(toURL, { metadata: { navigationType: 'internal' } }); - setRouteParts({ path: toURL.pathname, queryString: toURL.search }); - return internalNavRes; + isNavigatingRef.current = true; + try { + const internalNavRes = await internalNavigate(toURL, { metadata: { navigationType: 'internal' } }); + // We need to flushSync to guarantee the re-render happens before handing things back to the caller, + // otherwise setActive might emit, and children re-render with the old navigation state. + // An alternative solution here could be to return a deferred promise, set that to state together + // with the routeParts and resolve it in an effect. That way we could avoid the flushSync performance penalty. + flushSync(() => { + setRouteParts({ path: toURL.pathname, queryString: toURL.search }); + }); + return internalNavRes; + } finally { + isNavigatingRef.current = false; + } }; return ( diff --git a/packages/clerk-js/src/ui/router/PathRouter.tsx b/packages/clerk-js/src/ui/router/PathRouter.tsx index c34f5a404b0..b1c48e48b57 100644 --- a/packages/clerk-js/src/ui/router/PathRouter.tsx +++ b/packages/clerk-js/src/ui/router/PathRouter.tsx @@ -59,7 +59,7 @@ export const PathRouter = ({ basePath, preservedParams, children }: PathRouterPr getPath={getPath} getQueryString={getQueryString} internalNavigate={internalNavigate} - refreshEvents={['popstate']} + refreshEvents={['pushstate', 'replacestate', 'popstate']} preservedParams={preservedParams} > {children} From 42395e2417d31bf6f2aeda50dd989a102c59ede1 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Tue, 17 Feb 2026 11:04:38 -0600 Subject: [PATCH 2/4] Update .changeset/fix-router-sync-popup-oauth.md --- .changeset/fix-router-sync-popup-oauth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-router-sync-popup-oauth.md b/.changeset/fix-router-sync-popup-oauth.md index cfad3363423..639411475d0 100644 --- a/.changeset/fix-router-sync-popup-oauth.md +++ b/.changeset/fix-router-sync-popup-oauth.md @@ -1,5 +1,5 @@ --- -'@clerk/ui': patch +'@clerk/clerk-js': patch --- Fix BaseRouter state not syncing after popup OAuth by observing `pushState`/`replaceState` changes in addition to `popstate` From 6a85991ca8f20e42490dedd84f4082858471701a Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 17 Feb 2026 13:17:22 -0600 Subject: [PATCH 3/4] fix: remove non-null assertions in history patching helpers Capture the bound original function in a local const to avoid non-null assertions flagged by @typescript-eslint/no-non-null-assertion. Co-Authored-By: Claude Opus 4.6 --- packages/clerk-js/src/ui/router/BaseRouter.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/ui/router/BaseRouter.tsx b/packages/clerk-js/src/ui/router/BaseRouter.tsx index d62ddbf46eb..4dc15538478 100644 --- a/packages/clerk-js/src/ui/router/BaseRouter.tsx +++ b/packages/clerk-js/src/ui/router/BaseRouter.tsx @@ -38,9 +38,10 @@ function ensurePushStatePatched(): void { if (originalPushState) { return; } - originalPushState = history.pushState.bind(history); + const original = history.pushState.bind(history); + originalPushState = original; history.pushState = (...args: Parameters) => { - originalPushState!(...args); + original(...args); pushStateSubscribers.forEach(fn => fn()); }; } @@ -49,9 +50,10 @@ function ensureReplaceStatePatched(): void { if (originalReplaceState) { return; } - originalReplaceState = history.replaceState.bind(history); + const original = history.replaceState.bind(history); + originalReplaceState = original; history.replaceState = (...args: Parameters) => { - originalReplaceState!(...args); + original(...args); replaceStateSubscribers.forEach(fn => fn()); }; } From 85ccf76f23a57bc7f7049605417bdd889aa5c841 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 18 Feb 2026 14:35:58 -0600 Subject: [PATCH 4/4] fix import for core 2 --- integration/templates/react-vite/src/sign-in-popup/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/templates/react-vite/src/sign-in-popup/index.tsx b/integration/templates/react-vite/src/sign-in-popup/index.tsx index b9aeb4f1e96..b97b5841563 100644 --- a/integration/templates/react-vite/src/sign-in-popup/index.tsx +++ b/integration/templates/react-vite/src/sign-in-popup/index.tsx @@ -1,4 +1,4 @@ -import { SignIn } from '@clerk/react'; +import { SignIn } from '@clerk/clerk-react'; export default function Page() { return (