From 26419cb947b4e11489137e838bbee96986520e8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Oct 2025 16:53:58 +0000 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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", ] `;