From 26419cb947b4e11489137e838bbee96986520e8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Oct 2025 16:53:58 +0000 Subject: [PATCH 01/19] feat: Add waitlist resource and hooks This commit introduces the Waitlist resource, enabling users to join a waitlist via email. It includes new hooks for accessing and interacting with the waitlist functionality in React applications. Co-authored-by: bryce --- .../clerk-js/src/core/resources/Client.ts | 6 ++- .../clerk-js/src/core/resources/Waitlist.ts | 48 ++++++++++++++++++- packages/clerk-js/src/core/signals.ts | 17 ++++++- packages/clerk-js/src/core/state.ts | 22 +++++++++ packages/react/src/hooks/index.ts | 2 +- packages/react/src/hooks/useClerkSignal.ts | 27 ++++++++++- packages/react/src/stateProxy.ts | 28 +++++++++++ packages/types/src/state.ts | 27 +++++++++++ packages/types/src/waitlist.ts | 29 +++++++++++ 9 files changed, 199 insertions(+), 7 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 4615e4b85fe..1ab5851150b 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -7,11 +7,12 @@ import type { SignedInSessionResource, SignInResource, SignUpResource, + WaitlistResource, } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; import { SessionTokenCache } from '../tokenCache'; -import { BaseResource, Session, SignIn, SignUp } from './internal'; +import { BaseResource, Session, SignIn, SignUp, Waitlist } from './internal'; export class Client extends BaseResource implements ClientResource { private static instance: Client | null | undefined; @@ -21,6 +22,7 @@ export class Client extends BaseResource implements ClientResource { sessions: Session[] = []; signUp: SignUpResource = new SignUp(); signIn: SignInResource = new SignIn(); + waitlist: WaitlistResource = new Waitlist(); lastActiveSessionId: string | null = null; captchaBypass = false; cookieExpiresAt: Date | null = null; @@ -84,6 +86,7 @@ export class Client extends BaseResource implements ClientResource { this.sessions = []; this.signUp = new SignUp(null); this.signIn = new SignIn(null); + this.waitlist = new Waitlist(null); this.lastActiveSessionId = null; this.lastAuthenticationStrategy = null; this.cookieExpiresAt = null; @@ -131,6 +134,7 @@ export class Client extends BaseResource implements ClientResource { this.sessions = (data.sessions || []).map(s => new Session(s)); this.signUp = new SignUp(data.sign_up); this.signIn = new SignIn(data.sign_in); + this.waitlist = new Waitlist((data as any).waitlist); this.lastActiveSessionId = data.last_active_session_id; this.captchaBypass = data.captcha_bypass || false; this.cookieExpiresAt = data.cookie_expires_at ? unixEpochToDate(data.cookie_expires_at) : null; diff --git a/packages/clerk-js/src/core/resources/Waitlist.ts b/packages/clerk-js/src/core/resources/Waitlist.ts index dffce0a8977..044f3888061 100644 --- a/packages/clerk-js/src/core/resources/Waitlist.ts +++ b/packages/clerk-js/src/core/resources/Waitlist.ts @@ -1,6 +1,8 @@ -import type { JoinWaitlistParams, WaitlistJSON, WaitlistResource } from '@clerk/types'; +import type { JoinWaitlistParams, WaitlistFutureResource, WaitlistJSON, WaitlistResource } from '@clerk/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 { @@ -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); } @@ -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; } @@ -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, + }); + }); + } +} diff --git a/packages/clerk-js/src/core/signals.ts b/packages/clerk-js/src/core/signals.ts index 815cfb7acff..e3f98ad4787 100644 --- a/packages/clerk-js/src/core/signals.ts +++ b/packages/clerk-js/src/core/signals.ts @@ -1,10 +1,11 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import { snakeToCamel } from '@clerk/shared/underscore'; -import type { Errors, SignInSignal, SignUpSignal } from '@clerk/types'; +import type { Errors, SignInSignal, SignUpSignal, WaitlistSignal } from '@clerk/types'; 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: unknown }>({ error: null }); @@ -34,6 +35,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: unknown }>({ 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 = errorsToParsedErrors(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. diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index 5746ed17d98..835eaa6dd0a 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -5,6 +5,7 @@ import { eventBus } from './events'; import type { BaseResource } from './resources/Base'; import { SignIn } from './resources/SignIn'; import { SignUp } from './resources/SignUp'; +import { Waitlist } from './resources/Waitlist'; import { signInComputedSignal, signInErrorSignal, @@ -14,6 +15,10 @@ import { signUpErrorSignal, signUpFetchSignal, signUpResourceSignal, + waitlistComputedSignal, + waitlistErrorSignal, + waitlistFetchSignal, + waitlistResourceSignal, } from './signals'; export class State implements StateInterface { @@ -27,6 +32,11 @@ export class State implements StateInterface { signUpFetchSignal = signUpFetchSignal; signUpSignal = signUpComputedSignal; + waitlistResourceSignal = waitlistResourceSignal; + waitlistErrorSignal = waitlistErrorSignal; + waitlistFetchSignal = waitlistFetchSignal; + waitlistSignal = waitlistComputedSignal; + __internal_effect = effect; __internal_computed = computed; @@ -44,6 +54,10 @@ export class State implements StateInterface { if (payload.resource instanceof SignUp) { this.signUpErrorSignal({ error: payload.error }); } + + if (payload.resource instanceof Waitlist) { + this.waitlistErrorSignal({ error: payload.error }); + } }; private onResourceUpdated = (payload: { resource: BaseResource }) => { @@ -54,6 +68,10 @@ export class State implements StateInterface { if (payload.resource instanceof SignUp) { this.signUpResourceSignal({ resource: payload.resource }); } + + if (payload.resource instanceof Waitlist) { + this.waitlistResourceSignal({ resource: payload.resource }); + } }; private onResourceFetch = (payload: { resource: BaseResource; status: 'idle' | 'fetching' }) => { @@ -64,5 +82,9 @@ export class State implements StateInterface { if (payload.resource instanceof SignUp) { this.signUpFetchSignal({ status: payload.status }); } + + if (payload.resource instanceof Waitlist) { + this.waitlistFetchSignal({ status: payload.status }); + } }; } diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 8beaba1c56f..b2ff54e467d 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,6 +1,6 @@ export { useAuth } from './useAuth'; export { useEmailLink } from './useEmailLink'; -export { useSignIn, useSignUp } from './useClerkSignal'; +export { useSignIn, useSignUp, useWaitlist } from './useClerkSignal'; export { useClerk, useOrganization, diff --git a/packages/react/src/hooks/useClerkSignal.ts b/packages/react/src/hooks/useClerkSignal.ts index 96ff8f30011..617261f3932 100644 --- a/packages/react/src/hooks/useClerkSignal.ts +++ b/packages/react/src/hooks/useClerkSignal.ts @@ -1,4 +1,4 @@ -import type { SignInSignalValue, SignUpSignalValue } from '@clerk/types'; +import type { SignInSignalValue, SignUpSignalValue, WaitlistSignalValue } from '@clerk/types'; import { useCallback, useSyncExternalStore } from 'react'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; @@ -6,7 +6,8 @@ import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvid function useClerkSignal(signal: 'signIn'): SignInSignalValue; function useClerkSignal(signal: 'signUp'): SignUpSignalValue; -function useClerkSignal(signal: 'signIn' | 'signUp'): SignInSignalValue | SignUpSignalValue { +function useClerkSignal(signal: 'waitlist'): WaitlistSignalValue; +function useClerkSignal(signal: 'signIn' | 'signUp' | 'waitlist'): SignInSignalValue | SignUpSignalValue | WaitlistSignalValue { useAssertWrappedByClerkProvider('useClerkSignal'); const clerk = useIsomorphicClerkContext(); @@ -25,6 +26,9 @@ function useClerkSignal(signal: 'signIn' | 'signUp'): SignInSignalValue | SignUp case 'signUp': clerk.__internal_state.signUpSignal(); break; + case 'waitlist': + clerk.__internal_state.waitlistSignal(); + break; default: throw new Error(`Unknown signal: ${signal}`); } @@ -39,6 +43,8 @@ function useClerkSignal(signal: 'signIn' | 'signUp'): SignInSignalValue | SignUp return clerk.__internal_state.signInSignal() as SignInSignalValue; case 'signUp': return clerk.__internal_state.signUpSignal() as SignUpSignalValue; + case 'waitlist': + return clerk.__internal_state.waitlistSignal() as WaitlistSignalValue; default: throw new Error(`Unknown signal: ${signal}`); } @@ -82,3 +88,20 @@ export function useSignIn() { export function useSignUp() { return useClerkSignal('signUp'); } + +/** + * This hook allows you to access the Signal-based `Waitlist` resource. + * + * @example + * import { useWaitlist } from "@clerk/react/experimental"; + * + * function WaitlistForm() { + * const { waitlist, errors, fetchStatus } = useWaitlist(); + * // + * } + * + * @experimental This experimental API is subject to change. + */ +export function useWaitlist() { + return useClerkSignal('waitlist'); +} diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index bb196b6f229..55982f34a9e 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -26,6 +26,7 @@ export class StateProxy implements State { private readonly signInSignalProxy = this.buildSignInProxy(); private readonly signUpSignalProxy = this.buildSignUpProxy(); + private readonly waitlistSignalProxy = this.buildWaitlistProxy(); signInSignal() { return this.signInSignalProxy; @@ -33,6 +34,9 @@ export class StateProxy implements State { signUpSignal() { return this.signUpSignalProxy; } + waitlistSignal() { + return this.waitlistSignalProxy; + } private buildSignInProxy() { const gateProperty = this.gateProperty.bind(this); @@ -226,6 +230,30 @@ export class StateProxy implements State { }; } + private buildWaitlistProxy() { + const gateProperty = this.gateProperty.bind(this); + const gateMethod = this.gateMethod.bind(this); + const target = () => this.client.waitlist?.__internal_future; + + return { + errors: defaultErrors(), + fetchStatus: 'idle' as const, + waitlist: { + get id() { + return gateProperty(target, 'id', undefined); + }, + get createdAt() { + return gateProperty(target, 'createdAt', null); + }, + get updatedAt() { + return gateProperty(target, 'updatedAt', null); + }, + + join: gateMethod(target, 'join'), + }, + }; + } + __internal_effect(_: () => void): () => void { throw new Error('__internal_effect called before Clerk is loaded'); } diff --git a/packages/types/src/state.ts b/packages/types/src/state.ts index 4438d92fe57..ca3ae6fa2c0 100644 --- a/packages/types/src/state.ts +++ b/packages/types/src/state.ts @@ -1,5 +1,6 @@ import type { SignInFutureResource } from './signInFuture'; import type { SignUpFutureResource } from './signUpFuture'; +import type { WaitlistFutureResource } from './waitlist'; /** * Represents an error on a specific field. @@ -128,6 +129,27 @@ export interface SignUpSignal { (): NullableSignUpSignal; } +export interface WaitlistSignalValue { + /** + * The errors that occurred during the last fetch of the underlying `Waitlist` resource. + */ + errors: Errors; + /** + * The fetch status of the underlying `Waitlist` resource. + */ + fetchStatus: 'idle' | 'fetching'; + /** + * The underlying `Waitlist` resource. + */ + waitlist: WaitlistFutureResource; +} +export type NullableWaitlistSignal = Omit & { + waitlist: WaitlistFutureResource | null; +}; +export interface WaitlistSignal { + (): NullableWaitlistSignal; +} + export interface State { /** * A Signal that updates when the underlying `SignIn` resource changes, including errors. @@ -139,6 +161,11 @@ export interface State { */ signUpSignal: SignUpSignal; + /** + * A Signal that updates when the underlying `Waitlist` resource changes, including errors. + */ + waitlistSignal: WaitlistSignal; + /** * An alias for `effect()` from `alien-signals`, which can be used to subscribe to changes from Signals. * diff --git a/packages/types/src/waitlist.ts b/packages/types/src/waitlist.ts index 8b8fe3a7ee1..1f08985c40a 100644 --- a/packages/types/src/waitlist.ts +++ b/packages/types/src/waitlist.ts @@ -5,3 +5,32 @@ export interface WaitlistResource extends ClerkResource { createdAt: Date | null; updatedAt: Date | null; } + +export interface JoinWaitlistParams { + /** + * The user's email address to join the waitlist. + */ + emailAddress: string; +} + +export interface WaitlistFutureResource { + /** + * The unique identifier for the waitlist entry. `null` if the user has not joined the waitlist yet. + */ + readonly id?: string; + + /** + * The date and time the waitlist entry was created. `null` if the user has not joined the waitlist yet. + */ + readonly createdAt: Date | null; + + /** + * The date and time the waitlist entry was last updated. `null` if the user has not joined the waitlist yet. + */ + readonly updatedAt: Date | null; + + /** + * Used to join the waitlist with the provided email address. + */ + join: (params: JoinWaitlistParams) => Promise<{ error: unknown }>; +} From bd48cfc5fd1a1f10a705c92f8c216498f93d5b23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Oct 2025 17:05:52 +0000 Subject: [PATCH 02/19] feat: Add waitlist page and functionality Co-authored-by: bryce --- .../custom-flows-react-vite/src/main.tsx | 5 + .../src/routes/Waitlist.tsx | 112 ++++++++++++++++ .../tests/custom-flows/waitlist.test.ts | 126 ++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx create mode 100644 integration/tests/custom-flows/waitlist.test.ts diff --git a/integration/templates/custom-flows-react-vite/src/main.tsx b/integration/templates/custom-flows-react-vite/src/main.tsx index bff0b63053b..cc6ea6c54f4 100644 --- a/integration/templates/custom-flows-react-vite/src/main.tsx +++ b/integration/templates/custom-flows-react-vite/src/main.tsx @@ -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; @@ -37,6 +38,10 @@ createRoot(document.getElementById('root')!).render( path='/sign-up' element={} /> + } + /> } diff --git a/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx b/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx new file mode 100644 index 00000000000..9adfdde8af5 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx @@ -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 ( +
+ + + Successfully joined! + You're on the waitlist + + +
+
+ Already have an account?{' '} + + Sign in + +
+
+
+
+
+ ); + } + + return ( +
+ + + Join the Waitlist + Enter your email address to join the waitlist + + +
+
+
+
+ + + {errors.fields.emailAddress && ( +

+ {errors.fields.emailAddress.longMessage} +

+ )} +
+ +
+
+ Already have an account?{' '} + + Sign in + +
+
+
+
+
+
+ ); +} diff --git a/integration/tests/custom-flows/waitlist.test.ts b/integration/tests/custom-flows/waitlist.test.ts new file mode 100644 index 00000000000..bb1304843ef --- /dev/null +++ b/integration/tests/custom-flows/waitlist.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from '@playwright/test'; +import { parsePublishableKey } from '@clerk/shared/keys'; +import { clerkSetup } from '@clerk/testing/playwright'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { createTestUtils, FakeUser } from '../../testUtils'; + +test.describe('Custom Flows Waitlist @custom', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + test.setTimeout(150_000); + app = await appConfigs.customFlows.reactVite.clone().commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + + 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); + + 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 app.teardown(); + }); + + test('can join waitlist with email', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/waitlist'); + 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 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'); + 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 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 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'); + + // 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(); + }); +}); From 7dc031ee85cc27b229ea32300ff294f19b2db0f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Oct 2025 17:23:32 +0000 Subject: [PATCH 03/19] Refactor: Remove waitlist from client and move to state Co-authored-by: bryce --- packages/clerk-js/src/core/resources/Client.ts | 6 +----- packages/clerk-js/src/core/state.ts | 10 ++++++++++ packages/react/src/stateProxy.ts | 7 ++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 1ab5851150b..4615e4b85fe 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -7,12 +7,11 @@ import type { SignedInSessionResource, SignInResource, SignUpResource, - WaitlistResource, } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; import { SessionTokenCache } from '../tokenCache'; -import { BaseResource, Session, SignIn, SignUp, Waitlist } from './internal'; +import { BaseResource, Session, SignIn, SignUp } from './internal'; export class Client extends BaseResource implements ClientResource { private static instance: Client | null | undefined; @@ -22,7 +21,6 @@ export class Client extends BaseResource implements ClientResource { sessions: Session[] = []; signUp: SignUpResource = new SignUp(); signIn: SignInResource = new SignIn(); - waitlist: WaitlistResource = new Waitlist(); lastActiveSessionId: string | null = null; captchaBypass = false; cookieExpiresAt: Date | null = null; @@ -86,7 +84,6 @@ export class Client extends BaseResource implements ClientResource { this.sessions = []; this.signUp = new SignUp(null); this.signIn = new SignIn(null); - this.waitlist = new Waitlist(null); this.lastActiveSessionId = null; this.lastAuthenticationStrategy = null; this.cookieExpiresAt = null; @@ -134,7 +131,6 @@ export class Client extends BaseResource implements ClientResource { this.sessions = (data.sessions || []).map(s => new Session(s)); this.signUp = new SignUp(data.sign_up); this.signIn = new SignIn(data.sign_in); - this.waitlist = new Waitlist((data as any).waitlist); this.lastActiveSessionId = data.last_active_session_id; this.captchaBypass = data.captcha_bypass || false; this.cookieExpiresAt = data.cookie_expires_at ? unixEpochToDate(data.cookie_expires_at) : null; diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index 835eaa6dd0a..eb912a86286 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -37,6 +37,8 @@ export class State implements StateInterface { waitlistFetchSignal = waitlistFetchSignal; waitlistSignal = waitlistComputedSignal; + private _waitlistInstance: Waitlist | null = null; + __internal_effect = effect; __internal_computed = computed; @@ -46,6 +48,14 @@ export class State implements StateInterface { eventBus.on('resource:fetch', this.onResourceFetch); } + get __internal_waitlist() { + if (!this._waitlistInstance) { + this._waitlistInstance = new Waitlist(null); + this.waitlistResourceSignal({ resource: this._waitlistInstance }); + } + return this._waitlistInstance; + } + private onResourceError = (payload: { resource: BaseResource; error: unknown }) => { if (payload.resource instanceof SignIn) { this.signInErrorSignal({ error: payload.error }); diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 55982f34a9e..045e1ad6d87 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -233,7 +233,12 @@ export class StateProxy implements State { private buildWaitlistProxy() { const gateProperty = this.gateProperty.bind(this); const gateMethod = this.gateMethod.bind(this); - const target = () => this.client.waitlist?.__internal_future; + const target = () => { + if (!inBrowser() || !this.isomorphicClerk.loaded) { + return null; + } + return this.isomorphicClerk.__internal_state.__internal_waitlist.__internal_future; + }; return { errors: defaultErrors(), From 000592b2ef1f99c1c5c8feb8a8319492d626eebc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Oct 2025 19:38:04 +0000 Subject: [PATCH 04/19] Refactor: Inline JoinWaitlistParams type Co-authored-by: bryce --- packages/types/src/waitlist.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/types/src/waitlist.ts b/packages/types/src/waitlist.ts index 1f08985c40a..b50e7effb08 100644 --- a/packages/types/src/waitlist.ts +++ b/packages/types/src/waitlist.ts @@ -6,13 +6,6 @@ export interface WaitlistResource extends ClerkResource { updatedAt: Date | null; } -export interface JoinWaitlistParams { - /** - * The user's email address to join the waitlist. - */ - emailAddress: string; -} - export interface WaitlistFutureResource { /** * The unique identifier for the waitlist entry. `null` if the user has not joined the waitlist yet. @@ -32,5 +25,7 @@ export interface WaitlistFutureResource { /** * Used to join the waitlist with the provided email address. */ - join: (params: JoinWaitlistParams) => Promise<{ error: unknown }>; + join: (params: { emailAddress: string }) => Promise<{ error: unknown }>; } + +export type { JoinWaitlistParams } from './clerk'; From c3484e1a72c80a886cb07a1a67a500e80e314cf2 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 29 Oct 2025 15:08:38 -0500 Subject: [PATCH 05/19] remove experimental exports --- packages/tanstack-react-start/package.json | 4 ---- packages/tanstack-react-start/src/experimental.ts | 1 - 2 files changed, 5 deletions(-) delete mode 100644 packages/tanstack-react-start/src/experimental.ts diff --git a/packages/tanstack-react-start/package.json b/packages/tanstack-react-start/package.json index 237cd897f92..ca862af98ac 100644 --- a/packages/tanstack-react-start/package.json +++ b/packages/tanstack-react-start/package.json @@ -47,10 +47,6 @@ "types": "./dist/legacy.d.ts", "default": "./dist/legacy.js" }, - "./experimental": { - "types": "./dist/experimental.d.ts", - "default": "./dist/experimental.js" - }, "./package.json": "./package.json" }, "main": "dist/index.js", diff --git a/packages/tanstack-react-start/src/experimental.ts b/packages/tanstack-react-start/src/experimental.ts deleted file mode 100644 index 03f1a1d4dfc..00000000000 --- a/packages/tanstack-react-start/src/experimental.ts +++ /dev/null @@ -1 +0,0 @@ -export { useSignInSignal as useSignIn, useSignUpSignal as useSignUp } from '@clerk/clerk-react/experimental'; From 1e6865f6556b993598254eda8974479f376ad0be Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 29 Oct 2025 21:07:08 -0500 Subject: [PATCH 06/19] updates type --- packages/types/src/state.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/types/src/state.ts b/packages/types/src/state.ts index ca3ae6fa2c0..9e6559fb33b 100644 --- a/packages/types/src/state.ts +++ b/packages/types/src/state.ts @@ -182,4 +182,8 @@ export interface State { * @experimental This experimental API is subject to change. */ __internal_computed: (getter: (previousValue?: T) => T) => () => T; + /** + * An instance of the Waitlist resource. + */ + __internal_waitlist: WaitlistFutureResource; } From 12688ca80071709a77320023ea3117c3d3cbd00f Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 29 Oct 2025 21:14:54 -0500 Subject: [PATCH 07/19] fix types --- packages/types/src/state.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/types/src/state.ts b/packages/types/src/state.ts index 9e6559fb33b..457d2cef7aa 100644 --- a/packages/types/src/state.ts +++ b/packages/types/src/state.ts @@ -1,6 +1,6 @@ import type { SignInFutureResource } from './signInFuture'; import type { SignUpFutureResource } from './signUpFuture'; -import type { WaitlistFutureResource } from './waitlist'; +import type { WaitlistFutureResource, WaitlistResource } from './waitlist'; /** * Represents an error on a specific field. @@ -185,5 +185,5 @@ export interface State { /** * An instance of the Waitlist resource. */ - __internal_waitlist: WaitlistFutureResource; + __internal_waitlist: WaitlistResource | null; } From 60ca143ee6d4a8022fe13eaf24073a15c5fd8070 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 29 Oct 2025 21:24:15 -0500 Subject: [PATCH 08/19] move JoinWaitlistParams --- packages/types/src/clerk.ts | 6 +----- packages/types/src/waitlist.ts | 6 ++++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index a8fc6103bc9..1d97f7003e1 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -60,7 +60,7 @@ import type { Web3Strategy } from './strategies'; import type { TelemetryCollector } from './telemetry'; import type { UserResource } from './user'; import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils'; -import type { WaitlistResource } from './waitlist'; +import type { JoinWaitlistParams, WaitlistResource } from './waitlist'; type __experimental_CheckoutStatus = 'needs_initialization' | 'needs_confirmation' | 'completed'; @@ -2149,10 +2149,6 @@ export interface ClerkAuthenticateWithWeb3Params { secondFactorUrl?: string; } -export type JoinWaitlistParams = { - emailAddress: string; -}; - export interface AuthenticateWithMetamaskParams { customNavigate?: (to: string) => Promise; redirectUrl?: string; diff --git a/packages/types/src/waitlist.ts b/packages/types/src/waitlist.ts index b50e7effb08..016a08cc868 100644 --- a/packages/types/src/waitlist.ts +++ b/packages/types/src/waitlist.ts @@ -25,7 +25,9 @@ export interface WaitlistFutureResource { /** * Used to join the waitlist with the provided email address. */ - join: (params: { emailAddress: string }) => Promise<{ error: unknown }>; + join: (params: JoinWaitlistParams) => Promise<{ error: unknown }>; } -export type { JoinWaitlistParams } from './clerk'; +export type JoinWaitlistParams = { + emailAddress: string; +}; From bfc3364ca48a7275a0bb926daf6c4add2082710e Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 29 Oct 2025 23:15:08 -0500 Subject: [PATCH 09/19] fix implementation, get tests passing --- .../src/routes/Waitlist.tsx | 2 +- .../tests/custom-flows/waitlist.test.ts | 36 +++++++++++-------- packages/clerk-js/src/core/state.ts | 7 ++-- packages/react/src/stateProxy.ts | 24 +++++++++++-- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx b/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx index 9adfdde8af5..59fd25015de 100644 --- a/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx +++ b/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx @@ -21,7 +21,7 @@ export function Waitlist({ className, ...props }: React.ComponentProps<'div'>) { await waitlist.join({ emailAddress }); }; - if (waitlist.id) { + if (waitlist?.id) { return (
{ test.describe.configure({ mode: 'parallel' }); @@ -12,15 +13,14 @@ test.describe('Custom Flows Waitlist @custom', () => { let fakeUser: FakeUser; test.beforeAll(async () => { - test.setTimeout(150_000); app = await appConfigs.customFlows.reactVite.clone().commit(); await app.setup(); - await app.withEnv(appConfigs.envs.withEmailCodes); + await app.withEnv(appConfigs.envs.withWaitlistdMode); await app.dev(); - 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 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); await clerkSetup({ @@ -39,12 +39,14 @@ test.describe('Custom Flows Waitlist @custom', () => { }); 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'); @@ -60,12 +62,13 @@ test.describe('Custom Flows Waitlist @custom', () => { 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'); + await emailInput.fill('invalid-email@com'); await submitButton.click(); await expect(u.page.getByTestId('email-error')).toBeVisible(); @@ -74,20 +77,21 @@ test.describe('Custom Flows Waitlist @custom', () => { 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(); }); @@ -95,6 +99,7 @@ test.describe('Custom Flows Waitlist @custom', () => { 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'); @@ -108,18 +113,19 @@ test.describe('Custom Flows Waitlist @custom', () => { 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(); }); diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index eb912a86286..1977947bf0e 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -46,13 +46,12 @@ export class State implements StateInterface { eventBus.on('resource:update', this.onResourceUpdated); eventBus.on('resource:error', this.onResourceError); eventBus.on('resource:fetch', this.onResourceFetch); + + this._waitlistInstance = new Waitlist(null); + this.waitlistResourceSignal({ resource: this._waitlistInstance }); } get __internal_waitlist() { - if (!this._waitlistInstance) { - this._waitlistInstance = new Waitlist(null); - this.waitlistResourceSignal({ resource: this._waitlistInstance }); - } return this._waitlistInstance; } diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 045e1ad6d87..e7b4113d9d4 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -38,6 +38,13 @@ export class StateProxy implements State { return this.waitlistSignalProxy; } + get __internal_waitlist() { + if (!inBrowser() || !this.isomorphicClerk.loaded) { + return null; + } + return this.isomorphicClerk.__internal_state.__internal_waitlist; + } + private buildSignInProxy() { const gateProperty = this.gateProperty.bind(this); const target = () => this.client.signIn.__internal_future; @@ -233,11 +240,22 @@ export class StateProxy implements State { private buildWaitlistProxy() { const gateProperty = this.gateProperty.bind(this); const gateMethod = this.gateMethod.bind(this); - const target = () => { + const fallbackWaitlistFuture = { + id: undefined, + createdAt: null, + updatedAt: null, + join: () => Promise.resolve({ error: null }), + }; + const target = (): typeof fallbackWaitlistFuture => { if (!inBrowser() || !this.isomorphicClerk.loaded) { - return null; + return fallbackWaitlistFuture; + } + const state = this.isomorphicClerk.__internal_state; + const waitlist = state.__internal_waitlist; + if (waitlist && '__internal_future' in waitlist) { + return (waitlist as { __internal_future: typeof fallbackWaitlistFuture }).__internal_future; } - return this.isomorphicClerk.__internal_state.__internal_waitlist.__internal_future; + return fallbackWaitlistFuture; }; return { From 4b79f9a1304b48aaec63503185af764474f07853 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 30 Oct 2025 15:34:36 -0500 Subject: [PATCH 10/19] snapdates --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 8b268b93a36..cba4c47026a 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -65,6 +65,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "useSignIn", "useSignUp", "useUser", + "useWaitlist", ] `; diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index c8a1f96ceba..6778dea3902 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -70,6 +70,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "useSignIn", "useSignUp", "useUser", + "useWaitlist", ] `; From 17774f28b7c1414aad585f35a0d8232c5678d02a Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 19 Nov 2025 20:54:08 -0600 Subject: [PATCH 11/19] Address PR feedback --- .../tests/custom-flows/waitlist.test.ts | 34 ------------------- .../clerk-js/src/core/resources/Waitlist.ts | 3 +- packages/shared/src/types/waitlist.ts | 7 ++-- 3 files changed, 6 insertions(+), 38 deletions(-) diff --git a/integration/tests/custom-flows/waitlist.test.ts b/integration/tests/custom-flows/waitlist.test.ts index c1147631cd6..c03162d6be7 100644 --- a/integration/tests/custom-flows/waitlist.test.ts +++ b/integration/tests/custom-flows/waitlist.test.ts @@ -95,38 +95,4 @@ test.describe('Custom Flows Waitlist @custom', () => { // 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(); - }); }); diff --git a/packages/clerk-js/src/core/resources/Waitlist.ts b/packages/clerk-js/src/core/resources/Waitlist.ts index b728d00b991..7bde5725605 100644 --- a/packages/clerk-js/src/core/resources/Waitlist.ts +++ b/packages/clerk-js/src/core/resources/Waitlist.ts @@ -1,3 +1,4 @@ +import type { ClerkError } from '@clerk/shared/error'; import type { JoinWaitlistParams, WaitlistFutureResource, WaitlistJSON, WaitlistResource } from '@clerk/shared/types'; import { unixEpochToDate } from '../../utils/date'; @@ -73,7 +74,7 @@ class WaitlistFuture implements WaitlistFutureResource { return this.resource.updatedAt; } - async join(params: JoinWaitlistParams): Promise<{ error: unknown }> { + async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> { return runAsyncResourceTask(this.resource, async () => { await this.resource.__internal_basePost({ path: this.resource.pathRoot, diff --git a/packages/shared/src/types/waitlist.ts b/packages/shared/src/types/waitlist.ts index 016a08cc868..cc542ee590d 100644 --- a/packages/shared/src/types/waitlist.ts +++ b/packages/shared/src/types/waitlist.ts @@ -1,3 +1,4 @@ +import type { ClerkError } from '../error'; import type { ClerkResource } from './resource'; export interface WaitlistResource extends ClerkResource { @@ -8,7 +9,7 @@ export interface WaitlistResource extends ClerkResource { export interface WaitlistFutureResource { /** - * The unique identifier for the waitlist entry. `null` if the user has not joined the waitlist yet. + * The unique identifier for the waitlist entry. `undefined` if the user has not joined the waitlist yet. */ readonly id?: string; @@ -23,9 +24,9 @@ export interface WaitlistFutureResource { readonly updatedAt: Date | null; /** - * Used to join the waitlist with the provided email address. + * Used to add the provided `emailAddress` to the waitlist. */ - join: (params: JoinWaitlistParams) => Promise<{ error: unknown }>; + join: (params: JoinWaitlistParams) => Promise<{ error: ClerkError | null }>; } export type JoinWaitlistParams = { From 346a83d1a010a2efe303c9534764d05c364de78e Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 19 Nov 2025 20:55:39 -0600 Subject: [PATCH 12/19] revert unnecessary changes --- .../src/compiled/path-to-regexp/index.js | 24 +++++++++---------- .../tanstack-react-start/src/experimental.ts | 1 + 2 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 packages/tanstack-react-start/src/experimental.ts diff --git a/packages/shared/src/compiled/path-to-regexp/index.js b/packages/shared/src/compiled/path-to-regexp/index.js index 7112d4ae2b8..7dbeaca3864 100644 --- a/packages/shared/src/compiled/path-to-regexp/index.js +++ b/packages/shared/src/compiled/path-to-regexp/index.js @@ -45,12 +45,12 @@ function _(r) { break; } if (!u) throw new TypeError('Missing parameter name at '.concat(e)); - n.push({ + (n.push({ type: 'NAME', index: e, value: u, }), - (e = t); + (e = t)); continue; } if (a === '(') { @@ -74,12 +74,12 @@ function _(r) { } if (o) throw new TypeError('Unbalanced pattern at '.concat(e)); if (!m) throw new TypeError('Missing pattern at '.concat(e)); - n.push({ + (n.push({ type: 'PATTERN', index: e, value: m, }), - (e = t); + (e = t)); continue; } n.push({ @@ -147,7 +147,7 @@ function F(r, n) { C = f('PATTERN'); if (x || C) { var g = T || ''; - u.indexOf(g) === -1 && ((p += g), (g = '')), + (u.indexOf(g) === -1 && ((p += g), (g = '')), p && (o.push(p), (p = '')), o.push({ name: x || m++, @@ -155,7 +155,7 @@ function F(r, n) { suffix: '', pattern: C || A(g), modifier: f('MODIFIER') || '', - }); + })); continue; } var i = T || f('ESCAPED_CHAR'); @@ -170,14 +170,14 @@ function F(r, n) { y = f('NAME') || '', O = f('PATTERN') || '', b = d(); - w('CLOSE'), + (w('CLOSE'), o.push({ name: y || (O ? m++ : ''), pattern: y && !O ? A(g) : O, prefix: g, suffix: b, modifier: f('MODIFIER') || '', - }); + })); continue; } w('END'); @@ -240,14 +240,14 @@ function D(r) { function $(r, n) { if (!n) return r; for (var e = /\((?:\?<(.*?)>)?(?!\?)/g, a = 0, u = e.exec(r.source); u; ) - n.push({ + (n.push({ name: u[1] || a++, prefix: '', suffix: '', modifier: '', pattern: '', }), - (u = e.exec(r.source)); + (u = e.exec(r.source))); return r; } @@ -316,11 +316,11 @@ function U(r, n, e) { else x += '(?:'.concat(R).concat(y, ')').concat(i.modifier); } } - if (m) u || (x += ''.concat(T, '?')), (x += e.endsWith ? '(?='.concat(A, ')') : '$'); + if (m) (u || (x += ''.concat(T, '?')), (x += e.endsWith ? '(?='.concat(A, ')') : '$')); else { var b = r[r.length - 1], l = typeof b == 'string' ? T.indexOf(b[b.length - 1]) > -1 : b === void 0; - u || (x += '(?:'.concat(T, '(?=').concat(A, '))?')), l || (x += '(?='.concat(T, '|').concat(A, ')')); + (u || (x += '(?:'.concat(T, '(?=').concat(A, '))?')), l || (x += '(?='.concat(T, '|').concat(A, ')'))); } return new RegExp(x, D(e)); } diff --git a/packages/tanstack-react-start/src/experimental.ts b/packages/tanstack-react-start/src/experimental.ts new file mode 100644 index 00000000000..fc3fa4e8f09 --- /dev/null +++ b/packages/tanstack-react-start/src/experimental.ts @@ -0,0 +1 @@ +export * from '@clerk/react/experimental'; From d541b79e2ed6b6e4e1f4dade92a3d5a88b264789 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 19 Nov 2025 22:43:32 -0600 Subject: [PATCH 13/19] Remove WaitlistFuture --- .../clerk-js/src/core/resources/Waitlist.ts | 48 +++---------------- packages/clerk-js/src/core/signals.ts | 2 +- packages/clerk-js/src/core/state.ts | 2 +- packages/react/src/stateProxy.ts | 36 ++++++-------- packages/shared/src/types/state.ts | 8 ++-- packages/shared/src/types/waitlist.ts | 10 +--- 6 files changed, 30 insertions(+), 76 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Waitlist.ts b/packages/clerk-js/src/core/resources/Waitlist.ts index 7bde5725605..cf2e0ceffb5 100644 --- a/packages/clerk-js/src/core/resources/Waitlist.ts +++ b/packages/clerk-js/src/core/resources/Waitlist.ts @@ -1,5 +1,5 @@ import type { ClerkError } from '@clerk/shared/error'; -import type { JoinWaitlistParams, WaitlistFutureResource, WaitlistJSON, WaitlistResource } from '@clerk/shared/types'; +import type { JoinWaitlistParams, WaitlistJSON, WaitlistResource } from '@clerk/shared/types'; import { unixEpochToDate } from '../../utils/date'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; @@ -13,21 +13,6 @@ export class Waitlist extends BaseResource implements WaitlistResource { updatedAt: Date | null = null; createdAt: Date | null = null; - /** - * @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); @@ -46,6 +31,12 @@ export class Waitlist extends BaseResource implements WaitlistResource { return this; } + async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> { + return runAsyncResourceTask(this, async () => { + await Waitlist.join(params); + }); + } + static async join(params: JoinWaitlistParams): Promise { const json = ( await BaseResource._fetch({ @@ -58,28 +49,3 @@ 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: ClerkError | null }> { - return runAsyncResourceTask(this.resource, async () => { - await this.resource.__internal_basePost({ - path: this.resource.pathRoot, - body: params, - }); - }); - } -} diff --git a/packages/clerk-js/src/core/signals.ts b/packages/clerk-js/src/core/signals.ts index 91ef5e14d08..9286e57c936 100644 --- a/packages/clerk-js/src/core/signals.ts +++ b/packages/clerk-js/src/core/signals.ts @@ -55,7 +55,7 @@ export const waitlistComputedSignal: WaitlistSignal = computed(() => { const errors = errorsToWaitlistErrors(error); - return { errors, fetchStatus, waitlist: waitlist ? waitlist.__internal_future : null }; + return { errors, fetchStatus, waitlist }; }); /** diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index a77494f51bf..9bf041232c8 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -38,7 +38,7 @@ export class State implements StateInterface { waitlistFetchSignal = waitlistFetchSignal; waitlistSignal = waitlistComputedSignal; - private _waitlistInstance: Waitlist | null = null; + private _waitlistInstance: Waitlist; __internal_effect = effect; __internal_computed = computed; diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index c5022c18418..046545303da 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -8,6 +8,7 @@ import type { SignUpErrors, State, WaitlistErrors, + WaitlistResource, } from '@clerk/shared/types'; import { errorThrower } from './errors/errorThrower'; @@ -71,10 +72,7 @@ export class StateProxy implements State { } get __internal_waitlist() { - if (!inBrowser() || !this.isomorphicClerk.loaded) { - return null; - } - return this.isomorphicClerk.__internal_state.__internal_waitlist; + return this.state.__internal_waitlist; } checkoutSignal(params: CheckoutSignalProps) { @@ -282,30 +280,17 @@ export class StateProxy implements State { private buildWaitlistProxy() { const gateProperty = this.gateProperty.bind(this); const gateMethod = this.gateMethod.bind(this); - const fallbackWaitlistFuture = { - id: undefined, - createdAt: null, - updatedAt: null, - join: () => Promise.resolve({ error: null }), - }; - const target = (): typeof fallbackWaitlistFuture => { - if (!inBrowser() || !this.isomorphicClerk.loaded) { - return fallbackWaitlistFuture; - } - const state = this.isomorphicClerk.__internal_state; - const waitlist = state.__internal_waitlist; - if (waitlist && '__internal_future' in waitlist) { - return (waitlist as { __internal_future: typeof fallbackWaitlistFuture }).__internal_future; - } - return fallbackWaitlistFuture; + const target = (): WaitlistResource => { + return this.state.__internal_waitlist; }; return { errors: defaultWaitlistErrors(), fetchStatus: 'idle' as const, waitlist: { + pathRoot: '/waitlist', get id() { - return gateProperty(target, 'id', undefined); + return gateProperty(target, 'id', ''); }, get createdAt() { return gateProperty(target, 'createdAt', null); @@ -315,6 +300,7 @@ export class StateProxy implements State { }, join: gateMethod(target, 'join'), + reload: gateMethod(target, 'reload'), }, }; } @@ -382,6 +368,14 @@ export class StateProxy implements State { throw new Error('__internal_computed called before Clerk is loaded'); } + private get state() { + const s = this.isomorphicClerk.__internal_state; + if (!s) { + throw new Error('Clerk state not ready'); + } + return s; + } + private get client() { const c = this.isomorphicClerk.client; if (!c) { diff --git a/packages/shared/src/types/state.ts b/packages/shared/src/types/state.ts index 50ced7e4a2e..7c065226454 100644 --- a/packages/shared/src/types/state.ts +++ b/packages/shared/src/types/state.ts @@ -1,7 +1,7 @@ import type { ClerkGlobalHookError } from '../errors/globalHookError'; import type { SignInFutureResource } from './signInFuture'; import type { SignUpFutureResource } from './signUpFuture'; -import type { WaitlistFutureResource, WaitlistResource } from './waitlist'; +import type { WaitlistResource } from './waitlist'; /** * Represents an error on a specific field. @@ -182,10 +182,10 @@ export interface WaitlistSignalValue { /** * The underlying `Waitlist` resource. */ - waitlist: WaitlistFutureResource; + waitlist: WaitlistResource; } export type NullableWaitlistSignal = Omit & { - waitlist: WaitlistFutureResource | null; + waitlist: WaitlistResource | null; }; export interface WaitlistSignal { (): NullableWaitlistSignal; @@ -228,5 +228,5 @@ export interface State { /** * An instance of the Waitlist resource. */ - __internal_waitlist: WaitlistResource | null; + __internal_waitlist: WaitlistResource; } diff --git a/packages/shared/src/types/waitlist.ts b/packages/shared/src/types/waitlist.ts index cc542ee590d..52f16a427b6 100644 --- a/packages/shared/src/types/waitlist.ts +++ b/packages/shared/src/types/waitlist.ts @@ -2,16 +2,10 @@ import type { ClerkError } from '../error'; import type { ClerkResource } from './resource'; export interface WaitlistResource extends ClerkResource { - id: string; - createdAt: Date | null; - updatedAt: Date | null; -} - -export interface WaitlistFutureResource { /** - * The unique identifier for the waitlist entry. `undefined` if the user has not joined the waitlist yet. + * The unique identifier for the waitlist entry. `''` if the user has not joined the waitlist yet. */ - readonly id?: string; + readonly id: string; /** * The date and time the waitlist entry was created. `null` if the user has not joined the waitlist yet. From 203131c8648d0df3a0d5d1fe5c50d4d190f66167 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 20 Nov 2025 13:30:31 -0600 Subject: [PATCH 14/19] Fixes waitlist test cleanup and proper property access on waitlist response --- integration/presets/envs.ts | 6 ++--- integration/testUtils/index.ts | 2 ++ integration/testUtils/waitlistService.ts | 19 +++++++++++++++ .../tests/custom-flows/waitlist.test.ts | 24 +++++++++---------- integration/tests/waitlist-mode.test.ts | 2 +- .../src/api/endpoints/WaitlistEntryApi.ts | 2 +- .../clerk-js/src/core/resources/Waitlist.ts | 16 ++++++------- 7 files changed, 44 insertions(+), 27 deletions(-) create mode 100644 integration/testUtils/waitlistService.ts diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 318bbc4133b..cd807f0f242 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -134,9 +134,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 .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); @@ -220,7 +220,7 @@ export const envs = { withSignInOrUpEmailLinksFlow, withSignInOrUpFlow, withSignInOrUpwithRestrictedModeFlow, - withWaitlistdMode, + withWaitlistMode, withWhatsappPhoneCode, withProtectService, } as const; diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 53ee484f8a8..8aef94cccd0 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -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 }; @@ -40,6 +41,7 @@ export const createTestUtils = < users: createUserService(clerkClient), invitations: createInvitationService(clerkClient), organizations: createOrganizationsService(clerkClient), + waitlist: createWaitlistService(clerkClient), }; if (!params.page) { diff --git a/integration/testUtils/waitlistService.ts b/integration/testUtils/waitlistService.ts new file mode 100644 index 00000000000..b858059ca0e --- /dev/null +++ b/integration/testUtils/waitlistService.ts @@ -0,0 +1,19 @@ +import type { ClerkClient } from '@clerk/backend'; + +export type WaitlistService = { + clearWaitlistByEmail: (email: string) => Promise; +}; + +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; +}; diff --git a/integration/tests/custom-flows/waitlist.test.ts b/integration/tests/custom-flows/waitlist.test.ts index c03162d6be7..80820298256 100644 --- a/integration/tests/custom-flows/waitlist.test.ts +++ b/integration/tests/custom-flows/waitlist.test.ts @@ -3,24 +3,24 @@ 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 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; + let fakeEmail: string; test.beforeAll(async () => { app = await appConfigs.customFlows.reactVite.clone().commit(); await app.setup(); - await app.withEnv(appConfigs.envs.withWaitlistdMode); + await app.withEnv(appConfigs.envs.withWaitlistMode); 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 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); await clerkSetup({ @@ -32,14 +32,12 @@ test.describe('Custom Flows Waitlist @custom', () => { dotenv: false, }); - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser({ - fictionalEmail: true, - }); + fakeEmail = `${hash()}+clerk_test@clerkcookie.com`; }); test.afterAll(async () => { - await fakeUser.deleteIfExists(); + const u = createTestUtils({ app }); + await u.services.waitlist.clearWaitlistByEmail(fakeEmail); await app.teardown(); }); @@ -52,7 +50,7 @@ test.describe('Custom Flows Waitlist @custom', () => { const emailInput = u.page.getByTestId('email-input'); const submitButton = u.page.getByTestId('submit-button'); - await emailInput.fill(fakeUser.email); + await emailInput.fill(fakeEmail); await submitButton.click(); await expect(u.page.getByText('Successfully joined!')).toBeVisible(); @@ -83,7 +81,7 @@ test.describe('Custom Flows Waitlist @custom', () => { const emailInput = u.page.getByTestId('email-input'); const submitButton = u.page.getByTestId('submit-button'); - await emailInput.fill(fakeUser.email); + await emailInput.fill(fakeEmail); const submitPromise = submitButton.click(); diff --git a/integration/tests/waitlist-mode.test.ts b/integration/tests/waitlist-mode.test.ts index 3880f898c48..79e867cc31c 100644 --- a/integration/tests/waitlist-mode.test.ts +++ b/integration/tests/waitlist-mode.test.ts @@ -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 }); diff --git a/packages/backend/src/api/endpoints/WaitlistEntryApi.ts b/packages/backend/src/api/endpoints/WaitlistEntryApi.ts index f740d9f58de..2d09db2d525 100644 --- a/packages/backend/src/api/endpoints/WaitlistEntryApi.ts +++ b/packages/backend/src/api/endpoints/WaitlistEntryApi.ts @@ -37,7 +37,7 @@ export class WaitlistEntryAPI extends AbstractAPI { * @param params Optional parameters (e.g., `query`, `status`, `orderBy`). */ public async list(params: WaitlistEntryListParams = {}) { - return this.request>({ + return this.request>({ method: 'GET', path: basePath, queryParams: params, diff --git a/packages/clerk-js/src/core/resources/Waitlist.ts b/packages/clerk-js/src/core/resources/Waitlist.ts index cf2e0ceffb5..f12617bbc30 100644 --- a/packages/clerk-js/src/core/resources/Waitlist.ts +++ b/packages/clerk-js/src/core/resources/Waitlist.ts @@ -38,14 +38,12 @@ export class Waitlist extends BaseResource implements WaitlistResource { } static async join(params: JoinWaitlistParams): Promise { - const json = ( - await BaseResource._fetch({ - path: '/waitlist', - method: 'POST', - body: params as any, - }) - )?.response as unknown as WaitlistJSON; - - return new Waitlist(json); + const json = await BaseResource._fetch({ + path: '/waitlist', + method: 'POST', + body: params as any, + }); + + return new Waitlist(json as unknown as WaitlistJSON); } } From b24759b08a967b1253bebaef1efc35999a0ec70c Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 6 Jan 2026 20:22:15 -0600 Subject: [PATCH 15/19] fix(clerk-js): Update _waitlistInstance on resource update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensures __internal_waitlist and proxies reflect the latest waitlist state by updating _waitlistInstance before emitting the signal. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/clerk-js/src/core/state.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index 9bf041232c8..d601cdc581d 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -88,6 +88,7 @@ export class State implements StateInterface { } if (payload.resource instanceof Waitlist) { + this._waitlistInstance = payload.resource; this.waitlistResourceSignal({ resource: payload.resource }); } }; From 51f6853d6d2905aeefb4d46eae6482e4e5b40abc Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 6 Jan 2026 20:28:27 -0600 Subject: [PATCH 16/19] fix(integration): Use unique emails per waitlist test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each test now generates its own unique email to prevent race conditions when tests run in parallel. Emails are collected and cleaned up in afterAll. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- integration/tests/custom-flows/waitlist.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/integration/tests/custom-flows/waitlist.test.ts b/integration/tests/custom-flows/waitlist.test.ts index 80820298256..06288ca48b6 100644 --- a/integration/tests/custom-flows/waitlist.test.ts +++ b/integration/tests/custom-flows/waitlist.test.ts @@ -10,7 +10,7 @@ import { createTestUtils } from '../../testUtils'; test.describe('Custom Flows Waitlist @custom', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; - let fakeEmail: string; + const fakeEmails: string[] = []; test.beforeAll(async () => { app = await appConfigs.customFlows.reactVite.clone().commit(); @@ -31,17 +31,18 @@ test.describe('Custom Flows Waitlist @custom', () => { apiUrl, dotenv: false, }); - - fakeEmail = `${hash()}+clerk_test@clerkcookie.com`; }); test.afterAll(async () => { const u = createTestUtils({ app }); - await u.services.waitlist.clearWaitlistByEmail(fakeEmail); + 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(); @@ -73,6 +74,9 @@ test.describe('Custom Flows Waitlist @custom', () => { }); 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(); From ce964e53613b8e584547a400485d7320fc4dee05 Mon Sep 17 00:00:00 2001 From: brkalow Date: Tue, 6 Jan 2026 22:25:08 -0600 Subject: [PATCH 17/19] format --- packages/localizations/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/localizations/README.md b/packages/localizations/README.md index dcd5092fc6c..560fdb73126 100644 --- a/packages/localizations/README.md +++ b/packages/localizations/README.md @@ -66,7 +66,6 @@ We're open to all community contributions! If you'd like to contribute in any wa 1. Open the [`localizations/src/en-US.ts`](https://github.com/clerk/javascript/blob/main/packages/localizations/src/en-US.ts) file and add your new key to the object. `en-US` is the default language. If you feel comfortable adding your message in another language than English, feel free to also edit other files. 1. Use the new localization key inside the component. There are two ways: - - The string is inside a component like ``: ```diff From 0cf9faf854cd4280d1b440dc3175bf220381a549 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 7 Jan 2026 23:26:49 -0600 Subject: [PATCH 18/19] adds changeset --- .changeset/young-areas-divide.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/young-areas-divide.md diff --git a/.changeset/young-areas-divide.md b/.changeset/young-areas-divide.md new file mode 100644 index 00000000000..d68ddff805c --- /dev/null +++ b/.changeset/young-areas-divide.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/react': minor +--- + +Introduce `useWaitlist()` hook From c7b5ae74c3d833c6fa6876d978326c86f8590487 Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 8 Jan 2026 10:27:32 -0600 Subject: [PATCH 19/19] address PR feedback --- packages/react/src/hooks/useClerkSignal.ts | 9 ++++++--- packages/tanstack-react-start/package.json | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/react/src/hooks/useClerkSignal.ts b/packages/react/src/hooks/useClerkSignal.ts index 63c5fcdae5f..54976b6ab79 100644 --- a/packages/react/src/hooks/useClerkSignal.ts +++ b/packages/react/src/hooks/useClerkSignal.ts @@ -22,6 +22,9 @@ function useClerkSignal( case 'signUp': clerk.telemetry?.record(eventMethodCalled('useSignUp', { apiVersion: '2025-11' })); break; + case 'waitlist': + clerk.telemetry?.record(eventMethodCalled('useWaitlist', { apiVersion: '2025-11' })); + break; default: break; } @@ -73,7 +76,7 @@ function useClerkSignal( * This hook allows you to access the Signal-based `SignIn` resource. * * @example - * import { useSignInSignal } from "@clerk/react/experimental"; + * import { useSignIn } from "@clerk/react"; * * function SignInForm() { * const { signIn, errors, fetchStatus } = useSignInSignal(); @@ -90,7 +93,7 @@ export function useSignIn() { * This hook allows you to access the Signal-based `SignUp` resource. * * @example - * import { useSignUpSignal } from "@clerk/react/experimental"; + * import { useSignUp } from "@clerk/react"; * * function SignUpForm() { * const { signUp, errors, fetchStatus } = useSignUpSignal(); @@ -107,7 +110,7 @@ export function useSignUp() { * This hook allows you to access the Signal-based `Waitlist` resource. * * @example - * import { useWaitlist } from "@clerk/react/experimental"; + * import { useWaitlist } from "@clerk/react"; * * function WaitlistForm() { * const { waitlist, errors, fetchStatus } = useWaitlist(); diff --git a/packages/tanstack-react-start/package.json b/packages/tanstack-react-start/package.json index 08c7e1ea494..02517af2a8d 100644 --- a/packages/tanstack-react-start/package.json +++ b/packages/tanstack-react-start/package.json @@ -47,6 +47,10 @@ "types": "./dist/legacy.d.ts", "default": "./dist/legacy.js" }, + "./experimental": { + "types": "./dist/experimental.d.ts", + "default": "./dist/experimental.js" + }, "./package.json": "./package.json" }, "main": "dist/index.js",