diff --git a/.changeset/internal-use-token-signal.md b/.changeset/internal-use-token-signal.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/internal-use-token-signal.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/SIGNALS_REACTIVITY_PROPOSAL.md b/docs/SIGNALS_REACTIVITY_PROPOSAL.md new file mode 100644 index 00000000000..5421d6b27be --- /dev/null +++ b/docs/SIGNALS_REACTIVITY_PROPOSAL.md @@ -0,0 +1,346 @@ +# Proposal: Signals-Based Reactivity Architecture + +## Summary + +Clerk currently uses signals as a thin notification layer for a small set of custom-flow resources: sign-in, sign-up, waitlist, and checkout. The approach works for the current public hooks, but it is not yet a coherent reactivity architecture. The core issue is that the reactive state is partly global, partly instance-local, and partly hidden inside resource mutation side effects. + +The proposed direction is to make reactivity an explicit per-`Clerk` instance store with typed domains, lifecycle ownership, selector-based subscriptions, and framework adapters. This keeps the public hooks stable while removing global state leakage, ambiguous event ordering, and duplicated reactivity patterns. + +## Current Architecture + +### Sign-in, sign-up, and waitlist + +The main custom-flow signals live in `packages/clerk-js/src/core/signals.ts`: + +- `signInResourceSignal`, `signInErrorSignal`, `signInFetchSignal` +- `signUpResourceSignal`, `signUpErrorSignal`, `signUpFetchSignal` +- `waitlistResourceSignal`, `waitlistErrorSignal`, `waitlistFetchSignal` +- computed signals that derive `{ resource, errors, fetchStatus }` + +`packages/clerk-js/src/core/state.ts` exposes those module-level signals through a `State` class. It subscribes to the global internal `eventBus` and updates the correct signal by checking `resource instanceof SignIn`, `SignUp`, or `Waitlist`. + +Resources emit `resource:update` from `fromJSON()`, including during construction. Async custom-flow operations use `runAsyncResourceTask()` to emit `resource:error` and `resource:fetch` around the request. + +React consumes these signals in `packages/react/src/hooks/useClerkSignal.ts` with `useSyncExternalStore()`. The subscription is implemented by creating an `alien-signals` `effect()` that reads the computed signal and then invokes React's callback. + +### Checkout + +Checkout uses a separate architecture in `packages/clerk-js/src/core/resources/BillingCheckout.ts` and `packages/clerk-js/src/core/modules/checkout/instance.ts`: + +- signals are created per checkout flow via `createSignals()` +- writes are batched with `startBatch()` and `endBatch()` +- instances are cached in a module-level `Map` by user/org/plan/period + +This is closer to the desired direction, but still has global cache lifetime concerns and does not share much structure with sign-in/sign-up/waitlist. + +### Other packages + +Astro uses nanostores. Vue and Nuxt use their framework-native `computed()` APIs. React has a `StateProxy` that exposes pre-load placeholder resources and throws for `__internal_effect()` / `__internal_computed()` before Clerk is loaded. + +## Strengths + +- The public React custom-flow hooks are simple and align with React's external-store contract instead of relying on React context value churn. +- Signals are hidden behind Clerk APIs, so the chosen signal library is not directly part of the public API. +- The split between resource, error, and fetch signals is conceptually good. It gives enough state to support ergonomic custom flows without forcing consumers to handle exceptions for expected API errors. +- Checkout already demonstrates useful patterns that should be generalized: local signal creation, batched writes, operation deduplication, and a cache key that models flow identity. + +## Critical Issues + +### 1. Signal state is module-global, not Clerk-instance-local + +The sign-in, sign-up, and waitlist signals are exported module singletons. Every `State` instance points at the same signal functions. This creates several risks: + +- multiple Clerk instances in the same JavaScript realm can observe or overwrite each other's flow state +- tests need to manually reset global signals, which is a symptom of hidden shared state +- publishable-key changes, multi-domain setups, browser extension contexts, and embedded multi-app scenarios have no clear state boundary +- the `State` constructor registers event handlers but there is no disposal path, so multiple `State` instances can accumulate event listeners + +This is the highest-priority architectural problem. Auth state is instance-scoped; reactivity should be instance-scoped too. + +### 2. Resource constructors and deserializers have hidden reactive side effects + +`SignIn.fromJSON()`, `SignUp.fromJSON()`, and `Waitlist.fromJSON()` emit `resource:update`. That means parsing a response, constructing a placeholder, or calling `new SignIn(null)` can mutate global reactive state. + +This makes control flow hard to reason about: + +- construction is not pure +- null/empty resources are real updates, so `State` needs special `shouldIgnoreNullUpdate()` logic +- tests must know about event emission from constructors +- resource deserialization order becomes reactive update order + +Resource mutation should be explicit. Parsing JSON should create or update a resource; the store should decide whether and how that resource becomes observable state. + +### 3. The event bus is too broad for store updates + +The `eventBus` carries generic `resource:update`, `resource:error`, and `resource:fetch` events. `State` then routes events with `instanceof` checks. This works for a small number of resource types, but it does not scale well. + +Problems: + +- no type-level relationship between event payload and target state slice +- all `State` instances hear all resource events +- adding a new reactive resource requires touching central routing and exports +- operation identity is collapsed into one `fetchStatus`, so overlapping operations can race +- reset/finalize edge cases leak into the state reducer as resource-specific heuristics + +A store reducer should receive typed domain events such as `signIn/resourceUpdated`, `signIn/fetchStarted`, or `checkout/operationFinished`, scoped to one Clerk instance. + +### 4. Signals wrap mutable resource objects rather than stable snapshots + +The computed signals return objects containing mutable resources or future wrappers. React rerenders because the wrapper signal receives a new object, not because nested resource fields are signal-aware. + +This gives the cost of a signal dependency graph without getting fine-grained benefits: + +- all consumers of `useSignIn()` rerender for any sign-in change +- nested resource reads are not individually tracked +- mutable resource identity makes stale-reference behavior subtle +- public values are a mix of state and imperative methods + +This is acceptable for the first version of custom-flow hooks, but it should not become the long-term model for all Clerk reactivity. + +### 5. React subscription semantics are indirect + +`useClerkSignal()` subscribes by creating an `effect()` that reads the computed signal and calls the React callback. This couples React's external-store bridge to `alien-signals` internals. + +Concerns: + +- the effect invokes the callback when it is created, which can cause extra subscription-time renders +- there is no selector/equality layer, so the hook subscribes to the whole computed value +- telemetry is recorded during render, so re-renders can record repeated hook calls +- `getSnapshot` and `getServerSnapshot` are the same callback, even though pre-load and SSR behavior is special-cased elsewhere through `StateProxy` + +React should depend on a small store interface: `getSnapshot()`, `subscribe()`, and optionally `select()`. The signal library should be an implementation detail of that store. + +### 6. Checkout is better isolated but has unbounded global cache lifetime + +Checkout's per-flow signals are a good direction, but `CheckoutSignalCache` is module-global and not tied to Clerk instance lifecycle. It is keyed by user/org/plan/period, but not by Clerk instance identity or publishable key. There is also no eviction path on sign-out, organization switch, completed flow, or instance teardown. + +The checkout cache should be owned by the instance store, keyed by instance plus flow identity, and cleared on auth boundary changes. + +### 7. The architecture is inconsistent across frameworks + +React consumes `alien-signals` through `useSyncExternalStore()`. Astro exposes nanostores. Vue uses Vue computed refs. Checkout has its own mini-store. The same product concepts are represented differently in each package. + +The core package should expose a framework-neutral reactive store contract. Framework packages should adapt that store to React, Vue, Astro, and Nuxt idioms. + +## Target Architecture + +### 1. Introduce a per-instance `ReactiveStateStore` + +Each `Clerk` instance should own one reactive store: + +```ts +type Unsubscribe = () => void; + +interface ReactiveStateStore { + getSnapshot(): ClerkReactiveSnapshot; + subscribe(listener: () => void): Unsubscribe; + select(selector: (snapshot: ClerkReactiveSnapshot) => T, equality?: (a: T, b: T) => boolean): StoreView; + dispatch(event: ClerkReactiveEvent): void; + dispose(): void; +} +``` + +Internally this can still use `alien-signals`, but the rest of the SDK should depend on the store contract rather than direct `effect()` / `computed()` exports. + +### 2. Model each reactive domain as a typed slice + +Start with four slices: + +- `signIn` +- `signUp` +- `waitlist` +- `checkout` + +Each slice should have: + +- `resource` +- `errors` +- `operations` +- `revision` +- explicit reset/finalize/discard state + +`fetchStatus: 'idle' | 'fetching'` can remain for compatibility, but internally it should be derived from operation state. This avoids races when two operations overlap. + +Example: + +```ts +type OperationStatus = 'idle' | 'fetching'; + +interface FlowSlice { + resource: Resource | null; + errors: Errors; + operations: Record; + revision: number; + discardable: boolean; +} +``` + +### 3. Make resource parsing pure + +Remove `eventBus.emit('resource:update')` from `fromJSON()` over time. Resource constructors and deserializers should only mutate the resource instance. + +The operation layer should explicitly dispatch after a successful resource update: + +```ts +const result = await resource.__internal_basePost(params); +store.dispatch({ type: 'signIn/resourceUpdated', resource: result }); +``` + +For compatibility, an interim bridge can continue listening to the existing event bus, but it should be treated as a migration adapter, not the primary architecture. + +### 4. Replace global `runAsyncResourceTask()` with slice-owned operation runners + +The async runner should accept a store slice target instead of emitting global events: + +```ts +async function runFlowOperation({ + store, + slice, + operation, + task, +}: { + store: ReactiveStateStore; + slice: 'signIn' | 'signUp' | 'waitlist'; + operation: string; + task: () => Promise; +}) { + store.dispatch({ type: `${slice}/operationStarted`, operation }); + try { + const result = await task(); + store.dispatch({ type: `${slice}/operationSucceeded`, operation, result }); + return { result, error: null }; + } catch (error) { + store.dispatch({ type: `${slice}/operationFailed`, operation, error }); + return { error }; + } finally { + store.dispatch({ type: `${slice}/operationSettled`, operation }); + } +} +``` + +This makes batching, error clearing, and operation deduplication consistent across sign-in, sign-up, waitlist, and checkout. + +### 5. Return stable public facades backed by snapshots + +The public hook return shape can remain the same, but it should be assembled from a store snapshot and stable method facade. The resource state should be observable as data; imperative methods should be stable wrappers that dispatch operations. + +This avoids re-creating method trees and makes it possible to add selector hooks later: + +```ts +const signIn = useClerkStore(s => s.signIn.publicValue); +const fetchStatus = useClerkStore(s => s.signIn.fetchStatus); +const identifierError = useClerkStore(s => s.signIn.errors.fields.identifier); +``` + +The initial public API does not need to expose selectors, but the internal adapter should be built this way. + +### 6. Move framework adapters to the edge + +Core should expose the same store to every framework package. Framework-specific packages should adapt it: + +- React: `useSyncExternalStoreWithSelector()` +- Vue/Nuxt: `computed()` wrappers around selected store views +- Astro: nanostore wrappers that subscribe to selected store views + +This gives each framework idiomatic APIs without duplicating state semantics. + +### 7. Own checkout cache inside the store + +Checkout flow instances should be cached under the instance store: + +```ts +store.checkout.getOrCreate({ + userId, + orgId, + planId, + planPeriod, +}); +``` + +Cache invalidation should happen on: + +- sign-out +- user switch +- organization switch when the flow is organization-scoped +- publishable-key or instance replacement +- flow completion, after a short retention window if needed for UI continuity +- `store.dispose()` + +## Migration Plan + +### Phase 0: Document and instrument current behavior + +- Add tests that prove two Clerk instances cannot share sign-in/sign-up/waitlist state. +- Add tests for listener disposal when a `State` instance is replaced. +- Add tests for React Strict Mode subscription behavior. +- Add tests for overlapping operations, especially send-code plus verify-code flows. +- Add checkout cache tests for sign-out and organization switch. + +These tests will likely expose current gaps. That is useful; mark incompatible expectations as skipped or todo if they must wait for the store migration. + +### Phase 1: Stop exporting module-level singleton signals as state ownership + +- Replace `signals.ts` singleton exports with a `createFlowSignals()` factory. +- Have `State` construct its own signal set. +- Keep the public `State` interface unchanged. +- Add `State.dispose()` and unregister event bus listeners. +- Ensure tests no longer reset imported signal singletons. + +This is the minimum viable fix for cross-instance leakage. + +### Phase 2: Introduce the `ReactiveStateStore` behind `State` + +- Implement the store contract in `@clerk/clerk-js`. +- Make `State` a compatibility facade over the store. +- Move error parsing and fetch state derivation into reducers/selectors. +- Keep `__internal_effect` and `__internal_computed` temporarily, but stop requiring framework packages to call them directly. + +### Phase 3: Move resource updates out of constructors/deserializers + +- Add explicit dispatches in the operation layer. +- Keep a temporary event-bus bridge for legacy paths that still emit from `fromJSON()`. +- Convert sign-in, sign-up, and waitlist flows first. +- Convert checkout to use the same operation runner and store-owned cache. + +### Phase 4: Add selector-based framework adapters + +- Replace React's direct signal effect bridge with a store selector hook. +- Adapt Astro nanostores and Vue computed refs from the same core store. +- Add internal selector tests to verify minimal rerenders for common fields. + +### Phase 5: Remove legacy internals + +- Remove resource `fromJSON()` event emissions. +- Remove the flow-related global event bus bridge. +- Deprecate or narrow `__internal_effect` and `__internal_computed` on public-ish internal types. +- Remove module-global checkout cache. + +## Compatibility + +Public hooks should keep their current return shapes: + +- `useSignIn()` +- `useSignUp()` +- `useWaitlist()` +- `useCheckout()` + +The migration should not require users to understand signals. Signals remain an implementation detail. + +The main internal compatibility risk is code that reaches into `clerk.__internal_state.__internal_effect` or `__internal_computed`. Those APIs are marked internal/experimental and should be preserved during migration, then narrowed once framework adapters no longer use them. + +## Open Questions + +- Should Clerk expose selector hooks publicly, or keep them internal for now? +- Should operation state remain one public `fetchStatus`, or should future public APIs expose named operation statuses? +- How long should completed checkout flows stay cached for UI continuity? +- Should resources remain mutable internally, or should new custom-flow resources move toward immutable snapshots over time? +- Should the event bus remain only for cross-cutting notifications such as token/session/environment updates? + +## Recommendation + +Do the migration in two tracks: + +1. Fix ownership first: make signals and checkout caches per Clerk instance, add disposal, and test multi-instance isolation. +2. Then improve semantics: make resource parsing pure, move operation state into typed reducers, and adapt framework packages through a common selector-based store. + +This sequence addresses the riskiest architecture problem without forcing a large public API redesign. It also gives Clerk a path to use signals where they are valuable, while avoiding a long-term dependency on global reactive side effects. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 979cf6e24fa..ad8e33a09a1 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -197,6 +197,7 @@ describe('Clerk singleton', () => { await waitFor(() => { expect(mockSession.touch).not.toHaveBeenCalled(); expect(eventBusSpy).toHaveBeenCalledWith('token:update', { token: null }); + expect(sut.__internal_state.sessionTokenSignal()).toEqual({ isLoaded: true, token: null }); }); }); @@ -2721,6 +2722,39 @@ describe('Clerk singleton', () => { }); }); + it('updates the active session token signal from the active session token', () => { + const mockSession = { + id: 'session_1', + status: 'active', + user: { id: 'user_1', organizationMemberships: [] }, + lastActiveToken: { getRawString: () => 'token_1' }, + }; + + const mockClient = { + sessions: [mockSession], + signedInSessions: [mockSession], + lastActiveSessionId: 'session_1', + }; + + const sut = new Clerk(productionPublishableKey); + sut.updateClient(mockClient as any); + + expect(sut.__internal_state.sessionTokenSignal()).toEqual({ isLoaded: true, token: 'token_1' }); + }); + + it('sets the active session token signal to null when there is no active session', () => { + const mockClient = { + sessions: [], + signedInSessions: [], + lastActiveSessionId: null, + }; + + const sut = new Clerk(productionPublishableKey); + sut.updateClient(mockClient as any); + + expect(sut.__internal_state.sessionTokenSignal()).toEqual({ isLoaded: true, token: null }); + }); + it('does not emit to listeners when __internal_dangerouslySkipEmit is true', () => { const mockSession = { id: 'session_1', diff --git a/packages/clerk-js/src/core/__tests__/state.test.ts b/packages/clerk-js/src/core/__tests__/state.test.ts index 7d274a33be8..a7fb79528fc 100644 --- a/packages/clerk-js/src/core/__tests__/state.test.ts +++ b/packages/clerk-js/src/core/__tests__/state.test.ts @@ -1,3 +1,4 @@ +import type { TokenResource } from '@clerk/shared/types'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { eventBus } from '../events'; @@ -31,6 +32,55 @@ describe('State', () => { SignIn.clerk = originalSignInClerk; }); + describe('sessionTokenSignal', () => { + const createToken = (token: string): TokenResource => + ({ + getRawString: () => token, + }) as TokenResource; + + it('starts unloaded', () => { + expect(_state.sessionTokenSignal()).toEqual({ isLoaded: false, token: undefined }); + }); + + it('stores loaded null and valid token states', () => { + _state.__internal_setSessionToken(null); + expect(_state.sessionTokenSignal()).toEqual({ isLoaded: true, token: null }); + + _state.__internal_setSessionToken(createToken('token_1')); + expect(_state.sessionTokenSignal()).toEqual({ isLoaded: true, token: 'token_1' }); + }); + + it('treats empty token strings as loaded null state', () => { + _state.__internal_setSessionToken(createToken('')); + expect(_state.sessionTokenSignal()).toEqual({ isLoaded: true, token: null }); + }); + + it('can return to unloaded state', () => { + _state.__internal_setSessionToken(createToken('token_1')); + _state.__internal_setSessionToken(undefined); + + expect(_state.sessionTokenSignal()).toEqual({ isLoaded: false, token: undefined }); + }); + + it('does not notify subscribers when the raw token value is unchanged', () => { + const listener = vi.fn(); + const unsubscribe = _state.__internal_effect(() => { + _state.sessionTokenSignal(); + listener(); + }); + + listener.mockClear(); + _state.__internal_setSessionToken(createToken('token_1')); + expect(listener).toHaveBeenCalledTimes(1); + + listener.mockClear(); + _state.__internal_setSessionToken(createToken('token_1')); + expect(listener).not.toHaveBeenCalled(); + + unsubscribe(); + }); + }); + describe('shouldIgnoreNullUpdate behavior', () => { describe('SignUp', () => { it('should allow first resource update when previous resource is null', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index ac66ed01948..f161dd5fbd6 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -3275,6 +3275,7 @@ export class Clerk implements ClerkInterface { this.session = newSession; this.organization = organization; this.user = user; + this.__internal_state.__internal_setSessionToken(this.session?.lastActiveToken ?? null); if (!options?.dangerouslySkipEmit) { this.#emit(); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 068dfe1ea41..a9768690537 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -458,7 +458,7 @@ export class Session extends BaseResource implements SessionResource { // Only emit token updates when we have an actual token — emitting with an empty // token causes AuthCookieService to remove the __session cookie (looks like sign-out). if (shouldDispatchTokenUpdate && cachedToken.getRawString()) { - eventBus.emit(events.TokenUpdate, { token: cachedToken }); + this.#dispatchTokenUpdate(cachedToken, shouldDispatchTokenUpdate); } result = cachedToken.getRawString() || null; } else if (!isBrowserOnline()) { @@ -507,18 +507,27 @@ export class Session extends BaseResource implements SessionResource { }); } - #dispatchTokenEvents(token: TokenResource, shouldDispatch: boolean): void { + #dispatchTokenUpdate(token: TokenResource, shouldDispatch: boolean): boolean { if (!shouldDispatch) { - return; + return false; } // Never dispatch empty tokens — this would cause AuthCookieService to remove // the __session cookie even though the user is still authenticated. if (!token.getRawString()) { - return; + return false; } + Session.clerk?.__internal_state?.__internal_setSessionToken(token); eventBus.emit(events.TokenUpdate, { token }); + return true; + } + + #dispatchTokenEvents(token: TokenResource, shouldDispatch: boolean): void { + const didDispatchTokenUpdate = this.#dispatchTokenUpdate(token, shouldDispatch); + if (!didDispatchTokenUpdate) { + return; + } if (token.jwt) { this.lastActiveToken = token; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index aee7f42f614..bea5cc20f98 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -74,6 +74,29 @@ describe('Session', () => { ]); }); + it('updates the active session token state on getToken without active organization', async () => { + const setSessionToken = vi.fn(); + BaseResource.clerk = clerkMock({ + __internal_state: { __internal_setSessionToken: setSessionToken } as any, + }); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await session.getToken(); + + expect(setSessionToken).toHaveBeenCalledTimes(1); + expect(setSessionToken.mock.calls[0]?.[0].getRawString()).toBe(mockJwt); + }); + it('hydrates token cache from lastActiveToken', async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), @@ -199,8 +222,10 @@ describe('Session', () => { }); it('does not dispatch token:update if template is provided', async () => { + const setSessionToken = vi.fn(); BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), + __internal_state: { __internal_setSessionToken: setSessionToken } as any, }); const session = new Session({ @@ -217,6 +242,7 @@ describe('Session', () => { await session.getToken({ template: 'foobar' }); expect(dispatchSpy).toHaveBeenCalledTimes(0); + expect(setSessionToken).not.toHaveBeenCalled(); }); it('dispatches token:update when provided organization ID matches current active organization', async () => { @@ -241,7 +267,10 @@ describe('Session', () => { }); it('does not dispatch token:update when provided organization ID does not match current active organization', async () => { - BaseResource.clerk = clerkMock(); + const setSessionToken = vi.fn(); + BaseResource.clerk = clerkMock({ + __internal_state: { __internal_setSessionToken: setSessionToken } as any, + }); const session = new Session({ status: 'active', @@ -257,6 +286,7 @@ describe('Session', () => { await session.getToken({ organizationId: 'anotherOrganization' }); expect(dispatchSpy).toHaveBeenCalledTimes(0); + expect(setSessionToken).not.toHaveBeenCalled(); }); describe('with offline browser and network failure', () => { @@ -654,7 +684,10 @@ describe('Session', () => { }); it('uses refreshed token after timer-triggered refresh succeeds', async () => { - BaseResource.clerk = clerkMock(); + const setSessionToken = vi.fn(); + BaseResource.clerk = clerkMock({ + __internal_state: { __internal_setSessionToken: setSessionToken } as any, + }); const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; const newMockJwt = @@ -686,6 +719,8 @@ describe('Session', () => { const freshToken = await session.getToken(); expect(freshToken).toEqual(newMockJwt); expect(requestSpy).not.toHaveBeenCalled(); + expect(setSessionToken).toHaveBeenCalled(); + expect(setSessionToken.mock.calls.at(-1)?.[0].getRawString()).toBe(newMockJwt); }); it('does not emit token:update with an empty token when background refresh fires while offline', async () => { diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index fb5909f6bba..7c882c78584 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -1,6 +1,6 @@ import type { ClerkError } from '@clerk/shared/error'; -import type { State as StateInterface } from '@clerk/shared/types'; -import { computed, effect } from 'alien-signals'; +import type { SessionTokenSignalValue, State as StateInterface, TokenResource } from '@clerk/shared/types'; +import { computed, effect, signal } from 'alien-signals'; import { eventBus } from './events'; import type { BaseResource } from './resources/Base'; @@ -23,6 +23,8 @@ import { } from './signals'; export class State implements StateInterface { + private sessionTokenInternalSignal = signal({ isLoaded: false, token: undefined }); + signInResourceSignal = signInResourceSignal; signInErrorSignal = signInErrorSignal; signInFetchSignal = signInFetchSignal; @@ -38,6 +40,8 @@ export class State implements StateInterface { waitlistFetchSignal = waitlistFetchSignal; waitlistSignal = waitlistComputedSignal; + sessionTokenSignal = computed(() => this.sessionTokenInternalSignal()); + private _waitlistInstance: Waitlist; __internal_effect = effect; @@ -56,6 +60,17 @@ export class State implements StateInterface { return this._waitlistInstance; } + __internal_setSessionToken = (token: TokenResource | null | undefined) => { + const nextValue = token === undefined ? unloadedTokenSignalValue : loadedTokenSignalValue(token?.getRawString()); + const previousValue = this.sessionTokenInternalSignal(); + + if (previousValue.isLoaded === nextValue.isLoaded && previousValue.token === nextValue.token) { + return; + } + + this.sessionTokenInternalSignal(nextValue); + }; + private onResourceError = (payload: { resource: BaseResource; error: ClerkError | null }) => { if (payload.resource instanceof SignIn) { this.signInErrorSignal({ error: payload.error }); @@ -108,6 +123,12 @@ export class State implements StateInterface { }; } +const unloadedTokenSignalValue = { isLoaded: false, token: undefined } as const; + +function loadedTokenSignalValue(token: string | null | undefined): SessionTokenSignalValue { + return { isLoaded: true, token: token || null }; +} + /** * Returns true if the new resource is null and the previous resource cannot be discarded. This is used to prevent * nullifying the resource after it's been completed or explicitly reset. diff --git a/packages/react/src/hooks/__tests__/useToken.test.tsx b/packages/react/src/hooks/__tests__/useToken.test.tsx new file mode 100644 index 00000000000..6f0a2a69508 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useToken.test.tsx @@ -0,0 +1,102 @@ +import { ClerkInstanceContext } from '@clerk/shared/react'; +import type { LoadedClerk, SessionTokenSignalValue } from '@clerk/shared/types'; +import { act, renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { __internal_useToken } from '../useToken'; + +function createClerkHarness(initialValue: SessionTokenSignalValue, loaded = true) { + let current = initialValue; + const listeners = new Set<() => void>(); + const getToken = vi.fn(); + + const state = { + sessionTokenSignal: vi.fn(() => current), + __internal_effect: vi.fn((callback: () => void) => { + const listener = () => callback(); + listeners.add(listener); + callback(); + return () => listeners.delete(listener); + }), + }; + + const clerk = { + loaded, + session: { getToken }, + __internal_state: state, + } as unknown as LoadedClerk; + + return { + clerk, + getToken, + state, + setValue(value: SessionTokenSignalValue) { + current = value; + listeners.forEach(listener => listener()); + }, + }; +} + +function createWrapper(clerk: LoadedClerk) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +describe('__internal_useToken', () => { + it('returns unloaded token state before Clerk loads', () => { + const harness = createClerkHarness({ isLoaded: false, token: undefined }, false); + + const { result } = renderHook(() => __internal_useToken(), { + wrapper: createWrapper(harness.clerk), + }); + + expect(result.current).toEqual({ isLoaded: false, token: undefined }); + expect(harness.state.__internal_effect).not.toHaveBeenCalled(); + }); + + it('returns loaded null token state when signed out', () => { + const harness = createClerkHarness({ isLoaded: true, token: null }); + + const { result } = renderHook(() => __internal_useToken(), { + wrapper: createWrapper(harness.clerk), + }); + + expect(result.current).toEqual({ isLoaded: true, token: null }); + }); + + it('returns the active session token when present', () => { + const harness = createClerkHarness({ isLoaded: true, token: 'token_1' }); + + const { result } = renderHook(() => __internal_useToken(), { + wrapper: createWrapper(harness.clerk), + }); + + expect(result.current).toEqual({ isLoaded: true, token: 'token_1' }); + }); + + it('re-renders when the token signal updates', () => { + const harness = createClerkHarness({ isLoaded: true, token: 'token_1' }); + + const { result } = renderHook(() => __internal_useToken(), { + wrapper: createWrapper(harness.clerk), + }); + + act(() => { + harness.setValue({ isLoaded: true, token: 'token_2' }); + }); + + expect(result.current).toEqual({ isLoaded: true, token: 'token_2' }); + }); + + it('does not call getToken on mount', () => { + const harness = createClerkHarness({ isLoaded: true, token: 'token_1' }); + + renderHook(() => __internal_useToken(), { + wrapper: createWrapper(harness.clerk), + }); + + expect(harness.getToken).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/hooks/useToken.ts b/packages/react/src/hooks/useToken.ts new file mode 100644 index 00000000000..7d1a4a76a8d --- /dev/null +++ b/packages/react/src/hooks/useToken.ts @@ -0,0 +1,33 @@ +import type { SessionTokenSignalValue } from '@clerk/shared/types'; +import { useCallback, useSyncExternalStore } from 'react'; + +import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; +import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider'; + +const useToken = (): SessionTokenSignalValue => { + useAssertWrappedByClerkProvider('__internal_useToken'); + + const clerk = useIsomorphicClerkContext(); + + const subscribe = useCallback( + (callback: () => void) => { + if (!clerk.loaded) { + return () => {}; + } + + return clerk.__internal_state.__internal_effect(() => { + clerk.__internal_state.sessionTokenSignal(); + callback(); + }); + }, + [clerk], + ); + + const getSnapshot = useCallback(() => { + return clerk.__internal_state.sessionTokenSignal(); + }, [clerk]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; + +export { useToken as __internal_useToken }; diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts index c723b95ce33..cf904dbf25e 100644 --- a/packages/react/src/internal.ts +++ b/packages/react/src/internal.ts @@ -25,6 +25,7 @@ export { OAuthConsent }; export { useRoutingProps } from './hooks/useRoutingProps'; export { useDerivedAuth } from './hooks/useAuth'; +export { __internal_useToken } from './hooks/useToken'; export { IS_REACT_SHARED_VARIANT_COMPATIBLE } from './utils/versionCheck'; export { diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index a51dbdc5519..9410d9103c9 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -4,6 +4,7 @@ import type { CheckoutSignalValue, Clerk, ForPayerType, + SessionTokenSignalValue, SignInErrors, SignUpErrors, SignUpVerificationResource, @@ -113,6 +114,7 @@ export class StateProxy implements State { private readonly signInSignalProxy = this.buildSignInProxy(); private readonly signUpSignalProxy = this.buildSignUpProxy(); private readonly waitlistSignalProxy = this.buildWaitlistProxy(); + private readonly sessionTokenSignalProxy: SessionTokenSignalValue = { isLoaded: false, token: undefined }; signInSignal() { return this.signInSignalProxy; @@ -123,6 +125,9 @@ export class StateProxy implements State { waitlistSignal() { return this.waitlistSignalProxy; } + sessionTokenSignal() { + return this.sessionTokenSignalProxy; + } get __internal_waitlist() { return this.state.__internal_waitlist; @@ -443,6 +448,9 @@ export class StateProxy implements State { __internal_computed(_: (prev?: T) => T): () => T { throw new Error('__internal_computed called before Clerk is loaded'); } + __internal_setSessionToken(): void { + throw new Error('__internal_setSessionToken called before Clerk is loaded'); + } private get state() { const s = this.isomorphicClerk.__internal_state; diff --git a/packages/shared/src/types/state.ts b/packages/shared/src/types/state.ts index 0ebcbc17b36..db2b9d41740 100644 --- a/packages/shared/src/types/state.ts +++ b/packages/shared/src/types/state.ts @@ -1,6 +1,7 @@ import type { ClerkGlobalHookError } from '../errors/globalHookError'; import type { SignInFutureResource } from './signInFuture'; import type { SignUpFutureResource } from './signUpFuture'; +import type { TokenResource } from './token'; import type { WaitlistResource } from './waitlist'; /** @@ -198,6 +199,20 @@ export interface WaitlistSignal { (): NullableWaitlistSignal; } +export type SessionTokenSignalValue = + | { + isLoaded: false; + token: undefined; + } + | { + isLoaded: true; + token: string | null; + }; + +export interface SessionTokenSignal { + (): SessionTokenSignalValue; +} + export interface State { /** * A Signal that updates when the underlying `SignIn` resource changes, including errors. @@ -214,6 +229,11 @@ export interface State { */ waitlistSignal: WaitlistSignal; + /** + * A Signal that updates when the active session token changes. + */ + sessionTokenSignal: SessionTokenSignal; + /** * An alias for `effect()` from `alien-signals`, which can be used to subscribe to changes from Signals. * @@ -236,4 +256,9 @@ export interface State { * An instance of the Waitlist resource. */ __internal_waitlist: WaitlistResource; + + /** + * Updates the active session token signal. + */ + __internal_setSessionToken: (token: TokenResource | null | undefined) => void; }