diff --git a/.changeset/mean-jobs-stare.md b/.changeset/mean-jobs-stare.md new file mode 100644 index 00000000000..b8e22051f34 --- /dev/null +++ b/.changeset/mean-jobs-stare.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Trigger Next.js hooks on session status transition from `active` to `pending` to update authentication context state diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index f26a547947d..8109c5e5faa 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2543,4 +2543,68 @@ describe('Clerk singleton', () => { }); }); }); + + describe('updateClient', () => { + afterEach(() => { + // cleanup global window pollution + (window as any).__unstable__onBeforeSetActive = null; + (window as any).__unstable__onAfterSetActive = null; + }); + + it('runs server revalidation hooks when session transitions from `active` to `pending`', async () => { + const mockOnBeforeSetActive = jest.fn().mockReturnValue(Promise.resolve()); + const mockOnAfterSetActive = jest.fn().mockReturnValue(Promise.resolve()); + (window as any).__unstable__onBeforeSetActive = mockOnBeforeSetActive; + (window as any).__unstable__onAfterSetActive = mockOnAfterSetActive; + + const mockActiveSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1' }, + lastActiveToken: { getRawString: () => 'token_1' }, + }; + + const mockPendingSession = { + id: 'session_1', + status: 'pending', + user: { id: 'user_1' }, + lastActiveToken: { getRawString: () => 'token_1' }, + }; + + const mockInitialClient = { + sessions: [mockActiveSession], + signedInSessions: [mockActiveSession], + lastActiveSessionId: 'session_1', + }; + + const mockUpdatedClient = { + sessions: [mockPendingSession], + signedInSessions: [mockPendingSession], + lastActiveSessionId: 'session_1', + }; + + const sut = new Clerk(productionPublishableKey); + + // Manually set the initial client and session state to simulate active session + // without going through load() or setActive() + sut.updateClient(mockInitialClient as any); + + // Verify we start with an active session + expect(sut.session?.status).toBe('active'); + + // Call updateClient with the new client that has pending session + sut.updateClient(mockUpdatedClient as any); + + // Verify hooks were called + await waitFor(() => { + expect(mockOnBeforeSetActive).toHaveBeenCalledTimes(1); + expect(mockOnAfterSetActive).toHaveBeenCalledTimes(1); + }); + + // Verify that onAfterSetActive was called after onBeforeSetActive + const beforeCallTime = mockOnBeforeSetActive.mock.invocationCallOrder[0]; + const afterCallTime = mockOnAfterSetActive.mock.invocationCallOrder[0]; + expect(afterCallTime).toBeGreaterThan(beforeCallTime); + }); + }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b90343e8806..1e549bcb3b6 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2258,6 +2258,23 @@ export class Clerk implements ClerkInterface { if (this.session) { const session = this.#getSessionFromClient(this.session.id); + const hasTransitionedToPendingStatus = this.session.status === 'active' && session?.status === 'pending'; + if (hasTransitionedToPendingStatus) { + 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; + + // Execute hooks to update server authentication context and trigger + // page protections in clerkMiddleware or server components + void onBeforeSetActive()?.then?.(() => void onAfterSetActive()); + } + // Note: this might set this.session to null this.#setAccessors(session);