Skip to content

Commit 583f7a9

Browse files
authored
feat(*): Migrate to useSyncExternalStore (#7411)
1 parent a9c9ee3 commit 583f7a9

44 files changed

Lines changed: 964 additions & 597 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/full-parents-crash.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@clerk/nextjs': major
3+
'@clerk/shared': major
4+
'@clerk/react': major
5+
'@clerk/expo': major
6+
'@clerk/chrome-extension': major
7+
'@clerk/react-router': major
8+
'@clerk/clerk-js': minor
9+
'@clerk/tanstack-react-start': minor
10+
---
11+
12+
Refactor React SDK hooks to subscribe to auth state via `useSyncExternalStore`. This is a mostly internal refactor to unlock future improvements, but includes a few breaking changes and fixes.
13+
14+
Breaking changes:
15+
16+
* All `@clerk/react`-based packages: Removes ability to pass in `initialAuthState` to `useAuth`
17+
* This was added for internal use and is no longer needed
18+
* Instead pass in `initialState` to the `<ClerkProvider>`, or `dynamic` if using the Next package
19+
* See your specific SDK documentation for more information on Server Rendering
20+
* `@clerk/shared`: Removes now unused contexts `ClientContext`, `SessionContext`, `UserContext` and `OrganizationProvider`
21+
* We do not anticipate public use of these
22+
* If you were using any of these, file an issue to discuss a path forward as they are no longer available even internally
23+
24+
New features:
25+
26+
* `@clerk/clerk-js`: `addListener` now takes a `skipInitialEmit` option that can be used to avoid emitting immediately after subscribing
27+
28+
Fixes:
29+
30+
* A bug where `useAuth` would sometimes briefly return the `initialState` rather than `undefined`
31+
* This could in certain situations incorrectly lead to a brief `user: null` on the first page after signing in, indicating a signed out state
32+
* Hydration mismatches in certain rare scenarios where subtrees would suspend and hydrate only after `clerk-js` had loaded fully
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
'use client';
2+
3+
import { OrganizationSwitcher, useAuth, useOrganizationList } from '@clerk/nextjs';
4+
import { OrganizationMembershipResource, SetActive } from '@clerk/shared/types';
5+
import { Suspense, useState, useTransition } from 'react';
6+
7+
// Quick and dirty promise cache to enable Suspense "fetching"
8+
const cachedPromises = new Map<string, Promise<unknown>>();
9+
const getCachedPromise = (key: string, value: string | undefined | null, delay: number = 1000) => {
10+
if (cachedPromises.has(`${key}-${value}-${delay}`)) {
11+
return cachedPromises.get(`${key}-${value}-${delay}`)!;
12+
}
13+
const promise = new Promise(resolve => {
14+
setTimeout(() => {
15+
const returnValue = `Fetched value: ${value}`;
16+
(promise as any).status = 'fulfilled';
17+
(promise as any).value = returnValue;
18+
resolve(returnValue);
19+
}, delay);
20+
});
21+
cachedPromises.set(`${key}-${value}-${delay}`, promise);
22+
return promise;
23+
};
24+
25+
export default function TransitionsPage() {
26+
return (
27+
<div style={{ margin: '40px' }}>
28+
<div
29+
style={{
30+
display: 'flex',
31+
flexDirection: 'row',
32+
justifyContent: 'space-between',
33+
marginBottom: '60px',
34+
alignItems: 'center',
35+
}}
36+
>
37+
<TransitionController />
38+
<TransitionSwitcher />
39+
<div>
40+
<div style={{ backgroundColor: 'white' }}>
41+
<OrganizationSwitcher fallback={<div>Loading...</div>} />
42+
</div>
43+
</div>
44+
</div>
45+
<div style={{ display: 'flex', flexDirection: 'column', gap: '40px' }}>
46+
<AuthStatePresenter />
47+
<Suspense fallback={<div data-testid='fetcher-fallback'>Loading...</div>}>
48+
<Fetcher />
49+
</Suspense>
50+
</div>
51+
</div>
52+
);
53+
}
54+
55+
// This is a hack to be able to control the start and stop of a transition by using a promise
56+
function TransitionController() {
57+
const [transitionPromise, setTransitionPromise] = useState<Promise<unknown> | null>(null);
58+
const [pending, startTransition] = useTransition();
59+
return (
60+
<div>
61+
<button
62+
onClick={() => {
63+
if (pending) {
64+
(transitionPromise as any).resolve();
65+
setTransitionPromise(null);
66+
} else {
67+
let resolve;
68+
const promise = new Promise(r => {
69+
resolve = r;
70+
});
71+
// We save the resolve on the promise itself so we can later resolve it manually
72+
(promise as any).resolve = resolve;
73+
setTransitionPromise(promise);
74+
75+
startTransition(async () => {
76+
await promise;
77+
});
78+
}
79+
}}
80+
>
81+
{pending ? 'Finish transition' : 'Start transition'}
82+
</button>
83+
</div>
84+
);
85+
}
86+
87+
function TransitionSwitcher() {
88+
const { isLoaded, userMemberships, setActive } = useOrganizationList({ userMemberships: true });
89+
90+
if (!isLoaded || !userMemberships.data) {
91+
return null;
92+
}
93+
94+
return (
95+
<div style={{ display: 'flex', flexDirection: 'row', gap: '10px' }}>
96+
{userMemberships.data.map(membership => (
97+
<TransitionSwitcherButton
98+
key={membership.organization.id}
99+
membership={membership}
100+
setActive={setActive}
101+
/>
102+
))}
103+
</div>
104+
);
105+
}
106+
107+
function TransitionSwitcherButton({
108+
membership,
109+
setActive,
110+
}: {
111+
membership: OrganizationMembershipResource;
112+
setActive: SetActive;
113+
}) {
114+
const [pending, startTransition] = useTransition();
115+
return (
116+
<button
117+
onClick={() => {
118+
startTransition(async () => {
119+
// Note that this does not currently work, as setActive does not support transitions,
120+
// we are using it to verify the existing behavior.
121+
await setActive({ organization: membership.organization.id });
122+
});
123+
}}
124+
>
125+
{pending ? 'Switching...' : `Switch to ${membership.organization.name} in transition`}
126+
</button>
127+
);
128+
}
129+
130+
function AuthStatePresenter() {
131+
const { orgId, sessionId, userId } = useAuth();
132+
133+
return (
134+
<div>
135+
<h1>Auth state</h1>
136+
<div>
137+
SessionId: <span data-testid='session-id'>{String(sessionId)}</span>
138+
</div>
139+
<div>
140+
UserId: <span data-testid='user-id'>{String(userId)}</span>
141+
</div>
142+
<div>
143+
OrgId: <span data-testid='org-id'>{String(orgId)}</span>
144+
</div>
145+
</div>
146+
);
147+
}
148+
149+
function Fetcher() {
150+
const { orgId } = useAuth();
151+
152+
if (!orgId) {
153+
return null;
154+
}
155+
156+
const promise = getCachedPromise('fetcher', orgId, 1000);
157+
if (promise && (promise as any).status !== 'fulfilled') {
158+
throw promise;
159+
}
160+
161+
return (
162+
<div>
163+
<h1>Fetcher</h1>
164+
<div data-testid='fetcher-result'>{(promise as any).value}</div>
165+
</div>
166+
);
167+
}

integration/testUtils/usersService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export const createUserService = (clerkClient: ClerkClient) => {
207207
const name = faker.animal.dog();
208208
const organization = await withErrorLogging('createOrganization', () =>
209209
clerkClient.organizations.createOrganization({
210-
name: faker.animal.dog(),
210+
name: name,
211211
createdBy: userId,
212212
}),
213213
);

0 commit comments

Comments
 (0)