diff --git a/.changeset/new-fishes-rescue.md b/.changeset/new-fishes-rescue.md new file mode 100644 index 00000000000..7878052008b --- /dev/null +++ b/.changeset/new-fishes-rescue.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Bug fix: Broadcast a sign out event to all opened tabs when `Clerk.signOut()` or `User.delete()` is called. diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 52b8bf58ab9..f593b29127d 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -36,10 +36,14 @@ const createExpectPageObject = ({ page }: TestArgs) => { expect(redirect.status()).toBe(307); expect(redirect.headers()['x-clerk-auth-status']).toContain('handshake'); }, - toBeSignedOut: () => { - return page.waitForFunction(() => { - return !window.Clerk?.user; - }); + toBeSignedOut: (args?: { timeOut: number }) => { + return page.waitForFunction( + () => { + return !window.Clerk?.user; + }, + null, + { timeout: args?.timeOut }, + ); }, toBeSignedIn: async () => { return page.waitForFunction(() => { diff --git a/integration/tests/sign-out-smoke.test.ts b/integration/tests/sign-out-smoke.test.ts index d2c57c819a5..d24bf937f1b 100644 --- a/integration/tests/sign-out-smoke.test.ts +++ b/integration/tests/sign-out-smoke.test.ts @@ -55,7 +55,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign out await m.po.expect.toBeSignedOut(); }); - await mainTab.po.expect.toBeSignedOut(); + await mainTab.po.expect.toBeSignedOut({ timeOut: 2 * 1_000 }); }); test('sign out persisting client', async ({ page, context }) => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d74720f50af..05853300d2b 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -374,6 +374,9 @@ export class Clerk implements ClerkInterface { const handleSetActive = () => { const signOutCallback = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined; + + // Notify other tabs that user is signing out. + eventBus.dispatch(events.UserSignOut, null); if (signOutCallback) { return this.setActive({ session: null, @@ -908,14 +911,6 @@ export class Clerk implements ClerkInterface { await onBeforeSetActive(); - // If this.session exists, then signOut was triggered by the current tab - // and should emit. Other tabs should not emit the same event again - const shouldSignOutSession = this.session && newSession === null; - if (shouldSignOutSession) { - this.#broadcastSignOutEvent(); - eventBus.dispatch(events.TokenUpdate, { token: null }); - } - //1. setLastActiveSession to passed user session (add a param). // Note that this will also update the session's active organization // id. @@ -1534,6 +1529,7 @@ export class Clerk implements ClerkInterface { }); }; + // TODO: Deprecate this one, and mark it as internal. Is there actual benefit for external developers to use this ? Should they ever reach for it ? public handleUnauthenticated = async (opts = { broadcast: true }): Promise => { if (!this.client || !this.session) { return; @@ -1545,7 +1541,7 @@ export class Clerk implements ClerkInterface { return; } if (opts.broadcast) { - this.#broadcastSignOutEvent(); + eventBus.dispatch(events.UserSignOut, null); } return this.setActive({ session: null }); } catch (err) { @@ -2061,11 +2057,21 @@ export class Clerk implements ClerkInterface { this.#sessionTouchOfflineScheduler.schedule(performTouch); }); + /** + * Background tabs get notified of a signout event from active tab. + */ this.#broadcastChannel?.addEventListener('message', ({ data }) => { if (data.type === 'signout') { - void this.handleUnauthenticated(); + void this.handleUnauthenticated({ broadcast: false }); } }); + + /** + * Allow resources within the singleton to notify other tabs about a signout event (scoped to a single tab) + */ + eventBus.on(events.UserSignOut, () => { + this.#broadcastChannel?.postMessage({ type: 'signout' }); + }); }; // TODO: Be more conservative about touches. Throttle, don't touch when only one user, etc @@ -2100,10 +2106,6 @@ export class Clerk implements ClerkInterface { } }; - #broadcastSignOutEvent = () => { - this.#broadcastChannel?.postMessage({ type: 'signout' }); - }; - #setTransitiveState = () => { this.session = undefined; this.organization = undefined; diff --git a/packages/clerk-js/src/core/events.ts b/packages/clerk-js/src/core/events.ts index fb5ecc263cd..7401dd91370 100644 --- a/packages/clerk-js/src/core/events.ts +++ b/packages/clerk-js/src/core/events.ts @@ -2,6 +2,7 @@ import type { TokenResource } from '@clerk/types'; export const events = { TokenUpdate: 'token:update', + UserSignOut: 'user:signOut', } as const; type ClerkEvent = (typeof events)[keyof typeof events]; @@ -11,6 +12,7 @@ type TokenUpdatePayload = { token: TokenResource | null }; type EventPayload = { [events.TokenUpdate]: TokenUpdatePayload; + [events.UserSignOut]: null; }; const createEventBus = () => { diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index b7558c49a07..fb0b9b707c7 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -83,7 +83,10 @@ export class Client extends BaseResource implements ClientResource { removeSessions(): Promise { return this._baseDelete({ path: this.path() + '/sessions', - }) as unknown as Promise; + }).then(e => { + SessionTokenCache.clear(); + return e as unknown as ClientResource; + }); } clearCache(): void { diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index e8c491707b7..10d35d5c644 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -35,6 +35,7 @@ import type { import { unixEpochToDate } from '../../utils/date'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; import { getFullName } from '../../utils/user'; +import { eventBus, events } from '../events'; import { BackupCode } from './BackupCode'; import { BaseResource, @@ -241,7 +242,10 @@ export class User extends BaseResource implements UserResource { }; delete = (): Promise => { - return this._baseDelete({ path: '/me' }); + return this._baseDelete({ path: '/me' }).then(res => { + eventBus.dispatch(events.UserSignOut, null); + return res; + }); }; getSessions = async (): Promise => {