Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
26419cb
feat: Add waitlist resource and hooks
cursoragent Oct 29, 2025
bd48cfc
feat: Add waitlist page and functionality
cursoragent Oct 29, 2025
7dc031e
Refactor: Remove waitlist from client and move to state
cursoragent Oct 29, 2025
000592b
Refactor: Inline JoinWaitlistParams type
cursoragent Oct 29, 2025
c3484e1
remove experimental exports
brkalow Oct 29, 2025
1e6865f
updates type
brkalow Oct 30, 2025
12688ca
fix types
brkalow Oct 30, 2025
60ca143
move JoinWaitlistParams
brkalow Oct 30, 2025
bfc3364
fix implementation, get tests passing
brkalow Oct 30, 2025
4b79f9a
snapdates
brkalow Oct 30, 2025
8af2285
Merge branch 'vincent-and-the-doctor' into cursor/implement-waitlist-…
brkalow Oct 30, 2025
61b8bd0
merges with base and other updates
brkalow Nov 18, 2025
17774f2
Address PR feedback
brkalow Nov 20, 2025
346a83d
revert unnecessary changes
brkalow Nov 20, 2025
d541b79
Remove WaitlistFuture
brkalow Nov 20, 2025
203131c
Fixes waitlist test cleanup and proper property access on waitlist re…
brkalow Nov 20, 2025
8a6e71f
Merge branch 'vincent-and-the-doctor' into cursor/implement-waitlist-…
brkalow Nov 21, 2025
cd0104b
Merge branch 'vincent-and-the-doctor' into cursor/implement-waitlist-…
brkalow Nov 24, 2025
406f16a
Merge branch 'main' into cursor/implement-waitlist-hook-with-signal-p…
brkalow Dec 8, 2025
3386df5
Merge branch 'main' into cursor/implement-waitlist-hook-with-signal-p…
brkalow Jan 6, 2026
b24759b
fix(clerk-js): Update _waitlistInstance on resource update
brkalow Jan 7, 2026
51f6853
fix(integration): Use unique emails per waitlist test
brkalow Jan 7, 2026
f8606fa
Merge branch 'main' into cursor/implement-waitlist-hook-with-signal-p…
brkalow Jan 7, 2026
ce964e5
format
brkalow Jan 7, 2026
0cf9faf
adds changeset
brkalow Jan 8, 2026
2da25c6
Merge branch 'main' into cursor/implement-waitlist-hook-with-signal-p…
brkalow Jan 8, 2026
c7b5ae7
address PR feedback
brkalow Jan 8, 2026
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
7 changes: 7 additions & 0 deletions .changeset/young-areas-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/react': minor
---

Introduce `useWaitlist()` hook
6 changes: 3 additions & 3 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ const withLegalConsent = base
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-legal-consent').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-legal-consent').pk);

const withWaitlistdMode = withEmailCodes
const withWaitlistMode = withEmailCodes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who put that there! 👀

.clone()
.setId('withWaitlistdMode')
.setId('withWaitlistMode')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-waitlist-mode').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-waitlist-mode').pk);

Expand Down Expand Up @@ -213,7 +213,7 @@ export const envs = {
withSignInOrUpEmailLinksFlow,
withSignInOrUpFlow,
withSignInOrUpwithRestrictedModeFlow,
withWaitlistdMode,
withWaitlistMode,
withWhatsappPhoneCode,
withProtectService,
} as const;
5 changes: 5 additions & 0 deletions integration/templates/custom-flows-react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Home } from './routes/Home';
import { SignIn } from './routes/SignIn';
import { SignUp } from './routes/SignUp';
import { Protected } from './routes/Protected';
import { Waitlist } from './routes/Waitlist';

// Import your Publishable Key
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
Expand Down Expand Up @@ -43,6 +44,10 @@ createRoot(document.getElementById('root')!).render(
path='/sign-up'
element={<SignUp />}
/>
<Route
path='/waitlist'
element={<Waitlist />}
/>
<Route
path='/protected'
element={<Protected />}
Expand Down
112 changes: 112 additions & 0 deletions integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client';

import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useWaitlist } from '@clerk/react';
import { NavLink } from 'react-router';

export function Waitlist({ className, ...props }: React.ComponentProps<'div'>) {
const { waitlist, errors, fetchStatus } = useWaitlist();

const handleSubmit = async (formData: FormData) => {
const emailAddress = formData.get('emailAddress') as string | null;

if (!emailAddress) {
return;
}

await waitlist.join({ emailAddress });
};

if (waitlist?.id) {
return (
<div
className={cn('flex flex-col gap-6', className)}
{...props}
>
<Card>
<CardHeader className='text-center'>
<CardTitle className='text-xl'>Successfully joined!</CardTitle>
<CardDescription>You&apos;re on the waitlist</CardDescription>
</CardHeader>
<CardContent>
<div className='grid gap-6'>
<div className='text-center text-sm'>
Already have an account?{' '}
<NavLink
to='/sign-in'
className='underline underline-offset-4'
data-testid='sign-in-link'
>
Sign in
</NavLink>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

return (
<div
className={cn('flex flex-col gap-6', className)}
{...props}
>
<Card>
<CardHeader className='text-center'>
<CardTitle className='text-xl'>Join the Waitlist</CardTitle>
<CardDescription>Enter your email address to join the waitlist</CardDescription>
</CardHeader>
<CardContent>
<form action={handleSubmit}>
<div className='grid gap-6'>
<div className='grid gap-6'>
<div className='grid gap-3'>
<Label htmlFor='emailAddress'>Email address</Label>
<Input
id='emailAddress'
type='email'
placeholder='Email address'
required
name='emailAddress'
data-testid='email-input'
/>
{errors.fields.emailAddress && (
<p
className='text-sm text-red-600'
data-testid='email-error'
>
{errors.fields.emailAddress.longMessage}
</p>
)}
</div>
<Button
type='submit'
className='w-full'
disabled={fetchStatus === 'fetching'}
data-testid='submit-button'
>
Join Waitlist
</Button>
</div>
<div className='text-center text-sm'>
Already have an account?{' '}
<NavLink
to='/sign-in'
className='underline underline-offset-4'
data-testid='sign-in-link'
>
Sign in
</NavLink>
</div>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
2 changes: 2 additions & 0 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createInvitationService } from './invitationsService';
import { createOrganizationsService } from './organizationsService';
import type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail } from './usersService';
import { createUserService } from './usersService';
import { createWaitlistService } from './waitlistService';

export type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail };

Expand Down Expand Up @@ -40,6 +41,7 @@ export const createTestUtils = <
users: createUserService(clerkClient),
invitations: createInvitationService(clerkClient),
organizations: createOrganizationsService(clerkClient),
waitlist: createWaitlistService(clerkClient),
};

if (!params.page) {
Expand Down
19 changes: 19 additions & 0 deletions integration/testUtils/waitlistService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ClerkClient } from '@clerk/backend';

export type WaitlistService = {
clearWaitlistByEmail: (email: string) => Promise<void>;
};

export const createWaitlistService = (clerkClient: ClerkClient) => {
const self: WaitlistService = {
clearWaitlistByEmail: async (email: string) => {
const { data: entries } = await clerkClient.waitlistEntries.list({ query: email, status: 'pending' });

if (entries.length > 0) {
await clerkClient.waitlistEntries.delete(entries[0].id);
}
},
};

return self;
};
Comment on lines +7 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Search for patterns where waitlist entries are created or cleared
rg -n -C3 --type=ts 'clearWaitlistByEmail|waitlist.*create|waitlist.*join' integration/

Repository: clerk/javascript

Length of output: 3765


🏁 Script executed:

# Also check the WaitlistService interface definition
rg -n 'interface WaitlistService|type WaitlistService' --type=ts

Repository: clerk/javascript

Length of output: 134


🏁 Script executed:

# Check if there are any scenarios with multiple entries per email
rg -n 'waitlistEntries.*list' --type=ts -A5 -B5

Repository: clerk/javascript

Length of output: 1577


🏁 Script executed:

# Check JSDoc patterns in other testUtils services
rg -n '/\*\*' integration/testUtils/ -A5 -B1 --type=ts

Repository: clerk/javascript

Length of output: 4789


🏁 Script executed:

# Check if there are other scenarios where the same email might create multiple entries
rg -n 'fakeEmail|fakeUser.email' integration/tests/custom-flows/waitlist.test.ts -B5 -A5

Repository: clerk/javascript

Length of output: 1853


Add JSDoc documentation to the public WaitlistService API.

Per the project's TypeScript guidelines, public APIs require JSDoc comments. The createWaitlistService function and clearWaitlistByEmail method should include JSDoc blocks documenting their purpose, parameters, and return types:

+/**
+ * Creates a waitlist service for managing test waitlist entries.
+ * @param clerkClient - The Clerk client instance
+ * @returns A service for clearing waitlist entries by email
+ */
 export const createWaitlistService = (clerkClient: ClerkClient) => {
   const self: WaitlistService = {
+    /**
+     * Clears a pending waitlist entry by email address.
+     * @param email - The email address to clear from the waitlist
+     */
     clearWaitlistByEmail: async (email: string) => {
🤖 Prompt for AI Agents
In integration/testUtils/waitlistService.ts around lines 7 to 19, the public API
lacks JSDoc comments; add JSDoc above the exported createWaitlistService
function describing its purpose (creates a WaitlistService helper for tests),
document the clerkClient parameter and its type (ClerkClient), and the return
type (WaitlistService), and add a JSDoc block above the clearWaitlistByEmail
method describing what it does, its email parameter (string) and that it returns
a Promise<void>; ensure descriptions are concise and follow the project's JSDoc
style.

100 changes: 100 additions & 0 deletions integration/tests/custom-flows/waitlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { parsePublishableKey } from '@clerk/shared/keys';
import { clerkSetup } from '@clerk/testing/playwright';
import { expect, test } from '@playwright/test';

import type { Application } from '../../models/application';
import { hash } from '../../models/helpers';
import { appConfigs } from '../../presets';
import { createTestUtils } from '../../testUtils';

test.describe('Custom Flows Waitlist @custom', () => {
test.describe.configure({ mode: 'parallel' });
let app: Application;
const fakeEmails: string[] = [];

test.beforeAll(async () => {
app = await appConfigs.customFlows.reactVite.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withWaitlistMode);
await app.dev();

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Destructuring from potentially null return value will cause runtime TypeError

parsePublishableKey can return null (see its signature in @clerk/shared/keys). Destructuring directly without a null check will throw at runtime if the key is invalid or missing.

Use { fatal: true } to throw a descriptive error, or add a null guard:

-    const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);
+    const parsedKey = parsePublishableKey(publishableKey, { fatal: true });
+    const { frontendApi: frontendApiUrl } = parsedKey;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @integration/tests/custom-flows/waitlist.test.ts at line 24, Destructuring
frontendApiUrl from parsePublishableKey(publishableKey) is unsafe because
parsePublishableKey can return null; update the call to either pass { fatal:
true } into parsePublishableKey so it throws a clear error on invalid keys
(e.g., parsePublishableKey(publishableKey, { fatal: true })) or first assign the
result to a variable and guard for null before destructuring (e.g., const res =
parsePublishableKey(publishableKey); if (!res) throw new Error('invalid
publishableKey'); const { frontendApi: frontendApiUrl } = res), referencing
parsePublishableKey, publishableKey, and frontendApiUrl.


Comment on lines +21 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Handle possibly null from parsePublishableKey before destructuring

parsePublishableKey can return null, so destructuring directly into { frontendApi: frontendApiUrl } is unsafe and will fail TypeScript checks.

Consider guarding or asserting non-null first, e.g.:

-    const publishableKey = appConfigs.envs.withWaitlistMode.publicVariables.get('CLERK_PUBLISHABLE_KEY');
-    const secretKey = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_SECRET_KEY');
-    const apiUrl = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_API_URL');
-    const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);
+    const publishableKey = appConfigs.envs.withWaitlistMode.publicVariables.get('CLERK_PUBLISHABLE_KEY');
+    const secretKey = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_SECRET_KEY');
+    const apiUrl = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_API_URL');
+
+    const parsedKey = parsePublishableKey(publishableKey, { fatal: true });
+    if (!parsedKey) {
+      throw new Error('Invalid CLERK_PUBLISHABLE_KEY for withWaitlistMode test environment');
+    }
+    const { frontendApi: frontendApiUrl } = parsedKey;

This satisfies TS and avoids a potential runtime destructuring of null.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In integration/tests/custom-flows/waitlist.test.ts around lines 21 to 25, the
call to parsePublishableKey may return null so directly destructuring into {
frontendApi: frontendApiUrl } is unsafe; update the code to first assign the
result to a variable, check that it is not null (or throw a clear error/assert)
and only then extract frontendApi into frontendApiUrl (or handle the null case
explicitly), ensuring TypeScript and runtime safety.

await clerkSetup({
publishableKey,
frontendApiUrl,
secretKey,
// @ts-expect-error
apiUrl,
dotenv: false,
});
});

test.afterAll(async () => {
const u = createTestUtils({ app });
await Promise.all(fakeEmails.map(email => u.services.waitlist.clearWaitlistByEmail(email)));
await app.teardown();
});

test('can join waitlist with email', async ({ page, context }) => {
const fakeEmail = `${hash()}+clerk_test@clerkcookie.com`;
fakeEmails.push(fakeEmail);

const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill(fakeEmail);
await submitButton.click();

await expect(u.page.getByText('Successfully joined!')).toBeVisible();
await expect(u.page.getByText("You're on the waitlist")).toBeVisible();
});

test('renders error with invalid email', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill('invalid-email@com');
await submitButton.click();

await expect(u.page.getByTestId('email-error')).toBeVisible();
});

test('displays loading state while joining', async ({ page, context }) => {
const fakeEmail = `${hash()}+clerk_test@clerkcookie.com`;
fakeEmails.push(fakeEmail);

const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill(fakeEmail);

const submitPromise = submitButton.click();

// Check that button is disabled during fetch
await expect(submitButton).toBeDisabled();

await submitPromise;

// Wait for success state
await expect(u.page.getByText('Successfully joined!')).toBeVisible();
});
});
2 changes: 1 addition & 1 deletion integration/tests/waitlist-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ test.describe('Waitlist mode', () => {
)
.commit();
await app.setup();
await app.withEnv(appConfigs.envs.withWaitlistdMode);
await app.withEnv(appConfigs.envs.withWaitlistMode);
await app.dev();

const m = createTestUtils({ app });
Expand Down
29 changes: 19 additions & 10 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ClerkError } from '@clerk/shared/error';
import type { JoinWaitlistParams, WaitlistJSON, WaitlistResource } from '@clerk/shared/types';

import { unixEpochToDate } from '../../utils/date';
import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask';
import { eventBus } from '../events';
import { BaseResource } from './internal';

export class Waitlist extends BaseResource implements WaitlistResource {
Expand All @@ -10,7 +13,7 @@ export class Waitlist extends BaseResource implements WaitlistResource {
updatedAt: Date | null = null;
createdAt: Date | null = null;

constructor(data: WaitlistJSON) {
constructor(data: WaitlistJSON | null = null) {
super();
this.fromJSON(data);
}
Expand All @@ -23,18 +26,24 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.id = data.id;
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
return this;
}

async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> {
return runAsyncResourceTask(this, async () => {
await Waitlist.join(params);
});
}

static async join(params: JoinWaitlistParams): Promise<WaitlistResource> {
const json = (
await BaseResource._fetch<WaitlistJSON>({
path: '/waitlist',
method: 'POST',
body: params as any,
})
)?.response as unknown as WaitlistJSON;

return new Waitlist(json);
const json = await BaseResource._fetch<WaitlistJSON>({
path: '/waitlist',
method: 'POST',
body: params as any,
});

return new Waitlist(json as unknown as WaitlistJSON);
}
}
Loading