Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
bc169ba
Refactor promises in NextJS ClerkProvider
Ephem Nov 10, 2025
3fa0633
Remove PromisifiedAuth
Ephem Nov 10, 2025
e5d4d4b
Remove initialAuthState from useAuth
Ephem Nov 10, 2025
3b6687e
Update expo useAuth to match new signature
Ephem Nov 10, 2025
11a38da
Add changeset
Ephem Nov 10, 2025
cf02da1
Remove special isNext13 handling from Next ClerkProvider
Ephem Nov 11, 2025
9f9477d
Temporarily remove affected packages from changeset
Ephem Nov 11, 2025
c7ed168
Add back affected packages
Ephem Nov 11, 2025
c4eadb4
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem Nov 11, 2025
2f45577
Fix changeset react package name
Ephem Nov 11, 2025
09d8a8a
Add InitialAuthStateProvider and refactor to derive authState in useA…
Ephem Nov 12, 2025
31d4f2b
Use InitialAuthStateProvider directly for nested Next ClerkProvider
Ephem Nov 12, 2025
7a5381c
Resolve !dynamic without promises
Ephem Nov 13, 2025
a23789b
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem Nov 18, 2025
ba26f9c
Clean up AuthContext
Ephem Nov 19, 2025
d72ec7c
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem Nov 19, 2025
19b996b
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem Nov 19, 2025
a2adbf3
Remove AuthContext and add uSES for useAuth hook
Ephem Nov 19, 2025
c85a66c
Move ClerkContextProvider from ui to shared/react
Ephem Nov 19, 2025
f35bbbb
Update shared ClerkContextProvider to support initialState and switch…
Ephem Nov 19, 2025
288cf94
Remove SessionContext and refactor to uSES
Ephem Nov 20, 2025
5d87895
Remove UserContext and refactor to uSES
Ephem Nov 20, 2025
a8f9f60
Remove OrganizationProvider and refactor to uSES
Ephem Nov 20, 2025
f37d8d1
Remove ClientContext and refactor to uSES
Ephem Nov 20, 2025
d3d85d1
Support passing in initialState as a promise
Ephem Nov 20, 2025
5b81912
Add skipInitialEmit option to addListener and use in uSES
Ephem Nov 20, 2025
c3c79f9
Remove unrelated changeset
Ephem Nov 20, 2025
7b73924
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem Nov 25, 2025
7581b74
Rename setAccessors -> updateAccessors and make it emit
Ephem Nov 26, 2025
05debd5
Fix getSnapshot stale closure issue
Ephem Nov 27, 2025
37eb323
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem Nov 27, 2025
e66fe43
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem Dec 1, 2025
8f23b8b
Update tests
Ephem Dec 1, 2025
dfc6e64
Remove useDeferredValue
Ephem Dec 1, 2025
ba8373e
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem Dec 2, 2025
185844f
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem Dec 4, 2025
f0b43d3
Introduce __internal_lastEmittedResources in clerk-js to enable prope…
Ephem Dec 4, 2025
84fdb9b
Refactor initialState
Ephem Dec 9, 2025
3244b9a
Merge branch 'main' into fredrik/poc
Ephem Dec 9, 2025
9d6f7f9
Revert supporting initialState as a promise (for now)
Ephem Dec 9, 2025
e3b3d7e
Fix mergeNextClerkPropsWithEnv types
Ephem Dec 9, 2025
1fed366
Trigger
Ephem Dec 9, 2025
c59b0bb
Merge branch 'main' into fredrik/user-4043-remove-clerk-state-context…
Ephem Dec 10, 2025
7486150
Add basic transition tests
Ephem Dec 10, 2025
c371efe
Remove failing @ts-expect-error in test
Ephem Dec 10, 2025
b4af784
Merge branch 'main' into fredrik/user-4043-remove-clerk-state-context…
Ephem Dec 11, 2025
565238b
Merge branch 'main' into fredrik/user-4043-remove-clerk-state-context…
Ephem Dec 11, 2025
162e2ec
Merge branch 'main' into fredrik/user-4043-remove-clerk-state-context…
Ephem Dec 12, 2025
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
167 changes: 167 additions & 0 deletions integration/templates/next-app-router/src/app/transitions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
'use client';

import { OrganizationSwitcher, useAuth, useOrganizationList } from '@clerk/nextjs';
import { OrganizationMembershipResource, SetActive } from '@clerk/shared/types';
import { Suspense, useState, useTransition } from 'react';

// Quick and dirty promise cache to enable Suspense "fetching"
const cachedPromises = new Map<string, Promise<unknown>>();
const getCachedPromise = (key: string, value: string | undefined | null, delay: number = 1000) => {
if (cachedPromises.has(`${key}-${value}-${delay}`)) {
return cachedPromises.get(`${key}-${value}-${delay}`)!;
}
const promise = new Promise(resolve => {
setTimeout(() => {
const returnValue = `Fetched value: ${value}`;
(promise as any).status = 'fulfilled';
(promise as any).value = returnValue;
resolve(returnValue);
}, delay);
});
cachedPromises.set(`${key}-${value}-${delay}`, promise);
return promise;
};

export default function TransitionsPage() {
return (
<div style={{ margin: '40px' }}>
<div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: '60px',
alignItems: 'center',
}}
>
<TransitionController />
<TransitionSwitcher />
<div>
<div style={{ backgroundColor: 'white' }}>
<OrganizationSwitcher fallback={<div>Loading...</div>} />
</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '40px' }}>
<AuthStatePresenter />
<Suspense fallback={<div data-testid='fetcher-fallback'>Loading...</div>}>
<Fetcher />
</Suspense>
</div>
</div>
);
}

// This is a hack to be able to control the start and stop of a transition by using a promise
function TransitionController() {
const [transitionPromise, setTransitionPromise] = useState<Promise<unknown> | null>(null);
const [pending, startTransition] = useTransition();
return (
<div>
<button
onClick={() => {
if (pending) {
(transitionPromise as any).resolve();
setTransitionPromise(null);
} else {
let resolve;
const promise = new Promise(r => {
resolve = r;
});
// We save the resolve on the promise itself so we can later resolve it manually
(promise as any).resolve = resolve;
setTransitionPromise(promise);

startTransition(async () => {
await promise;
});
}
}}
>
{pending ? 'Finish transition' : 'Start transition'}
</button>
</div>
);
}

function TransitionSwitcher() {
const { isLoaded, userMemberships, setActive } = useOrganizationList({ userMemberships: true });

if (!isLoaded || !userMemberships.data) {
return null;
}

return (
<div style={{ display: 'flex', flexDirection: 'row', gap: '10px' }}>
{userMemberships.data.map(membership => (
<TransitionSwitcherButton
key={membership.organization.id}
membership={membership}
setActive={setActive}
/>
))}
</div>
);
}

function TransitionSwitcherButton({
membership,
setActive,
}: {
membership: OrganizationMembershipResource;
setActive: SetActive;
}) {
const [pending, startTransition] = useTransition();
return (
<button
onClick={() => {
startTransition(async () => {
// Note that this does not currently work, as setActive does not support transitions,
// we are using it to verify the existing behavior.
await setActive({ organization: membership.organization.id });
});
}}
>
{pending ? 'Switching...' : `Switch to ${membership.organization.name} in transition`}
</button>
);
}

function AuthStatePresenter() {
const { orgId, sessionId, userId } = useAuth();

return (
<div>
<h1>Auth state</h1>
<div>
SessionId: <span data-testid='session-id'>{String(sessionId)}</span>
</div>
<div>
UserId: <span data-testid='user-id'>{String(userId)}</span>
</div>
<div>
OrgId: <span data-testid='org-id'>{String(orgId)}</span>
</div>
</div>
);
}

function Fetcher() {
const { orgId } = useAuth();

if (!orgId) {
return null;
}

const promise = getCachedPromise('fetcher', orgId, 1000);
if (promise && (promise as any).status !== 'fulfilled') {
throw promise;
}

return (
<div>
<h1>Fetcher</h1>
<div data-testid='fetcher-result'>{(promise as any).value}</div>
</div>
);
}
2 changes: 1 addition & 1 deletion integration/testUtils/usersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export const createUserService = (clerkClient: ClerkClient) => {
const name = faker.animal.dog();
const organization = await withErrorLogging('createOrganization', () =>
clerkClient.organizations.createOrganization({
name: faker.animal.dog(),
name: name,
createdBy: userId,
}),
);
Expand Down
189 changes: 189 additions & 0 deletions integration/tests/transitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { parsePublishableKey } from '@clerk/shared/keys';
import { clerkSetup } from '@clerk/testing/playwright';
import { test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeOrganization, FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

/*
These tests try to verify some existing transition behaviors. They are not comprehensive, and do not necessarily
document the desired behavior but the one we currently have, as changing some of these behaviors might be considered
a breaking change.

Note that it is unclear if we can support transitions fully for auth state as they involve cookies, which can not fork.

The tests use organization switching and useAuth as a stand-in for other type of auth state changes and hooks,
but the strategy and behavior should be the same across other type of state changes and hooks as well and we could
add more tests to have better coverage.

We might need to come up with a better strategy to test these behaviors in the future, but this is a start.

Note that these tests are entangled with the specific page implementation details and so are hard to understand
without reading the /transitions page code in the template.
*/
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('transitions @nextjs', ({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let fakeOrganization: FakeOrganization;
let fakeOrganization2: FakeOrganization;

test.beforeAll(async () => {
const u = createTestUtils({ app });

const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY');
const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY');
const apiUrl = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_API_URL');
const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);

// Not needed for the normal test setup, but makes it easier to run the tests against a manually started app
await clerkSetup({
publishableKey,
frontendApiUrl,
secretKey,
// @ts-expect-error Not typed
apiUrl,
dotenv: false,
});

fakeUser = u.services.users.createFakeUser();
const user = await u.services.users.createBapiUser(fakeUser);
fakeOrganization = await u.services.users.createFakeOrganization(user.id);
fakeOrganization2 = await u.services.users.createFakeOrganization(user.id);
});

test.afterAll(async () => {
await fakeOrganization.delete();
await fakeOrganization2.delete();
await fakeUser.deleteIfExists();
await app.teardown();
});

/*
This test verifies the page behavior when transitions are not involved. State updates immediately and
already mounted Suspense boundaries are suspended so the fallback shows.

If Clerk made auth changes as transitions, with full support, the behavior would be that the Suspense fallback
would not be shown, and orgId would not update until the full transition, including data fetching, was complete.
*/
test('should switch to the new organization immediately when not using transitions', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });

await u.po.page.goToRelative('/transitions');

// This page is not using `<ClerkProvider dynamic>`, so orgId should be undefined during page load
await test.expect(u.po.page.getByTestId('org-id')).toHaveText('undefined');

await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization2.organization.id);
// When orgId comes in, this page triggers a mock Suspense fetch
await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible();
await test
.expect(u.po.page.getByTestId('fetcher-result'))
.toHaveText(`Fetched value: ${fakeOrganization2.organization.id}`);

// Switch to new organization
await u.po.organizationSwitcher.waitForMounted();
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();
await u.po.organizationSwitcher.toggleTrigger();
await test.expect(u.page.locator('.cl-organizationSwitcherPopoverCard')).toBeVisible();
await u.page.getByText(fakeOrganization.name, { exact: true }).click();

// When orgId updates, we re-suspend and "fetch" the new value
await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization.organization.id);
await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible();
await test
.expect(u.po.page.getByTestId('fetcher-result'))
.toHaveText(`Fetched value: ${fakeOrganization.organization.id}`);
});

/*
This test verifies that auth state changes interrupt an already started, but unrelated transition, setting
the state immediately and suspending already mounted Suspense boundaries.

If Clerk made auth changes as transitions, with full support, the behavior would be that the Suspense fallback
would not be shown, and orgId would not update until the full transition, including data fetching, was complete.
*/
test('should switch to the new organization immediately when a transition is in progress', async ({
page,
context,
}) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });

await u.po.page.goToRelative('/transitions');

// This page is not using `<ClerkProvider dynamic>`, so orgId should be undefined during page load
await test.expect(u.po.page.getByTestId('org-id')).toHaveText('undefined');

await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization.organization.id);
// When orgId comes in, this page triggers a mock Suspense fetch
await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible();
await test
.expect(u.po.page.getByTestId('fetcher-result'))
.toHaveText(`Fetched value: ${fakeOrganization.organization.id}`);

// Start unrelated transition
await u.po.page.getByRole('button', { name: 'Start transition' }).click();
await test.expect(u.po.page.getByRole('button', { name: 'Finish transition' })).toBeVisible();

// Switch to new organization
await u.po.organizationSwitcher.waitForMounted();
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();
await u.po.organizationSwitcher.toggleTrigger();
await test.expect(u.page.locator('.cl-organizationSwitcherPopoverCard')).toBeVisible();
await u.page.getByText(fakeOrganization2.name, { exact: true }).click();

// When orgId updates, we re-suspend and "fetch" the new value
await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization2.organization.id);
await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible();
await test
.expect(u.po.page.getByTestId('fetcher-result'))
.toHaveText(`Fetched value: ${fakeOrganization2.organization.id}`);

// Finish unrelated transition - Should have been pending until now
await u.po.page.getByRole('button', { name: 'Finish transition' }).click();
await test.expect(u.po.page.getByRole('button', { name: 'Start transition' })).toBeVisible();
});

/*
This test verifies the current behavior when setActive is triggered inside a transition.

If setActive/Clerk fully supported transitions, the behavior would be that the Suspense fallback
would not be shown, and orgId would not update until the full transition, including data fetching, was complete.
*/
test('should switch to the new organization immediately when triggered inside a transition', async ({
page,
context,
}) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });

await u.po.page.goToRelative('/transitions');

// This page is not using `<ClerkProvider dynamic>`, so orgId should be undefined during page load
await test.expect(u.po.page.getByTestId('org-id')).toHaveText('undefined');

await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization2.organization.id);
// When orgId comes in, this page triggers a mock Suspense fetch
await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible();
await test
.expect(u.po.page.getByTestId('fetcher-result'))
.toHaveText(`Fetched value: ${fakeOrganization2.organization.id}`);

// Switch to new organization
await u.po.page.getByRole('button', { name: `Switch to ${fakeOrganization.name} in transition` }).click();
await test.expect(u.po.page.getByRole('button', { name: `Switching...` })).toBeVisible();

// When orgId updates, we re-suspend and "fetch" the new value
await test.expect(u.po.page.getByTestId('org-id')).toHaveText(fakeOrganization.organization.id);
await test.expect(u.po.page.getByTestId('fetcher-fallback')).toBeVisible();
await test
.expect(u.po.page.getByTestId('fetcher-result'))
.toHaveText(`Fetched value: ${fakeOrganization.organization.id}`);
});
});
Loading
Loading