-
Notifications
You must be signed in to change notification settings - Fork 418
feat(clerk-js,shared,react,nextjs): Migrate to useSyncExternalStore #7411
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Ephem
wants to merge
48
commits into
main
Choose a base branch
from
fredrik/user-4043-remove-clerk-state-contexts-from-provider-and-rely-on
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 3fa0633
Remove PromisifiedAuth
Ephem e5d4d4b
Remove initialAuthState from useAuth
Ephem 3b6687e
Update expo useAuth to match new signature
Ephem 11a38da
Add changeset
Ephem cf02da1
Remove special isNext13 handling from Next ClerkProvider
Ephem 9f9477d
Temporarily remove affected packages from changeset
Ephem c7ed168
Add back affected packages
Ephem c4eadb4
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem 2f45577
Fix changeset react package name
Ephem 09d8a8a
Add InitialAuthStateProvider and refactor to derive authState in useA…
Ephem 31d4f2b
Use InitialAuthStateProvider directly for nested Next ClerkProvider
Ephem 7a5381c
Resolve !dynamic without promises
Ephem a23789b
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem ba26f9c
Clean up AuthContext
Ephem d72ec7c
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem 19b996b
Merge branch 'vincent-and-the-doctor' into fredrik/remove-initial-aut…
Ephem a2adbf3
Remove AuthContext and add uSES for useAuth hook
Ephem c85a66c
Move ClerkContextProvider from ui to shared/react
Ephem f35bbbb
Update shared ClerkContextProvider to support initialState and switch…
Ephem 288cf94
Remove SessionContext and refactor to uSES
Ephem 5d87895
Remove UserContext and refactor to uSES
Ephem a8f9f60
Remove OrganizationProvider and refactor to uSES
Ephem f37d8d1
Remove ClientContext and refactor to uSES
Ephem d3d85d1
Support passing in initialState as a promise
Ephem 5b81912
Add skipInitialEmit option to addListener and use in uSES
Ephem c3c79f9
Remove unrelated changeset
Ephem 7b73924
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem 7581b74
Rename setAccessors -> updateAccessors and make it emit
Ephem 05debd5
Fix getSnapshot stale closure issue
Ephem 37eb323
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem e66fe43
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem 8f23b8b
Update tests
Ephem dfc6e64
Remove useDeferredValue
Ephem ba8373e
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem 185844f
Merge branch 'vincent-and-the-doctor' into fredrik/poc
Ephem f0b43d3
Introduce __internal_lastEmittedResources in clerk-js to enable prope…
Ephem 84fdb9b
Refactor initialState
Ephem 3244b9a
Merge branch 'main' into fredrik/poc
Ephem 9d6f7f9
Revert supporting initialState as a promise (for now)
Ephem e3b3d7e
Fix mergeNextClerkPropsWithEnv types
Ephem 1fed366
Trigger
Ephem c59b0bb
Merge branch 'main' into fredrik/user-4043-remove-clerk-state-context…
Ephem 7486150
Add basic transition tests
Ephem c371efe
Remove failing @ts-expect-error in test
Ephem b4af784
Merge branch 'main' into fredrik/user-4043-remove-clerk-state-context…
Ephem 565238b
Merge branch 'main' into fredrik/user-4043-remove-clerk-state-context…
Ephem 162e2ec
Merge branch 'main' into fredrik/user-4043-remove-clerk-state-context…
Ephem File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
167 changes: 167 additions & 0 deletions
167
integration/templates/next-app-router/src/app/transitions/page.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}`); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.