Skip to content

Commit 4334598

Browse files
authored
feat(clerk-react,clerk-js, types,shared): Introduce Clerk.status (#5476)
1 parent 3336161 commit 4334598

File tree

24 files changed

+579
-169
lines changed

24 files changed

+579
-169
lines changed

.changeset/cool-showers-admire.md

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@clerk/nextjs': minor
3+
'@clerk/clerk-react': minor
4+
---
5+
6+
Introduce `useClerk().status` alongside `<ClerkFailed />` and `<ClerkDegraded />`.
7+
8+
### `useClerk().status`
9+
Possible values for `useClerk().status` are:
10+
- `"loading"`: Set during initialization
11+
- `"error"`: Set when hotloading clerk-js failed or `Clerk.load()` failed
12+
- `"ready"`: Set when Clerk is fully operational
13+
- `"degraded"`: Set when Clerk is partially operational
14+
The computed value of `useClerk().loaded` is:
15+
16+
- `true` when `useClerk().status` is either `"ready"` or `"degraded"`.
17+
- `false` when `useClerk().status` is `"loading"` or `"error"`.
18+
19+
### `<ClerkFailed />`
20+
```tsx
21+
<ClerkLoaded>
22+
<MyCustomSignInForm/>
23+
</ClerkLoaded>
24+
<ClerkFailed>
25+
<ContactSupportBanner/>
26+
</ClerkFailed>
27+
```
28+
29+
### `<ClerkDegraded />`
30+
```tsx
31+
<ClerkLoaded>
32+
<MyCustomPasskeyRegistration/>
33+
<ClerkDegraded>
34+
We are experiencing issues, registering a passkey might fail.
35+
</ClerkDegraded>
36+
</ClerkLoaded>
37+
```

.changeset/olive-onions-do.md

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
Introduce `Clerk.status` for tracking the state of the clerk singleton.
7+
Possible values for `Clerk.status` are:
8+
- `"loading"`: Set during initialization
9+
- `"error"`: Set when hotloading clerk-js failed or `Clerk.load()` failed
10+
- `"ready"`: Set when Clerk is fully operational
11+
- `"degraded"`: Set when Clerk is partially operational
12+
13+
The computed value of `Clerk.loaded` is:
14+
- `true` when `Clerk.status` is either `"ready"` or `"degraded"`.
15+
- `false` when `Clerk.status` is `"loading"` or `"error"`.

.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
1515
"types/clerk-pagination-params.mdx",
1616
"types/clerk-pagination-request.mdx",
1717
"types/clerk-resource.mdx",
18+
"types/clerk-status.mdx",
1819
"types/clerk.mdx",
1920
"types/create-organization-params.mdx",
2021
"types/element-object-key.mdx",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { ClerkLoaded, ClerkLoading, ClerkFailed, ClerkDegraded, useClerk } from '@clerk/clerk-react';
2+
3+
export default function ClerkStatusPage() {
4+
const { loaded, status } = useClerk();
5+
6+
return (
7+
<>
8+
<p>Status: {status}</p>
9+
<p>{status === 'loading' ? 'Clerk is loading' : null}</p>
10+
<p>{status === 'error' ? 'Clerk is out' : null}</p>
11+
<p>{status === 'degraded' ? 'Clerk is degraded' : null}</p>
12+
<p>{status === 'ready' ? 'Clerk is ready' : null}</p>
13+
<p>{status === 'ready' || status === 'degraded' ? 'Clerk is ready or degraded (loaded)' : null}</p>
14+
<p>{loaded ? 'Clerk is loaded' : null}</p>
15+
<p>{!loaded ? 'Clerk is NOT loaded' : null}</p>
16+
17+
<ClerkDegraded>
18+
<p>(comp) Clerk is degraded</p>
19+
</ClerkDegraded>
20+
21+
<ClerkLoaded>
22+
<p>(comp) Clerk is loaded,(ready or degraded)</p>
23+
</ClerkLoaded>
24+
25+
<ClerkFailed>
26+
<p>(comp) Something went wrong with Clerk, refresh your page.</p>
27+
</ClerkFailed>
28+
29+
<ClerkLoading>
30+
<p>(comp) Waiting for clerk to fail, ready or regraded.</p>
31+
</ClerkLoading>
32+
</>
33+
);
34+
}

integration/templates/react-vite/src/main.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import OrganizationList from './organization-list';
2020
import CreateOrganization from './create-organization';
2121
import OrganizationSwitcher from './organization-switcher';
2222
import Buttons from './buttons';
23+
import ClerkStatusPage from './clerk-status';
2324

2425
const Root = () => {
2526
const navigate = useNavigate();
@@ -114,6 +115,10 @@ const router = createBrowserRouter([
114115
path: '/create-organization',
115116
element: <CreateOrganization />,
116117
},
118+
{
119+
path: '/clerk-status',
120+
element: <ClerkStatusPage />,
121+
},
117122
],
118123
},
119124
]);

integration/testUtils/index.ts

+15
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ const createClerkUtils = ({ page }: TestArgs) => {
7979
return window.Clerk?.session?.actor;
8080
});
8181
},
82+
toBeLoading: async () => {
83+
return page.waitForFunction(() => {
84+
return window.Clerk?.status === 'loading';
85+
});
86+
},
87+
toBeReady: async () => {
88+
return page.waitForFunction(() => {
89+
return window.Clerk?.status === 'ready';
90+
});
91+
},
92+
toBeDegraded: async () => {
93+
return page.waitForFunction(() => {
94+
return window.Clerk?.status === 'degraded';
95+
});
96+
},
8297
getClientSideUser: () => {
8398
return page.evaluate(() => {
8499
return window.Clerk?.user;

integration/tests/resiliency.test.ts

+131-16
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,27 @@ import { appConfigs } from '../presets';
44
import type { FakeUser } from '../testUtils';
55
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
66

7+
const make500ClerkResponse = () => ({
8+
status: 500,
9+
body: JSON.stringify({
10+
errors: [
11+
{
12+
message: 'Oops, an unexpected error occurred',
13+
long_message: "There was an internal error on our servers. We've been notified and are working on fixing it.",
14+
code: 'internal_clerk_error',
15+
},
16+
],
17+
clerk_trace_id: 'some-trace-id',
18+
}),
19+
});
20+
721
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resiliency @generic', ({ app }) => {
822
test.describe.configure({ mode: 'serial' });
923

24+
if (app.name.includes('next')) {
25+
test.skip();
26+
}
27+
1028
let fakeUser: FakeUser;
1129

1230
test.beforeAll(async () => {
@@ -32,22 +50,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc
3250
});
3351

3452
// Simulate developer coming back and client fails to load.
35-
await page.route('**/v1/client?**', route => {
36-
return route.fulfill({
37-
status: 500,
38-
body: JSON.stringify({
39-
errors: [
40-
{
41-
message: 'Oops, an unexpected error occurred',
42-
long_message:
43-
"There was an internal error on our servers. We've been notified and are working on fixing it.",
44-
code: 'internal_clerk_error',
45-
},
46-
],
47-
clerk_trace_id: 'some-trace-id',
48-
}),
49-
});
50-
});
53+
await page.route('**/v1/client?**', route => route.fulfill(make500ClerkResponse()));
5154

5255
await page.waitForTimeout(1_000);
5356
await page.reload();
@@ -140,4 +143,116 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc
140143

141144
await u.po.clerk.toBeLoaded();
142145
});
146+
147+
test.describe('Clerk.status', () => {
148+
test('normal flow shows correct states and transitions', async ({ page, context }) => {
149+
const u = createTestUtils({ app, page, context });
150+
await u.page.goToRelative('/clerk-status');
151+
152+
// Initial state checks
153+
await expect(page.getByText('Status: loading', { exact: true })).toBeVisible();
154+
await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible();
155+
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible();
156+
await expect(page.getByText('Clerk is out')).toBeHidden();
157+
await expect(page.getByText('Clerk is degraded')).toBeHidden();
158+
await expect(page.getByText('(comp) Waiting for clerk to fail, ready or regraded.')).toBeVisible();
159+
await u.po.clerk.toBeLoading();
160+
161+
// Wait for loading to complete and verify final state
162+
await expect(page.getByText('Status: ready', { exact: true })).toBeVisible();
163+
await u.po.clerk.toBeLoaded();
164+
await u.po.clerk.toBeReady();
165+
await expect(page.getByText('Clerk is ready', { exact: true })).toBeVisible();
166+
await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible();
167+
await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible();
168+
await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible();
169+
170+
// Verify loading component is no longer visible
171+
await expect(page.getByText('(comp) Waiting for clerk to fail, ready or regraded.')).toBeHidden();
172+
});
173+
174+
test('clerk-js hotloading failed', async ({ page, context }) => {
175+
const u = createTestUtils({ app, page, context });
176+
177+
await page.route('**/clerk.browser.js', route => route.abort());
178+
179+
await u.page.goToRelative('/clerk-status');
180+
181+
// Initial state checks
182+
await expect(page.getByText('Status: loading', { exact: true })).toBeVisible();
183+
await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible();
184+
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible();
185+
186+
// Wait for loading to complete and verify final state
187+
await expect(page.getByText('Status: error', { exact: true })).toBeVisible({
188+
timeout: 10_000,
189+
});
190+
await expect(page.getByText('Clerk is out', { exact: true })).toBeVisible();
191+
await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeHidden();
192+
await expect(page.getByText('Clerk is loaded', { exact: true })).toBeHidden();
193+
await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeHidden();
194+
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible();
195+
196+
// Verify loading component is no longer visible
197+
await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden();
198+
});
199+
200+
// TODO: Fix detection of hotloaded clerk-js failing
201+
test.skip('clerk-js client fails and status degraded', async ({ page, context }) => {
202+
const u = createTestUtils({ app, page, context });
203+
204+
await page.route('**/v1/client?**', route => route.fulfill(make500ClerkResponse()));
205+
206+
await u.page.goToRelative('/clerk-status');
207+
208+
// Initial state checks
209+
await expect(page.getByText('Status: loading', { exact: true })).toBeVisible();
210+
await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible();
211+
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible();
212+
213+
// Wait for loading to complete and verify final state
214+
await expect(page.getByText('Status: degraded', { exact: true })).toBeVisible({
215+
timeout: 10_000,
216+
});
217+
await u.po.clerk.toBeDegraded();
218+
await expect(page.getByText('Clerk is degraded', { exact: true })).toBeVisible();
219+
await expect(page.getByText('Clerk is ready', { exact: true })).toBeHidden();
220+
await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible();
221+
await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible();
222+
await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible();
223+
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeHidden();
224+
await expect(page.getByText('(comp) Clerk is degraded')).toBeVisible();
225+
226+
// Verify loading component is no longer visible
227+
await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden();
228+
});
229+
230+
test('clerk-js environment fails and status degraded', async ({ page, context }) => {
231+
const u = createTestUtils({ app, page, context });
232+
233+
await page.route('**/v1/environment?**', route => route.fulfill(make500ClerkResponse()));
234+
235+
await u.page.goToRelative('/clerk-status');
236+
237+
// Initial state checks
238+
await expect(page.getByText('Status: loading', { exact: true })).toBeVisible();
239+
await expect(page.getByText('Clerk is loading', { exact: true })).toBeVisible();
240+
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeVisible();
241+
await u.po.clerk.toBeLoading();
242+
243+
// Wait for loading to complete and verify final state
244+
await expect(page.getByText('Status: degraded', { exact: true })).toBeVisible();
245+
await u.po.clerk.toBeDegraded();
246+
await expect(page.getByText('Clerk is degraded', { exact: true })).toBeVisible();
247+
await expect(page.getByText('Clerk is ready', { exact: true })).toBeHidden();
248+
await expect(page.getByText('Clerk is ready or degraded (loaded)')).toBeVisible();
249+
await expect(page.getByText('Clerk is loaded', { exact: true })).toBeVisible();
250+
await expect(page.getByText('(comp) Clerk is loaded,(ready or degraded)')).toBeVisible();
251+
await expect(page.getByText('Clerk is NOT loaded', { exact: true })).toBeHidden();
252+
await expect(page.getByText('(comp) Clerk is degraded')).toBeVisible();
253+
254+
// Verify loading component is no longer visible
255+
await expect(page.getByText('Clerk is loading', { exact: true })).toBeHidden();
256+
});
257+
});
143258
});

packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
exports[`public exports should not include a breaking change 1`] = `
44
[
55
"AuthenticateWithRedirectCallback",
6+
"ClerkDegraded",
7+
"ClerkFailed",
68
"ClerkLoaded",
79
"ClerkLoading",
810
"ClerkProvider",

packages/clerk-js/bundlewatch.config.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"files": [
33
{ "path": "./dist/clerk.js", "maxSize": "593kB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "74.2KB" },
55
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
66
{ "path": "./dist/ui-common*.js", "maxSize": "100KB" },
77
{ "path": "./dist/vendors*.js", "maxSize": "36KB" },

packages/clerk-js/src/core/auth/AuthCookieService.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { createClerkEventBus } from '@clerk/shared/clerkEventBus';
2+
import { clerkEvents } from '@clerk/shared/clerkEventBus';
13
import { createCookieHandler } from '@clerk/shared/cookie';
24
import { setDevBrowserJWTInURL } from '@clerk/shared/devBrowser';
35
import { is4xxError, isClerkAPIResponseError, isClerkRuntimeError, isNetworkError } from '@clerk/shared/error';
@@ -41,9 +43,14 @@ export class AuthCookieService {
4143
private activeOrgCookie: ReturnType<typeof createCookieHandler>;
4244
private devBrowser: DevBrowser;
4345

44-
public static async create(clerk: Clerk, fapiClient: FapiClient, instanceType: InstanceType) {
46+
public static async create(
47+
clerk: Clerk,
48+
fapiClient: FapiClient,
49+
instanceType: InstanceType,
50+
clerkEventBus: ReturnType<typeof createClerkEventBus>,
51+
) {
4552
const cookieSuffix = await getCookieSuffix(clerk.publishableKey);
46-
const service = new AuthCookieService(clerk, fapiClient, cookieSuffix, instanceType);
53+
const service = new AuthCookieService(clerk, fapiClient, cookieSuffix, instanceType, clerkEventBus);
4754
await service.setup();
4855
return service;
4956
}
@@ -53,6 +60,7 @@ export class AuthCookieService {
5360
fapiClient: FapiClient,
5461
cookieSuffix: string,
5562
private instanceType: InstanceType,
63+
private clerkEventBus: ReturnType<typeof createClerkEventBus>,
5664
) {
5765
// set cookie on token update
5866
eventBus.on(events.TokenUpdate, ({ token }) => {
@@ -192,6 +200,9 @@ export class AuthCookieService {
192200
return;
193201
}
194202

203+
// The poller failed to fetch a fresh session token, update status to `degraded`.
204+
this.clerkEventBus.emit(clerkEvents.Status, 'degraded');
205+
195206
// --------
196207
// Treat any other error as a noop
197208
// TODO(debug-logs): Once debug logs is available log this error.

0 commit comments

Comments
 (0)