Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/internal-use-token-signal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
346 changes: 346 additions & 0 deletions docs/SIGNALS_REACTIVITY_PROPOSAL.md

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});

Expand Down Expand Up @@ -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',
Expand Down
50 changes: 50 additions & 0 deletions packages/clerk-js/src/core/__tests__/state.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TokenResource } from '@clerk/shared/types';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { eventBus } from '../events';
Expand Down Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 13 additions & 4 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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;
Expand Down
39 changes: 37 additions & 2 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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({
Expand All @@ -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 () => {
Expand All @@ -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',
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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<any>;

const newMockJwt =
Expand Down Expand Up @@ -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 () => {
Expand Down
25 changes: 23 additions & 2 deletions packages/clerk-js/src/core/state.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,6 +23,8 @@ import {
} from './signals';

export class State implements StateInterface {
private sessionTokenInternalSignal = signal<SessionTokenSignalValue>({ isLoaded: false, token: undefined });

signInResourceSignal = signInResourceSignal;
signInErrorSignal = signInErrorSignal;
signInFetchSignal = signInFetchSignal;
Expand All @@ -38,6 +40,8 @@ export class State implements StateInterface {
waitlistFetchSignal = waitlistFetchSignal;
waitlistSignal = waitlistComputedSignal;

sessionTokenSignal = computed(() => this.sessionTokenInternalSignal());

private _waitlistInstance: Waitlist;

__internal_effect = effect;
Expand All @@ -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 });
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading