Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
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 @@ -38,6 +39,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>
);
}
132 changes: 132 additions & 0 deletions integration/tests/custom-flows/waitlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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 { appConfigs } from '../../presets';
import type { FakeUser } from '../../testUtils';
import { createTestUtils } from '../../testUtils';

test.describe('Custom Flows Waitlist @custom', () => {
test.describe.configure({ mode: 'parallel' });
let app: Application;
let fakeUser: FakeUser;

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

const publishableKey = appConfigs.envs.withWaitlistdMode.publicVariables.get('CLERK_PUBLISHABLE_KEY');
const secretKey = appConfigs.envs.withWaitlistdMode.privateVariables.get('CLERK_SECRET_KEY');
const apiUrl = appConfigs.envs.withWaitlistdMode.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.


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

const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
});

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

test('can join waitlist with 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(fakeUser.email);
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 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(fakeUser.email);

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();
});

test('can navigate to sign-in from waitlist', 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 signInLink = u.page.getByTestId('sign-in-link');
await expect(signInLink).toBeVisible();
await signInLink.click();

await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
await u.page.waitForURL(/sign-in/);
});

test('waitlist hook provides correct properties', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();

// Check initial state - waitlist resource should be available but empty
const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await expect(emailInput).toBeVisible();
await expect(submitButton).toBeEnabled();

// Join waitlist
await emailInput.fill(fakeUser.email);
await submitButton.click();

// After successful join, the component should show success state
await expect(u.page.getByText('Successfully joined!')).toBeVisible();
});
});
48 changes: 46 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { JoinWaitlistParams, WaitlistJSON, WaitlistResource } from '@clerk/shared/types';
import type { JoinWaitlistParams, WaitlistFutureResource, 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 +12,22 @@ export class Waitlist extends BaseResource implements WaitlistResource {
updatedAt: Date | null = null;
createdAt: Date | null = null;

constructor(data: WaitlistJSON) {
/**
* @experimental This experimental API is subject to change.
*
* An instance of `WaitlistFuture`, which has a different API than `Waitlist`, intended to be used in custom flows.
*/
__internal_future: WaitlistFuture = new WaitlistFuture(this);

/**
* @internal Only used for internal purposes, and is not intended to be used directly.
*
* This property is used to provide access to underlying Client methods to `WaitlistFuture`, which wraps an instance
* of `Waitlist`.
*/
__internal_basePost = this._basePost.bind(this);

constructor(data: WaitlistJSON | null = null) {
super();
this.fromJSON(data);
}
Expand All @@ -23,6 +40,8 @@ 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;
}

Expand All @@ -38,3 +57,28 @@ export class Waitlist extends BaseResource implements WaitlistResource {
return new Waitlist(json);
}
}

class WaitlistFuture implements WaitlistFutureResource {
constructor(readonly resource: Waitlist) {}

get id() {
return this.resource.id || undefined;
}

get createdAt() {
return this.resource.createdAt;
}

get updatedAt() {
return this.resource.updatedAt;
}

async join(params: JoinWaitlistParams): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: params,
});
});
}
}
31 changes: 30 additions & 1 deletion packages/clerk-js/src/core/signals.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import type { ClerkAPIError, ClerkError } from '@clerk/shared/error';
import { createClerkGlobalHookError, isClerkAPIResponseError } from '@clerk/shared/error';
import type { Errors, SignInErrors, SignInSignal, SignUpErrors, SignUpSignal } from '@clerk/shared/types';
import type {
Errors,
SignInErrors,
SignInSignal,
SignUpErrors,
SignUpSignal,
WaitlistErrors,
WaitlistSignal,
} from '@clerk/shared/types';
import { snakeToCamel } from '@clerk/shared/underscore';
import { computed, signal } from 'alien-signals';

import type { SignIn } from './resources/SignIn';
import type { SignUp } from './resources/SignUp';
import type { Waitlist } from './resources/Waitlist';

export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });
export const signInErrorSignal = signal<{ error: ClerkError | null }>({ error: null });
Expand Down Expand Up @@ -35,6 +44,20 @@ export const signUpComputedSignal: SignUpSignal = computed(() => {
return { errors, fetchStatus, signUp: signUp ? signUp.__internal_future : null };
});

export const waitlistResourceSignal = signal<{ resource: Waitlist | null }>({ resource: null });
export const waitlistErrorSignal = signal<{ error: ClerkError | null }>({ error: null });
export const waitlistFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' });

export const waitlistComputedSignal: WaitlistSignal = computed(() => {
const waitlist = waitlistResourceSignal().resource;
const error = waitlistErrorSignal().error;
const fetchStatus = waitlistFetchSignal().status;

const errors = errorsToWaitlistErrors(error);

return { errors, fetchStatus, waitlist: waitlist ? waitlist.__internal_future : null };
});

/**
* Converts an error to a parsed errors object that reports the specific fields that the error pertains to. Will put
* generic non-API errors into the global array.
Expand Down Expand Up @@ -112,3 +135,9 @@ function errorsToSignUpErrors(error: ClerkError | null): SignUpErrors {
legalAccepted: null,
});
}

function errorsToWaitlistErrors(error: ClerkError | null): WaitlistErrors {
return errorsToParsedErrors(error, {
emailAddress: null,
});
}
Loading
Loading