diff --git a/.changeset/every-buttons-travel.md b/.changeset/every-buttons-travel.md
new file mode 100644
index 00000000000..4b89a19b70b
--- /dev/null
+++ b/.changeset/every-buttons-travel.md
@@ -0,0 +1,10 @@
+---
+'@clerk/tanstack-react-start': minor
+'@clerk/react-router': minor
+'@clerk/nextjs': minor
+'@clerk/shared': minor
+'@clerk/clerk-react': minor
+'@clerk/remix': minor
+---
+
+[Billing Beta]: Introduce experimental `useCheckout()` hook and ``.
diff --git a/.changeset/great-roses-punch.md b/.changeset/great-roses-punch.md
new file mode 100644
index 00000000000..2943a8420c5
--- /dev/null
+++ b/.changeset/great-roses-punch.md
@@ -0,0 +1,7 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/clerk-react': minor
+'@clerk/types': minor
+---
+
+[Billing Beta]: Introduce experimental `Clerk.__experimental_checkout()` for managing the state of a checkout session.
diff --git a/.changeset/stale-pillows-sneeze.md b/.changeset/stale-pillows-sneeze.md
new file mode 100644
index 00000000000..7640b43579e
--- /dev/null
+++ b/.changeset/stale-pillows-sneeze.md
@@ -0,0 +1,9 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/nextjs': minor
+'@clerk/shared': minor
+'@clerk/clerk-react': minor
+'@clerk/types': minor
+---
+
+wip
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index 3a0c7d1afee..6ef84ec3abc 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -1,11 +1,11 @@
{
"files": [
- { "path": "./dist/clerk.js", "maxSize": "612kB" },
+ { "path": "./dist/clerk.js", "maxSize": "612.37kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
- { "path": "./dist/ui-common*.js", "maxSize": "111.9KB" },
- { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "113.67KB" },
+ { "path": "./dist/ui-common*.js", "maxSize": "110KB" },
+ { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "113.72KB" },
{ "path": "./dist/vendors*.js", "maxSize": "40.2KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
{ "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" },
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 763f1f78ec5..80a87e2a838 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -15,6 +15,8 @@ import {
import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url';
import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils';
import type {
+ __experimental_CheckoutInstance,
+ __experimental_CheckoutOptions,
__internal_CheckoutProps,
__internal_ComponentNavigationContext,
__internal_OAuthConsentProps,
@@ -137,6 +139,7 @@ import type { FapiClient, FapiRequestCallback } from './fapiClient';
import { createFapiClient } from './fapiClient';
import { createClientFromJwt } from './jwt-client';
import { APIKeys } from './modules/apiKeys';
+import { createCheckoutInstance } from './modules/checkout/instance';
import { CommerceBilling } from './modules/commerce';
import {
BaseResource,
@@ -197,6 +200,7 @@ export class Clerk implements ClerkInterface {
};
private static _billing: CommerceBillingNamespace;
private static _apiKeys: APIKeysNamespace;
+ private _checkout: ClerkInterface['__experimental_checkout'] | undefined;
public client: ClientResource | undefined;
public session: SignedInSessionResource | null | undefined;
@@ -339,6 +343,13 @@ export class Clerk implements ClerkInterface {
return Clerk._apiKeys;
}
+ __experimental_checkout(options: __experimental_CheckoutOptions): __experimental_CheckoutInstance {
+ if (!this._checkout) {
+ this._checkout = params => createCheckoutInstance(this, params);
+ }
+ return this._checkout(options);
+ }
+
public __internal_getOption(key: K): ClerkOptions[K] {
return this.#options[key];
}
diff --git a/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts
new file mode 100644
index 00000000000..d9b2bd13aa9
--- /dev/null
+++ b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts
@@ -0,0 +1,695 @@
+import type { ClerkAPIResponseError, CommerceCheckoutResource } from '@clerk/types';
+import type { MockedFunction } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { type CheckoutCacheState, type CheckoutKey, createCheckoutManager, FETCH_STATUS } from '../manager';
+
+// Type-safe mock for CommerceCheckoutResource
+const createMockCheckoutResource = (overrides: Partial = {}): CommerceCheckoutResource => ({
+ id: 'checkout_123',
+ status: 'pending',
+ externalClientSecret: 'cs_test_123',
+ externalGatewayId: 'gateway_123',
+ statement_id: 'stmt_123',
+ totals: {
+ totalDueNow: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' },
+ credit: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' },
+ pastDue: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' },
+ subtotal: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' },
+ grandTotal: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' },
+ taxTotal: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' },
+ },
+ isImmediatePlanChange: false,
+ planPeriod: 'month',
+ plan: {
+ id: 'plan_123',
+ name: 'Pro Plan',
+ description: 'Professional plan',
+ features: [],
+ amount: 1000,
+ amountFormatted: '10.00',
+ annualAmount: 12000,
+ annualAmountFormatted: '120.00',
+ currency: 'USD',
+ currencySymbol: '$',
+ slug: 'pro-plan',
+ },
+ paymentSource: undefined,
+ confirm: vi.fn(),
+ reload: vi.fn(),
+ pathRoot: '/checkout',
+ ...overrides,
+});
+
+// Type-safe mock for ClerkAPIResponseError
+const createMockError = (message = 'Test error'): ClerkAPIResponseError => {
+ const error = new Error(message) as ClerkAPIResponseError;
+ error.status = 400;
+ error.clerkTraceId = 'trace_123';
+ error.clerkError = true;
+ return error;
+};
+
+// Helper to create a typed cache key
+const createCacheKey = (key: string): CheckoutKey => key as CheckoutKey;
+
+describe('createCheckoutManager', () => {
+ const testCacheKey = createCacheKey('user-123-plan-456-monthly');
+ let manager: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ manager = createCheckoutManager(testCacheKey);
+ });
+
+ describe('getCacheState', () => {
+ it('should return default state when cache is empty', () => {
+ const state = manager.getCacheState();
+
+ expect(state).toEqual({
+ isStarting: false,
+ isConfirming: false,
+ error: null,
+ checkout: null,
+ fetchStatus: 'idle',
+ status: 'awaiting_initialization',
+ });
+ });
+
+ it('should return immutable state object', () => {
+ const state = manager.getCacheState();
+
+ // State should be frozen
+ expect(Object.isFrozen(state)).toBe(true);
+ });
+ });
+
+ describe('subscribe', () => {
+ it('should add listener and return unsubscribe function', () => {
+ const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn();
+
+ const unsubscribe = manager.subscribe(listener);
+
+ expect(typeof unsubscribe).toBe('function');
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('should remove listener when unsubscribe is called', async () => {
+ const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn();
+
+ const unsubscribe = manager.subscribe(listener);
+
+ // Trigger a state change
+ const mockCheckout = createMockCheckoutResource();
+ const mockOperation = vi.fn().mockResolvedValue(mockCheckout);
+ await manager.executeOperation('start', mockOperation);
+
+ expect(listener).toHaveBeenCalled();
+
+ // Clear the mock and unsubscribe
+ listener.mockClear();
+ unsubscribe();
+
+ // Trigger another state change
+ const anotherMockOperation = vi.fn().mockResolvedValue(mockCheckout);
+ await manager.executeOperation('confirm', anotherMockOperation);
+
+ // Listener should not be called after unsubscribing
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('should notify all listeners when state changes', async () => {
+ const listener1: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn();
+ const listener2: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn();
+ const mockCheckout = createMockCheckoutResource();
+
+ manager.subscribe(listener1);
+ manager.subscribe(listener2);
+
+ const mockOperation = vi.fn().mockResolvedValue(mockCheckout);
+ await manager.executeOperation('start', mockOperation);
+
+ expect(listener1).toHaveBeenCalled();
+ expect(listener2).toHaveBeenCalled();
+
+ // Verify they were called with the updated state
+ const expectedState = expect.objectContaining({
+ checkout: mockCheckout,
+ isStarting: false,
+ error: null,
+ fetchStatus: 'idle',
+ status: 'awaiting_confirmation',
+ });
+
+ expect(listener1).toHaveBeenCalledWith(expectedState);
+ expect(listener2).toHaveBeenCalledWith(expectedState);
+ });
+
+ it('should handle multiple subscribe/unsubscribe cycles', () => {
+ const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn();
+
+ // Subscribe and unsubscribe multiple times
+ const unsubscribe1 = manager.subscribe(listener);
+ unsubscribe1();
+
+ const unsubscribe2 = manager.subscribe(listener);
+ const unsubscribe3 = manager.subscribe(listener);
+
+ unsubscribe2();
+ unsubscribe3();
+
+ // Should not throw errors
+ expect(() => unsubscribe1()).not.toThrow();
+ expect(() => unsubscribe2()).not.toThrow();
+ });
+ });
+
+ describe('executeOperation - start operations', () => {
+ it('should execute start operation successfully', async () => {
+ const mockCheckout = createMockCheckoutResource();
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockResolvedValue(mockCheckout);
+
+ const result = await manager.executeOperation('start', mockOperation);
+
+ expect(mockOperation).toHaveBeenCalledOnce();
+ expect(result).toEqual({
+ data: mockCheckout,
+ error: null,
+ });
+
+ const finalState = manager.getCacheState();
+ expect(finalState).toEqual(
+ expect.objectContaining({
+ isStarting: false,
+ checkout: mockCheckout,
+ error: null,
+ fetchStatus: 'idle',
+ status: 'awaiting_confirmation',
+ }),
+ );
+ });
+
+ it('should set isStarting to true during operation', async () => {
+ let capturedState: CheckoutCacheState | null = null;
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(async () => {
+ // Capture state while operation is running
+ capturedState = manager.getCacheState();
+ return createMockCheckoutResource();
+ });
+
+ await manager.executeOperation('start', mockOperation);
+
+ expect(capturedState).toEqual(
+ expect.objectContaining({
+ isStarting: true,
+ fetchStatus: 'fetching',
+ }),
+ );
+ });
+
+ it('should handle operation errors correctly', async () => {
+ const mockError = createMockError('Operation failed');
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockRejectedValue(mockError);
+
+ const result = await manager.executeOperation('start', mockOperation);
+
+ expect(result).toEqual({
+ data: null,
+ error: mockError,
+ });
+
+ const finalState = manager.getCacheState();
+ expect(finalState).toEqual(
+ expect.objectContaining({
+ isStarting: false,
+ error: mockError,
+ fetchStatus: 'error',
+ status: 'awaiting_initialization',
+ }),
+ );
+ });
+
+ it('should clear previous errors when starting new operation', async () => {
+ // First, create an error state
+ const mockError = createMockError('Previous error');
+ const failingOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockRejectedValue(mockError);
+
+ const result = await manager.executeOperation('start', failingOperation);
+ expect(result).toEqual({
+ data: null,
+ error: mockError,
+ });
+
+ const errorState = manager.getCacheState();
+ expect(errorState.error).toBe(mockError);
+
+ // Now start a successful operation
+ const mockCheckout = createMockCheckoutResource();
+ const successfulOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockResolvedValue(mockCheckout);
+
+ const successResult = await manager.executeOperation('start', successfulOperation);
+ expect(successResult).toEqual({
+ data: mockCheckout,
+ error: null,
+ });
+
+ const finalState = manager.getCacheState();
+ expect(finalState.error).toBeNull();
+ expect(finalState.checkout).toBe(mockCheckout);
+ });
+ });
+
+ describe('executeOperation - confirm operations', () => {
+ it('should execute confirm operation successfully', async () => {
+ const mockCheckout = createMockCheckoutResource({ status: 'completed' });
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockResolvedValue(mockCheckout);
+
+ const result = await manager.executeOperation('confirm', mockOperation);
+
+ expect(result).toEqual({
+ data: mockCheckout,
+ error: null,
+ });
+
+ const finalState = manager.getCacheState();
+ expect(finalState).toEqual(
+ expect.objectContaining({
+ isConfirming: false,
+ checkout: mockCheckout,
+ error: null,
+ fetchStatus: 'idle',
+ status: 'completed',
+ }),
+ );
+ });
+
+ it('should set isConfirming to true during operation', async () => {
+ let capturedState: CheckoutCacheState | null = null;
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(async () => {
+ capturedState = manager.getCacheState();
+ return createMockCheckoutResource();
+ });
+
+ await manager.executeOperation('confirm', mockOperation);
+
+ expect(capturedState).toEqual(
+ expect.objectContaining({
+ isConfirming: true,
+ fetchStatus: 'fetching',
+ }),
+ );
+ });
+
+ it('should handle confirm operation errors', async () => {
+ const mockError = createMockError('Confirm failed');
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockRejectedValue(mockError);
+
+ const result = await manager.executeOperation('confirm', mockOperation);
+ expect(result).toEqual({
+ data: null,
+ error: mockError,
+ });
+
+ const finalState = manager.getCacheState();
+ expect(finalState).toEqual(
+ expect.objectContaining({
+ isConfirming: false,
+ error: mockError,
+ fetchStatus: 'error',
+ }),
+ );
+ });
+ });
+
+ describe('operation deduplication', () => {
+ it('should deduplicate concurrent start operations', async () => {
+ const mockCheckout = createMockCheckoutResource();
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockCheckout), 50)));
+
+ // Start multiple operations concurrently
+ const [result1, result2, result3] = await Promise.all([
+ manager.executeOperation('start', mockOperation),
+ manager.executeOperation('start', mockOperation),
+ manager.executeOperation('start', mockOperation),
+ ]);
+
+ // Operation should only be called once
+ expect(mockOperation).toHaveBeenCalledOnce();
+
+ // All results should be the same
+ expect(result1).toEqual({
+ data: mockCheckout,
+ error: null,
+ });
+ expect(result2).toEqual({
+ data: mockCheckout,
+ error: null,
+ });
+ expect(result3).toEqual({
+ data: mockCheckout,
+ error: null,
+ });
+ });
+
+ it('should deduplicate concurrent confirm operations', async () => {
+ const mockCheckout = createMockCheckoutResource();
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockCheckout), 50)));
+
+ const [result1, result2] = await Promise.all([
+ manager.executeOperation('confirm', mockOperation),
+ manager.executeOperation('confirm', mockOperation),
+ ]);
+
+ expect(mockOperation).toHaveBeenCalledOnce();
+ expect(result1).toBe(result2);
+ });
+
+ it('should allow different operation types to run concurrently', async () => {
+ const startCheckout = createMockCheckoutResource({ id: 'start_checkout' });
+ const confirmCheckout = createMockCheckoutResource({ id: 'confirm_checkout' });
+
+ const startOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(startCheckout), 50)));
+ const confirmOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(confirmCheckout), 50)));
+
+ const [startResult, confirmResult] = await Promise.all([
+ manager.executeOperation('start', startOperation),
+ manager.executeOperation('confirm', confirmOperation),
+ ]);
+
+ expect(startOperation).toHaveBeenCalledOnce();
+ expect(confirmOperation).toHaveBeenCalledOnce();
+ expect(startResult).toEqual({
+ data: startCheckout,
+ error: null,
+ });
+ expect(confirmResult).toEqual({
+ data: confirmCheckout,
+ error: null,
+ });
+ });
+
+ it('should propagate errors to all concurrent callers', async () => {
+ const mockError = createMockError('Concurrent operation failed');
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(() => new Promise((_, reject) => setTimeout(() => reject(mockError), 50)));
+
+ const promises = [
+ manager.executeOperation('start', mockOperation),
+ manager.executeOperation('start', mockOperation),
+ manager.executeOperation('start', mockOperation),
+ ];
+
+ const results = await Promise.all(promises);
+
+ // All promises should resolve with the same error
+ results.forEach(result => {
+ expect(result).toEqual({
+ data: null,
+ error: mockError,
+ });
+ });
+ expect(mockOperation).toHaveBeenCalledOnce();
+ });
+
+ it('should allow sequential operations of the same type', async () => {
+ const checkout1 = createMockCheckoutResource({ id: 'checkout1' });
+ const checkout2 = createMockCheckoutResource({ id: 'checkout2' });
+
+ const operation1: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout1);
+ const operation2: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout2);
+
+ const result1 = await manager.executeOperation('start', operation1);
+ const result2 = await manager.executeOperation('start', operation2);
+
+ expect(operation1).toHaveBeenCalledOnce();
+ expect(operation2).toHaveBeenCalledOnce();
+ expect(result1).toEqual({
+ data: checkout1,
+ error: null,
+ });
+ expect(result2).toEqual({
+ data: checkout2,
+ error: null,
+ });
+ });
+ });
+
+ describe('clearCheckout', () => {
+ it('should clear checkout state when no operations are pending', () => {
+ const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn();
+ manager.subscribe(listener);
+
+ manager.clearCheckout();
+
+ const state = manager.getCacheState();
+ expect(state).toEqual({
+ isStarting: false,
+ isConfirming: false,
+ error: null,
+ checkout: null,
+ fetchStatus: 'idle',
+ status: 'awaiting_initialization',
+ });
+
+ // Should notify listeners
+ expect(listener).toHaveBeenCalledWith(state);
+ });
+
+ it('should not clear checkout state when operations are pending', async () => {
+ const mockCheckout = createMockCheckoutResource();
+ let resolveOperation: ((value: CommerceCheckoutResource) => void) | undefined;
+
+ const mockOperation: MockedFunction<() => Promise> = vi.fn().mockImplementation(
+ () =>
+ new Promise(resolve => {
+ resolveOperation = resolve;
+ }),
+ );
+
+ // Start an operation but don't resolve it yet
+ const operationPromise = manager.executeOperation('start', mockOperation);
+
+ // Verify operation is in progress
+ let state = manager.getCacheState();
+ expect(state.isStarting).toBe(true);
+ expect(state.fetchStatus).toBe('fetching');
+
+ // Try to clear while operation is pending
+ manager.clearCheckout();
+
+ // State should not be cleared
+ state = manager.getCacheState();
+ expect(state.isStarting).toBe(true);
+ expect(state.fetchStatus).toBe('fetching');
+
+ // Resolve the operation
+ resolveOperation?.(mockCheckout);
+ await operationPromise;
+
+ // Now clearing should work
+ manager.clearCheckout();
+ state = manager.getCacheState();
+ expect(state.checkout).toBeNull();
+ expect(state.status).toBe('awaiting_initialization');
+ });
+ });
+
+ describe('state derivation', () => {
+ it('should derive fetchStatus correctly based on operation state', async () => {
+ // Initially idle
+ expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.IDLE);
+
+ // During operation - fetching
+ let capturedState: CheckoutCacheState | null = null;
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(async () => {
+ capturedState = manager.getCacheState();
+ return createMockCheckoutResource();
+ });
+
+ await manager.executeOperation('start', mockOperation);
+ expect(capturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING);
+
+ // After successful operation - idle
+ expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.IDLE);
+
+ // After error - error
+ const mockError = createMockError();
+ const failingOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockRejectedValue(mockError);
+
+ const result = await manager.executeOperation('start', failingOperation);
+ expect(result).toEqual({
+ data: null,
+ error: mockError,
+ });
+ expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.ERROR);
+ });
+
+ it('should derive status based on checkout state', async () => {
+ // Initially awaiting initialization
+ expect(manager.getCacheState().status).toBe('awaiting_initialization');
+
+ // After starting checkout - awaiting confirmation
+ const pendingCheckout = createMockCheckoutResource({ status: 'pending' });
+ const startOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockResolvedValue(pendingCheckout);
+
+ await manager.executeOperation('start', startOperation);
+ expect(manager.getCacheState().status).toBe('awaiting_confirmation');
+
+ // After completing checkout - completed
+ const completedCheckout = createMockCheckoutResource({ status: 'completed' });
+ const confirmOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockResolvedValue(completedCheckout);
+
+ await manager.executeOperation('confirm', confirmOperation);
+ expect(manager.getCacheState().status).toBe('completed');
+ });
+
+ it('should handle both operations running simultaneously', async () => {
+ let startCapturedState: CheckoutCacheState | null = null;
+ let confirmCapturedState: CheckoutCacheState | null = null;
+
+ const startOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(async () => {
+ await new Promise(resolve => setTimeout(resolve, 30));
+ startCapturedState = manager.getCacheState();
+ return createMockCheckoutResource({ id: 'start' });
+ });
+
+ const confirmOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(async () => {
+ await new Promise(resolve => setTimeout(resolve, 20));
+ confirmCapturedState = manager.getCacheState();
+ return createMockCheckoutResource({ id: 'confirm' });
+ });
+
+ await Promise.all([
+ manager.executeOperation('start', startOperation),
+ manager.executeOperation('confirm', confirmOperation),
+ ]);
+
+ // Both should have seen fetching status
+ expect(startCapturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING);
+ expect(confirmCapturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING);
+
+ // At least one should have seen both operations running
+ expect(
+ (startCapturedState?.isStarting && startCapturedState?.isConfirming) ||
+ (confirmCapturedState?.isStarting && confirmCapturedState?.isConfirming),
+ ).toBe(true);
+ });
+ });
+
+ describe('cache isolation', () => {
+ it('should isolate state between different cache keys', async () => {
+ const manager1 = createCheckoutManager(createCacheKey('key1'));
+ const manager2 = createCheckoutManager(createCacheKey('key2'));
+
+ const checkout1 = createMockCheckoutResource({ id: 'checkout1' });
+ const checkout2 = createMockCheckoutResource({ id: 'checkout2' });
+
+ const operation1: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout1);
+ const operation2: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout2);
+
+ await manager1.executeOperation('start', operation1);
+ await manager2.executeOperation('confirm', operation2);
+
+ const state1 = manager1.getCacheState();
+ const state2 = manager2.getCacheState();
+
+ expect(state1.checkout?.id).toBe('checkout1');
+ expect(state1.status).toBe('awaiting_confirmation');
+
+ expect(state2.checkout?.id).toBe('checkout2');
+ expect(state2.isStarting).toBe(false);
+ expect(state2.isConfirming).toBe(false);
+ });
+
+ it('should isolate listeners between different cache keys', async () => {
+ const manager1 = createCheckoutManager(createCacheKey('key1'));
+ const manager2 = createCheckoutManager(createCacheKey('key2'));
+
+ const listener1: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn();
+ const listener2: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn();
+
+ manager1.subscribe(listener1);
+ manager2.subscribe(listener2);
+
+ // Trigger operation on manager1
+ const mockOperation: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockResolvedValue(createMockCheckoutResource());
+ await manager1.executeOperation('start', mockOperation);
+
+ // Only listener1 should be called
+ expect(listener1).toHaveBeenCalled();
+ expect(listener2).not.toHaveBeenCalled();
+ });
+
+ it('should isolate pending operations between different cache keys', async () => {
+ const manager1 = createCheckoutManager(createCacheKey('key1'));
+ const manager2 = createCheckoutManager(createCacheKey('key2'));
+
+ const checkout1 = createMockCheckoutResource({ id: 'checkout1' });
+ const checkout2 = createMockCheckoutResource({ id: 'checkout2' });
+
+ const operation1: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(checkout1), 50)));
+ const operation2: MockedFunction<() => Promise> = vi
+ .fn()
+ .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(checkout2), 50)));
+
+ // Start concurrent operations on both managers
+ const [result1, result2] = await Promise.all([
+ manager1.executeOperation('start', operation1),
+ manager2.executeOperation('start', operation2),
+ ]);
+
+ // Both operations should execute (not deduplicated across managers)
+ expect(operation1).toHaveBeenCalledOnce();
+ expect(operation2).toHaveBeenCalledOnce();
+ expect(result1).toEqual({
+ data: checkout1,
+ error: null,
+ });
+ expect(result2).toEqual({
+ data: checkout2,
+ error: null,
+ });
+ });
+ });
+});
diff --git a/packages/clerk-js/src/core/modules/checkout/instance.ts b/packages/clerk-js/src/core/modules/checkout/instance.ts
new file mode 100644
index 00000000000..7ada04d355b
--- /dev/null
+++ b/packages/clerk-js/src/core/modules/checkout/instance.ts
@@ -0,0 +1,86 @@
+import type {
+ __experimental_CheckoutCacheState,
+ __experimental_CheckoutInstance,
+ __experimental_CheckoutOptions,
+} from '@clerk/types';
+
+import type { Clerk } from '../../clerk';
+import { type CheckoutKey, createCheckoutManager } from './manager';
+
+/**
+ * Generate cache key for checkout instance
+ */
+function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey {
+ const { userId, orgId, planId, planPeriod } = options;
+ return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}` as CheckoutKey;
+}
+
+/**
+ * Create a checkout instance with the given options
+ */
+function createCheckoutInstance(
+ clerk: Clerk,
+ options: __experimental_CheckoutOptions,
+): __experimental_CheckoutInstance {
+ const { for: forOrganization, planId, planPeriod } = options;
+
+ if (!clerk.user) {
+ throw new Error('Clerk: User is not authenticated');
+ }
+
+ if (forOrganization === 'organization' && !clerk.organization) {
+ throw new Error('Clerk: Use `setActive` to set the organization');
+ }
+
+ const checkoutKey = cacheKey({
+ userId: clerk.user.id,
+ orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined,
+ planId,
+ planPeriod,
+ });
+
+ const manager = createCheckoutManager(checkoutKey);
+
+ const start: __experimental_CheckoutInstance['start'] = async () => {
+ return manager.executeOperation('start', async () => {
+ const result = await clerk.billing?.startCheckout({
+ ...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}),
+ planId,
+ planPeriod,
+ });
+ return result;
+ });
+ };
+
+ const confirm: __experimental_CheckoutInstance['confirm'] = async params => {
+ return manager.executeOperation('confirm', async () => {
+ const checkout = manager.getCacheState().checkout;
+ if (!checkout) {
+ throw new Error('Clerk: Call `start` before `confirm`');
+ }
+ return checkout.confirm(params);
+ });
+ };
+
+ const finalize = (params?: { redirectUrl: string }) => {
+ const { redirectUrl } = params || {};
+ void clerk.setActive({ session: clerk.session?.id, redirectUrl });
+ };
+
+ const clear = () => manager.clearCheckout();
+
+ const subscribe = (listener: (state: __experimental_CheckoutCacheState) => void) => {
+ return manager.subscribe(listener);
+ };
+
+ return {
+ start,
+ confirm,
+ finalize,
+ clear,
+ subscribe,
+ getState: manager.getCacheState,
+ };
+}
+
+export { createCheckoutInstance };
diff --git a/packages/clerk-js/src/core/modules/checkout/manager.ts b/packages/clerk-js/src/core/modules/checkout/manager.ts
new file mode 100644
index 00000000000..c49a504567a
--- /dev/null
+++ b/packages/clerk-js/src/core/modules/checkout/manager.ts
@@ -0,0 +1,187 @@
+import type {
+ __experimental_CheckoutCacheState,
+ __experimental_CheckoutInstance,
+ ClerkAPIResponseError,
+ CommerceCheckoutResource,
+} from '@clerk/types';
+
+type CheckoutKey = string & { readonly __tag: 'CheckoutKey' };
+
+type CheckoutResult = Awaited>;
+
+const createManagerCache = () => {
+ const cache = new Map();
+ const listeners = new Map void>>();
+ const pendingOperations = new Map>>();
+
+ return {
+ cache,
+ listeners,
+ pendingOperations,
+ safeGet>(key: K, map: Map): NonNullable {
+ if (!map.has(key)) {
+ map.set(key, new Set() as V);
+ }
+ return map.get(key) as NonNullable;
+ },
+ safeGetOperations(key: K): Map> {
+ if (!this.pendingOperations.has(key)) {
+ this.pendingOperations.set(key, new Map>());
+ }
+ return this.pendingOperations.get(key) as Map>;
+ },
+ };
+};
+
+const managerCache = createManagerCache();
+
+const CHECKOUT_STATUS = {
+ AWAITING_INITIALIZATION: 'awaiting_initialization',
+ AWAITING_CONFIRMATION: 'awaiting_confirmation',
+ COMPLETED: 'completed',
+} as const;
+
+export const FETCH_STATUS = {
+ IDLE: 'idle',
+ FETCHING: 'fetching',
+ ERROR: 'error',
+} as const;
+
+/**
+ * Derives the checkout state from the base state.
+ */
+function deriveCheckoutState(
+ baseState: Omit<__experimental_CheckoutCacheState, 'fetchStatus' | 'status'>,
+): __experimental_CheckoutCacheState {
+ const fetchStatus = (() => {
+ if (baseState.isStarting || baseState.isConfirming) return FETCH_STATUS.FETCHING;
+ if (baseState.error) return FETCH_STATUS.ERROR;
+ return FETCH_STATUS.IDLE;
+ })();
+
+ const status = (() => {
+ if (baseState.checkout?.status === CHECKOUT_STATUS.COMPLETED) return CHECKOUT_STATUS.COMPLETED;
+ if (baseState.checkout) return CHECKOUT_STATUS.AWAITING_CONFIRMATION;
+ return CHECKOUT_STATUS.AWAITING_INITIALIZATION;
+ })();
+
+ return {
+ ...baseState,
+ fetchStatus,
+ status,
+ };
+}
+
+const defaultCacheState: __experimental_CheckoutCacheState = Object.freeze(
+ deriveCheckoutState({
+ isStarting: false,
+ isConfirming: false,
+ error: null,
+ checkout: null,
+ }),
+);
+
+/**
+ * Creates a checkout manager for handling checkout operations and state management.
+ *
+ * @param cacheKey - Unique identifier for the checkout instance
+ * @returns Manager with methods for checkout operations and state subscription
+ *
+ * @example
+ * ```typescript
+ * const manager = createCheckoutManager('user-123-plan-456-monthly');
+ * const unsubscribe = manager.subscribe(state => console.log(state));
+ * ```
+ */
+function createCheckoutManager(cacheKey: CheckoutKey) {
+ const listeners = managerCache.safeGet(cacheKey, managerCache.listeners);
+ const pendingOperations = managerCache.safeGetOperations(cacheKey);
+
+ const notifyListeners = () => {
+ listeners.forEach(listener => listener(getCacheState()));
+ };
+
+ const getCacheState = (): __experimental_CheckoutCacheState => {
+ return managerCache.cache.get(cacheKey) || defaultCacheState;
+ };
+
+ const updateCacheState = (
+ updates: Partial>,
+ ): void => {
+ const currentState = getCacheState();
+ const baseState = { ...currentState, ...updates };
+ const newState = deriveCheckoutState(baseState);
+ managerCache.cache.set(cacheKey, Object.freeze(newState));
+ notifyListeners();
+ };
+
+ return {
+ subscribe(listener: (newState: __experimental_CheckoutCacheState) => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+
+ getCacheState,
+
+ // Shared operation handler to eliminate duplication
+ async executeOperation(
+ operationType: 'start' | 'confirm',
+ operationFn: () => Promise,
+ ): Promise {
+ const operationId = `${cacheKey}-${operationType}`;
+ const isRunningField = operationType === 'start' ? 'isStarting' : 'isConfirming';
+
+ // Check if there's already a pending operation
+ const existingOperation = pendingOperations.get(operationId);
+ if (existingOperation) {
+ // Wait for the existing operation to complete and return its result
+ // If it fails, all callers should receive the same error
+ return await existingOperation;
+ }
+
+ // Create and store the operation promise
+ const operationPromise = (async () => {
+ let data: CommerceCheckoutResource | null = null;
+ let error: ClerkAPIResponseError | null = null;
+ try {
+ // Mark operation as in progress and clear any previous errors
+ updateCacheState({
+ [isRunningField]: true,
+ error: null,
+ ...(operationType === 'start' ? { checkout: null } : {}),
+ });
+
+ // Execute the checkout operation
+ const result = await operationFn();
+
+ // Update state with successful result
+ updateCacheState({ [isRunningField]: false, error: null, checkout: result });
+ data = result;
+ } catch (e) {
+ // Cast error to expected type and update state
+ const clerkError = e as ClerkAPIResponseError;
+ error = clerkError;
+ updateCacheState({ [isRunningField]: false, error: clerkError });
+ } finally {
+ // Always clean up pending operation tracker
+ pendingOperations.delete(operationId);
+ }
+ return { data, error } as CheckoutResult;
+ })();
+
+ pendingOperations.set(operationId, operationPromise);
+ return operationPromise;
+ },
+
+ clearCheckout(): void {
+ // Only reset the state if there are no pending operations
+ if (pendingOperations.size === 0) {
+ updateCacheState(defaultCacheState);
+ }
+ },
+ };
+}
+
+export { createCheckoutManager, type CheckoutKey };
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
index 6b5dad5fd62..4240e528ca5 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx
@@ -1,3 +1,4 @@
+import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react';
import { useEffect, useId, useRef, useState } from 'react';
import { Drawer, useDrawerContext } from '@/ui/elements/Drawer';
@@ -9,7 +10,6 @@ import { Box, Button, descriptors, Heading, localizationKeys, Span, Text, useApp
import { transitionDurationValues, transitionTiming } from '../../foundations/transitions';
import { usePrefersReducedMotion } from '../../hooks';
import { useRouter } from '../../router';
-import { useCheckoutContextRoot } from './CheckoutPage';
const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1);
const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt;
@@ -18,7 +18,8 @@ export const CheckoutComplete = () => {
const router = useRouter();
const { setIsOpen } = useDrawerContext();
const { newSubscriptionRedirectUrl } = useCheckoutContext();
- const { checkout } = useCheckoutContextRoot();
+ const { checkout } = useCheckout();
+ const { totals, paymentSource, planPeriodStart } = checkout;
const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 });
const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 });
@@ -82,7 +83,7 @@ export const CheckoutComplete = () => {
}
};
- if (!checkout) {
+ if (!totals) {
return null;
}
@@ -309,7 +310,7 @@ export const CheckoutComplete = () => {
as='h2'
textVariant='h2'
localizationKey={
- checkout.totals.totalDueNow.amount > 0
+ totals.totalDueNow.amount > 0
? localizationKeys('commerce.checkout.title__paymentSuccessful')
: localizationKeys('commerce.checkout.title__subscriptionSuccessful')
}
@@ -364,7 +365,7 @@ export const CheckoutComplete = () => {
}),
})}
localizationKey={
- checkout.totals.totalDueNow.amount > 0
+ totals.totalDueNow.amount > 0
? localizationKeys('commerce.checkout.description__paymentSuccessful')
: localizationKeys('commerce.checkout.description__subscriptionSuccessful')
}
@@ -396,28 +397,26 @@ export const CheckoutComplete = () => {
-
+
0
+ totals.totalDueNow.amount > 0
? localizationKeys('commerce.checkout.lineItems.title__paymentMethod')
: localizationKeys('commerce.checkout.lineItems.title__subscriptionBegins')
}
/>
0
- ? checkout.paymentSource
- ? checkout.paymentSource.paymentMethod !== 'card'
- ? `${capitalize(checkout.paymentSource.paymentMethod)}`
- : `${capitalize(checkout.paymentSource.cardType)} ⋯ ${checkout.paymentSource.last4}`
+ totals.totalDueNow.amount > 0
+ ? paymentSource
+ ? paymentSource.paymentMethod !== 'card'
+ ? `${capitalize(paymentSource.paymentMethod)}`
+ : `${capitalize(paymentSource.cardType)} ⋯ ${paymentSource.last4}`
: '–'
- : checkout.planPeriodStart
- ? formatDate(new Date(checkout.planPeriodStart))
+ : planPeriodStart
+ ? formatDate(new Date(planPeriodStart))
: '–'
}
/>
diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
index 5e605dcb55c..31a987c7195 100644
--- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
+++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx
@@ -1,10 +1,5 @@
-import { useOrganization } from '@clerk/shared/react';
-import type {
- CommerceCheckoutResource,
- CommerceMoney,
- CommercePaymentSourceResource,
- ConfirmCheckoutParams,
-} from '@clerk/types';
+import { __experimental_useCheckout as useCheckout, useOrganization } from '@clerk/shared/react';
+import type { CommerceMoney, CommercePaymentSourceResource, ConfirmCheckoutParams } from '@clerk/types';
import { useMemo, useState } from 'react';
import { Card } from '@/ui/elements/Card';
@@ -22,21 +17,20 @@ import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Text } fro
import { ChevronUpDown, InformationCircle } from '../../icons';
import * as AddPaymentSource from '../PaymentSources/AddPaymentSource';
import { PaymentSourceRow } from '../PaymentSources/PaymentSourceRow';
-import { useCheckoutContextRoot } from './CheckoutPage';
type PaymentMethodSource = 'existing' | 'new';
const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1);
export const CheckoutForm = withCardStateProvider(() => {
- const ctx = useCheckoutContextRoot();
- const { checkout } = ctx;
+ const { checkout } = useCheckout();
- if (!checkout) {
+ const { id, plan, totals, isImmediatePlanChange, planPeriod } = checkout;
+
+ if (!id) {
return null;
}
- const { plan, planPeriod, totals, isImmediatePlanChange } = checkout;
const showCredits = !!totals.credit?.amount && totals.credit.amount > 0;
const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0;
const showDowngradeInfo = !isImmediatePlanChange;
@@ -114,35 +108,37 @@ export const CheckoutForm = withCardStateProvider(() => {
)}
-
+
);
});
const useCheckoutMutations = () => {
const { organization } = useOrganization();
- const { subscriberType } = useCheckoutContext();
- const { updateCheckout, checkout } = useCheckoutContextRoot();
+ const { subscriberType, onSubscriptionComplete } = useCheckoutContext();
+ const { checkout } = useCheckout();
+ const { id, confirm } = checkout;
const card = useCardState();
- if (!checkout) {
+ if (!id) {
throw new Error('Checkout not found');
}
const confirmCheckout = async (params: ConfirmCheckoutParams) => {
card.setLoading();
card.setError(undefined);
- try {
- const newCheckout = await checkout.confirm({
- ...params,
- ...(subscriberType === 'org' ? { orgId: organization?.id } : {}),
- });
- updateCheckout(newCheckout);
- } catch (error) {
+
+ const { data, error } = await confirm({
+ ...params,
+ ...(subscriberType === 'org' ? { orgId: organization?.id } : {}),
+ });
+
+ if (error) {
handleError(error, [], card.setError);
- } finally {
- card.setIdle();
+ } else if (data) {
+ onSubscriptionComplete?.();
}
+ card.setIdle();
};
const payWithExistingPaymentSource = (e: React.FormEvent) => {
@@ -158,22 +154,11 @@ const useCheckoutMutations = () => {
const addPaymentSourceAndPay = (ctx: { gateway: 'stripe'; paymentToken: string }) => confirmCheckout(ctx);
- const payWithTestCard = async () => {
- card.setLoading();
- card.setError(undefined);
- try {
- const newCheckout = await checkout.confirm({
- gateway: 'stripe',
- useTestCard: true,
- ...(subscriberType === 'org' ? { orgId: organization?.id } : {}),
- });
- updateCheckout(newCheckout);
- } catch (error) {
- handleError(error, [], card.setError);
- } finally {
- card.setIdle();
- }
- };
+ const payWithTestCard = () =>
+ confirmCheckout({
+ gateway: 'stripe',
+ useTestCard: true,
+ });
return {
payWithExistingPaymentSource,
@@ -182,13 +167,19 @@ const useCheckoutMutations = () => {
};
};
-const CheckoutFormElements = ({ checkout }: { checkout: CommerceCheckoutResource }) => {
+const CheckoutFormElements = () => {
+ const { checkout } = useCheckout();
+ const { id, totals } = checkout;
const { data: paymentSources } = usePaymentMethods();
const [paymentMethodSource, setPaymentMethodSource] = useState(() =>
paymentSources.length > 0 ? 'existing' : 'new',
);
+ if (!id) {
+ return null;
+ }
+
return (
({ padding: t.space.$4 })}
>
{/* only show if there are payment sources and there is a total due now */}
- {paymentSources.length > 0 && checkout.totals.totalDueNow.amount > 0 && (
+ {paymentSources.length > 0 && totals.totalDueNow.amount > 0 && (
)}
@@ -287,9 +277,10 @@ export const PayWithTestPaymentSource = () => {
const AddPaymentSourceForCheckout = withCardStateProvider(() => {
const { addPaymentSourceAndPay } = useCheckoutMutations();
- const { checkout } = useCheckoutContextRoot();
+ const { checkout } = useCheckout();
+ const { id, totals } = checkout;
- if (!checkout) {
+ if (!id) {
return null;
}
@@ -302,10 +293,10 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => {
- {checkout.totals.totalDueNow.amount > 0 ? (
+ {totals.totalDueNow.amount > 0 ? (
) : (
@@ -317,18 +308,19 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => {
const ExistingPaymentSourceForm = withCardStateProvider(
({
- checkout,
totalDueNow,
paymentSources,
}: {
- checkout: CommerceCheckoutResource;
totalDueNow: CommerceMoney;
paymentSources: CommercePaymentSourceResource[];
}) => {
+ const { checkout } = useCheckout();
+ const { paymentSource } = checkout;
+
const { payWithExistingPaymentSource } = useCheckoutMutations();
const card = useCardState();
const [selectedPaymentSource, setSelectedPaymentSource] = useState(
- checkout.paymentSource || paymentSources.find(p => p.isDefault),
+ paymentSource || paymentSources.find(p => p.isDefault),
);
const options = useMemo(() => {
@@ -354,7 +346,7 @@ const ExistingPaymentSourceForm = withCardStateProvider(
rowGap: t.space.$4,
})}
>
- {checkout.totals.totalDueNow.amount > 0 ? (
+ {totalDueNow.amount > 0 ? (