diff --git a/.changeset/dry-tigers-mate.md b/.changeset/dry-tigers-mate.md new file mode 100644 index 00000000000..d1518f4bcb5 --- /dev/null +++ b/.changeset/dry-tigers-mate.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fixes an issue where calling `signOut()` would not always redirect. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index dc233ebfbc7..48f0a85a291 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -587,16 +587,13 @@ describe('Clerk singleton', () => { ); const sut = new Clerk(productionPublishableKey); - sut.setActive = jest.fn(); + sut.navigate = jest.fn(); await sut.load(); await sut.signOut(); await waitFor(() => { expect(mockClientDestroy).not.toHaveBeenCalled(); expect(mockClientRemoveSessions).toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ - session: null, - redirectUrl: '/', - }); + expect(sut.navigate).toHaveBeenCalledWith('/'); }); }); @@ -613,17 +610,14 @@ describe('Clerk singleton', () => { ); const sut = new Clerk(productionPublishableKey); - sut.setActive = jest.fn(); + sut.navigate = jest.fn(); await sut.load(); await sut.signOut(); await waitFor(() => { expect(mockClientDestroy).not.toHaveBeenCalled(); expect(mockClientRemoveSessions).toHaveBeenCalled(); expect(mockSession1.remove).not.toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ - session: null, - redirectUrl: '/', - }); + expect(sut.navigate).toHaveBeenCalledWith('/'); }); }, ); @@ -638,15 +632,13 @@ describe('Clerk singleton', () => { ); const sut = new Clerk(productionPublishableKey); - sut.setActive = jest.fn(); + sut.navigate = jest.fn(); await sut.load(); await sut.signOut({ sessionId: '2' }); await waitFor(() => { expect(mockSession2.remove).toHaveBeenCalled(); expect(mockClientDestroy).not.toHaveBeenCalled(); - expect(sut.setActive).not.toHaveBeenCalledWith({ - session: null, - }); + expect(sut.navigate).not.toHaveBeenCalled(); }); }); @@ -660,16 +652,13 @@ describe('Clerk singleton', () => { ); const sut = new Clerk(productionPublishableKey); - sut.setActive = jest.fn(); + sut.navigate = jest.fn(); await sut.load(); await sut.signOut({ sessionId: '1' }); await waitFor(() => { expect(mockSession1.remove).toHaveBeenCalled(); expect(mockClientDestroy).not.toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ - session: null, - redirectUrl: '/', - }); + expect(sut.navigate).toHaveBeenCalledWith('/'); }); }); @@ -683,16 +672,13 @@ describe('Clerk singleton', () => { ); const sut = new Clerk(productionPublishableKey); - sut.setActive = jest.fn(); + sut.navigate = jest.fn(); await sut.load(); await sut.signOut({ sessionId: '1', redirectUrl: '/after-sign-out' }); await waitFor(() => { expect(mockSession1.remove).toHaveBeenCalled(); expect(mockClientDestroy).not.toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ - session: null, - redirectUrl: '/after-sign-out', - }); + expect(sut.navigate).toHaveBeenCalledWith('/after-sign-out'); }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index dd946d23521..0748d3076c1 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -86,7 +86,6 @@ import { getClerkQueryParam, getWeb3Identifier, hasExternalAccountSignUpError, - ignoreEventValue, inActiveBrowserTab, inBrowser, isDevAccountPortalOrigin, @@ -132,6 +131,8 @@ import { } from './resources/internal'; import { warnings } from './warnings'; +type SetActiveHook = (intent?: 'sign-out') => void | Promise; + export type ClerkCoreBroadcastChannelEvent = { type: 'signout' }; declare global { @@ -376,28 +377,59 @@ export class Clerk implements ClerkInterface { if (!this.client || this.client.sessions.length === 0) { return; } + + const onBeforeSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' + ? window.__unstable__onBeforeSetActive + : noop; + + const onAfterSetActive: SetActiveHook = + typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function' + ? window.__unstable__onAfterSetActive + : noop; + const opts = callbackOrOptions && typeof callbackOrOptions === 'object' ? callbackOrOptions : options || {}; const redirectUrl = opts?.redirectUrl || this.buildAfterSignOutUrl(); + const signOutCallback = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined; - const handleSetActive = () => { - const signOutCallback = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined; + const executeSignOut = async () => { + const tracker = createBeforeUnloadTracker(this.#options.standardBrowser); + /** + * Hint to each framework, that the user will be signed out. + */ + await onBeforeSetActive('sign-out'); // Notify other tabs that user is signing out. eventBus.dispatch(events.UserSignOut, null); - if (signOutCallback) { - return this.setActive({ - session: null, - beforeEmit: ignoreEventValue(signOutCallback), - }); - } + // Clean up cookies + eventBus.dispatch(events.TokenUpdate, { token: null }); - return this.setActive({ - session: null, - redirectUrl, + this.#setTransitiveState(); + + await tracker.track(async () => { + if (signOutCallback) { + await signOutCallback(); + } else { + await this.navigate(redirectUrl); + } }); + + if (tracker.isUnloading()) { + return; + } + + this.#setAccessors(); + this.#emit(); + + await onAfterSetActive(); }; + /** + * Clears the router cache for `@clerk/nextjs` on all routes except the current one. + * Note: Calling `onBeforeSetActive` before signing out, allows for new RSC prefetch requests to render as signed in. + */ + await onBeforeSetActive(); if (!opts.sessionId || this.client.signedInSessions.length === 1) { if (this.#options.experimental?.persistClient ?? true) { await this.client.removeSessions(); @@ -405,14 +437,19 @@ export class Clerk implements ClerkInterface { await this.client.destroy(); } - return handleSetActive(); + await executeSignOut(); + + return; } + // Multi-session handling const session = this.client.signedInSessions.find(s => s.id === opts.sessionId); const shouldSignOutCurrent = session?.id && this.session?.id === session.id; + await session?.remove(); + if (shouldSignOutCurrent) { - return handleSetActive(); + await executeSignOut(); } }; @@ -868,7 +905,6 @@ export class Clerk implements ClerkInterface { ); } - type SetActiveHook = () => void | Promise; const onBeforeSetActive: SetActiveHook = typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function' ? window.__unstable__onBeforeSetActive @@ -909,7 +945,10 @@ export class Clerk implements ClerkInterface { eventBus.dispatch(events.TokenUpdate, { token: session.lastActiveToken }); } - await onBeforeSetActive(); + /** + * Hint to each framework, that the user will be signed out when `{session: null}` is provided. + */ + await onBeforeSetActive(newSession === null ? 'sign-out' : undefined); //1. setLastActiveSession to passed user session (add a param). // Note that this will also update the session's active organization @@ -930,40 +969,41 @@ export class Clerk implements ClerkInterface { // undefined, then wait for beforeEmit to complete before emitting the new session. // When undefined, neither SignedIn nor SignedOut renders, which avoids flickers or // automatic reloading when reloading shouldn't be happening. - const beforeUnloadTracker = this.#options.standardBrowser ? createBeforeUnloadTracker() : undefined; + const tracker = createBeforeUnloadTracker(this.#options.standardBrowser); + if (beforeEmit) { deprecated( 'Clerk.setActive({beforeEmit})', 'Use the `redirectUrl` property instead. Example `Clerk.setActive({redirectUrl:"/"})`', ); - beforeUnloadTracker?.startTracking(); - this.#setTransitiveState(); - await beforeEmit(newSession); - beforeUnloadTracker?.stopTracking(); + await tracker.track(async () => { + this.#setTransitiveState(); + await beforeEmit(newSession); + }); } if (redirectUrl && !beforeEmit) { - beforeUnloadTracker?.startTracking(); - this.#setTransitiveState(); - - if (this.client.isEligibleForTouch()) { - const absoluteRedirectUrl = new URL(redirectUrl, window.location.href); - - await this.navigate(this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl }))); - } else { - await this.navigate(redirectUrl); - } - - beforeUnloadTracker?.stopTracking(); + await tracker.track(async () => { + if (!this.client) { + // Typescript is not happy because since thinks this.client might have changed to undefined because the function is asynchronous. + return; + } + this.#setTransitiveState(); + if (this.client.isEligibleForTouch()) { + const absoluteRedirectUrl = new URL(redirectUrl, window.location.href); + await this.navigate(this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl }))); + } else { + await this.navigate(redirectUrl); + } + }); } - //3. Check if hard reloading (onbeforeunload). If not, set the user/session and emit - if (beforeUnloadTracker?.isUnloading()) { + //3. Check if hard reloading (onbeforeunload). If not, set the user/session and emit + if (tracker.isUnloading()) { return; } this.#setAccessors(newSession); - this.#emit(); await onAfterSetActive(); }; @@ -2123,17 +2163,13 @@ export class Clerk implements ClerkInterface { #setAccessors = (session?: SignedInSessionResource | null) => { this.session = session || null; this.organization = this.#getLastActiveOrganizationFromSession(); - this.#aliasUser(); + this.user = this.session ? this.session.user : null; }; #getSessionFromClient = (sessionId: string | undefined): SignedInSessionResource | null => { return this.client?.signedInSessions.find(x => x.id === sessionId) || null; }; - #aliasUser = () => { - this.user = this.session ? this.session.user : null; - }; - #handleImpersonationFab = () => { this.addListener(({ session }) => { const isImpersonating = !!session?.actor; diff --git a/packages/clerk-js/src/global.d.ts b/packages/clerk-js/src/global.d.ts index 98dd9b084a8..88d3d120df4 100644 --- a/packages/clerk-js/src/global.d.ts +++ b/packages/clerk-js/src/global.d.ts @@ -18,6 +18,6 @@ declare const __DEV__: boolean; declare const __BUILD_DISABLE_RHC__: string; interface Window { - __unstable__onBeforeSetActive: () => Promise | void; + __unstable__onBeforeSetActive: (intent?: 'sign-out') => Promise | void; __unstable__onAfterSetActive: () => Promise | void; } diff --git a/packages/clerk-js/src/utils/beforeUnloadTracker.ts b/packages/clerk-js/src/utils/beforeUnloadTracker.ts index fa5cf5703eb..5dd2cd793e8 100644 --- a/packages/clerk-js/src/utils/beforeUnloadTracker.ts +++ b/packages/clerk-js/src/utils/beforeUnloadTracker.ts @@ -11,22 +11,43 @@ import { CLERK_BEFORE_UNLOAD_EVENT } from './windowNavigate'; * * @internal */ -export const createBeforeUnloadTracker = () => { +const createBeforeUnloadListener = () => { let _isUnloading = false; const toggle = () => (_isUnloading = true); - const startTracking = () => { + const startListening = () => { window.addEventListener('beforeunload', toggle); window.addEventListener(CLERK_BEFORE_UNLOAD_EVENT, toggle); }; - const stopTracking = () => { + const stopListening = () => { window.removeEventListener('beforeunload', toggle); window.removeEventListener(CLERK_BEFORE_UNLOAD_EVENT, toggle); }; const isUnloading = () => _isUnloading; - return { startTracking, stopTracking, isUnloading }; + return { startListening, stopListening, isUnloading }; +}; + +export const createBeforeUnloadTracker = (enabled = false) => { + if (!enabled) { + return { + track: async (fn: () => Promise) => { + await fn(); + }, + isUnloading: () => false, + }; + } + + const l = createBeforeUnloadListener(); + return { + track: async (fn: () => Promise) => { + l.startListening(); + await fn(); + l.stopListening(); + }, + isUnloading: l.isUnloading, + }; }; diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index c12109385ca..ff54bf187c0 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -55,7 +55,7 @@ const NextClientClerkProvider = (props: NextClerkProviderProps) => { }, [isPending]); useSafeLayoutEffect(() => { - window.__unstable__onBeforeSetActive = () => { + window.__unstable__onBeforeSetActive = intent => { /** * We need to invalidate the cache in case the user is navigating to a page that * was previously cached using the auth state that was active at the time. @@ -78,11 +78,15 @@ const NextClientClerkProvider = (props: NextClerkProviderProps) => { return new Promise(res => { window.__clerk_internal_invalidateCachePromise = res; + const nextVersion = window?.next?.version || ''; + // NOTE: the following code will allow `useReverification()` to work properly when `handlerReverification` is called inside `startTransition` - if (window.next?.version && typeof window.next.version === 'string' && window.next.version.startsWith('13')) { + if (nextVersion.startsWith('13')) { startTransition(() => { router.refresh(); }); + } else if (nextVersion.startsWith('15') && intent === 'sign-out') { + res(); // noop } else { void invalidateCacheAction().then(() => res()); } diff --git a/packages/nextjs/src/global.d.ts b/packages/nextjs/src/global.d.ts index 8757bee7f05..0cacfd784bf 100644 --- a/packages/nextjs/src/global.d.ts +++ b/packages/nextjs/src/global.d.ts @@ -39,7 +39,7 @@ interface Window { __clerk_nav_await: Array<(value: void) => void>; __clerk_nav: (to: string) => Promise; - __unstable__onBeforeSetActive: () => void | Promise; + __unstable__onBeforeSetActive: (intent?: 'sign-out') => void | Promise; __unstable__onAfterSetActive: () => void | Promise; next?: {