diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a446de1a2ba..d5fc38c0d51 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,9 +1,9 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "605kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "69.2KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "113KB" }, - { "path": "./dist/clerk.headless*.js", "maxSize": "53KB" }, + { "path": "./dist/clerk.js", "maxSize": "608kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "73KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" }, + { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "106.3KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 36c01bbcb9c..fd78b4e4f64 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -78,7 +78,8 @@ "dequal": "2.0.3", "qrcode.react": "4.2.0", "regenerator-runtime": "0.14.1", - "swr": "2.3.3" + "swr": "2.3.3", + "zustand": "5.0.5" }, "devDependencies": { "@emotion/jest": "^11.13.0", diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 707b52b1674..e4c34e3f416 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -1,3 +1,5 @@ +import type { SignInResource } from '@clerk/types'; + import * as l from '../../localizations'; import type { Clerk as ClerkType } from '../'; @@ -34,6 +36,7 @@ const AVAILABLE_COMPONENTS = [ 'waitlist', 'pricingTable', 'oauthConsent', + 'signInObservable', ] as const; const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox'; @@ -93,6 +96,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component waitlist: buildComponentControls('waitlist'), pricingTable: buildComponentControls('pricingTable'), oauthConsent: buildComponentControls('oauthConsent'), + signInObservable: buildComponentControls('signInObservable'), }; declare global { @@ -257,6 +261,844 @@ function otherOptions() { return { updateOtherOptions }; } +function mountSignInObservable(element: HTMLDivElement) { + assertClerkIsLoaded(Clerk); + + // Add global error handler to catch store-related errors + const originalError = console.error; + console.error = function (...args) { + if (args.some(arg => typeof arg === 'string' && arg.includes('dispatch is not a function'))) { + console.log('=== Store Dispatch Error Detected ==='); + console.log('Arguments:', args); + console.log('Current signIn:', signIn); + if (signIn?.store) { + console.log('SignIn store state:', signIn.store.getState()); + } + } + originalError.apply(console, args); + }; + + // Create main container + const mainContainer = document.createElement('div'); + mainContainer.className = 'space-y-6'; + element.appendChild(mainContainer); + + // Create title + const title = document.createElement('h2'); + title.textContent = 'SignIn Observable Store Demo'; + title.className = 'text-2xl font-bold mb-4'; + mainContainer.appendChild(title); + + // Create container for status display + const statusContainer = document.createElement('div'); + statusContainer.className = 'p-4 border border-gray-200 rounded-md mb-4'; + mainContainer.appendChild(statusContainer); + + // 1. SIGN IN FORM (First) + const form = document.createElement('form'); + form.className = 'space-y-4 p-4 border rounded-lg bg-blue-50'; + + const formTitle = document.createElement('h3'); + formTitle.textContent = '1. Test Sign In Flow'; + formTitle.className = 'font-semibold mb-2 text-blue-800'; + form.appendChild(formTitle); + + const formDescription = document.createElement('p'); + formDescription.textContent = 'Create a SignIn instance and observe store state changes in real-time'; + formDescription.className = 'text-sm text-blue-600 mb-3'; + form.appendChild(formDescription); + + const emailInput = document.createElement('input'); + emailInput.type = 'email'; + emailInput.placeholder = 'Email (must exist in your Clerk app)'; + emailInput.value = ''; + emailInput.className = 'w-full p-2 border rounded'; + + const passwordInput = document.createElement('input'); + passwordInput.type = 'password'; + passwordInput.placeholder = 'Password or Code'; + passwordInput.value = '123456'; + passwordInput.className = 'w-full p-2 border rounded'; + + const submitButton = document.createElement('button'); + submitButton.type = 'submit'; + submitButton.textContent = 'Create SignIn & Observe Store'; + submitButton.className = 'w-full p-2 bg-blue-500 text-white rounded hover:bg-blue-600'; + + form.appendChild(emailInput); + form.appendChild(passwordInput); + form.appendChild(submitButton); + mainContainer.appendChild(form); + + // 2. SIMULATE BUTTONS (Second) + const buttonsContainer = document.createElement('div'); + buttonsContainer.className = 'space-y-3 p-4 bg-gray-50 rounded-lg'; + + const buttonsTitle = document.createElement('h3'); + buttonsTitle.textContent = '2. Store State Simulation Controls'; + buttonsTitle.className = 'font-semibold mb-2 text-gray-800'; + buttonsContainer.appendChild(buttonsTitle); + + const buttonsDescription = document.createElement('p'); + buttonsDescription.textContent = 'Manually trigger store actions to observe state changes'; + buttonsDescription.className = 'text-sm text-gray-600 mb-3'; + buttonsContainer.appendChild(buttonsDescription); + + const buttonsRow = document.createElement('div'); + buttonsRow.className = 'flex flex-wrap gap-2'; + + const createTestButton = (text: string, onClick: () => void, colorClass = 'bg-gray-500 hover:bg-gray-600') => { + const button = document.createElement('button'); + button.textContent = text; + button.className = `px-3 py-1 ${colorClass} text-white rounded text-sm`; + button.onclick = onClick; + return button; + }; + + buttonsContainer.appendChild(buttonsRow); + mainContainer.appendChild(buttonsContainer); + + // 3. STORE SLICES SECTIONS (Third) + const combinedStoreDisplay = document.createElement('div'); + combinedStoreDisplay.className = 'space-y-4'; + + const storeTitle = document.createElement('h3'); + storeTitle.textContent = '3. Live Store State Inspection'; + storeTitle.className = 'font-semibold mb-2 text-purple-800'; + combinedStoreDisplay.appendChild(storeTitle); + + const storeDescription = document.createElement('p'); + storeDescription.textContent = 'Real-time view of the observable store structure and state changes'; + storeDescription.className = 'text-sm text-purple-600 mb-4'; + combinedStoreDisplay.appendChild(storeDescription); + + // Combined store display section + const combinedStoreSection = document.createElement('div'); + combinedStoreSection.className = 'p-4 bg-purple-50 rounded-lg'; + combinedStoreSection.innerHTML = + '

Complete Store State (signIn.store.getState())

'; + + const combinedStoreStateDisplay = document.createElement('div'); + combinedStoreStateDisplay.className = 'p-2 bg-white rounded text-sm font-mono'; + combinedStoreSection.appendChild(combinedStoreStateDisplay); + + // Resource slice view section + const resourceSliceSection = document.createElement('div'); + resourceSliceSection.className = 'p-4 bg-blue-50 rounded-lg'; + resourceSliceSection.innerHTML = + '

Resource Slice (Inherited from BaseResource)

'; + + const resourceStoreDisplay = document.createElement('div'); + resourceStoreDisplay.className = 'p-2 bg-white rounded text-sm font-mono'; + resourceSliceSection.appendChild(resourceStoreDisplay); + + // SignIn slice view section + const signInSliceSection = document.createElement('div'); + signInSliceSection.className = 'p-4 bg-green-50 rounded-lg'; + signInSliceSection.innerHTML = + '

SignIn Slice (Domain-Specific Logic)

'; + + const signInStoreDisplay = document.createElement('div'); + signInStoreDisplay.className = 'p-2 bg-white rounded text-sm font-mono'; + signInSliceSection.appendChild(signInStoreDisplay); + + // Add store state displays to container + combinedStoreDisplay.appendChild(combinedStoreSection); + combinedStoreDisplay.appendChild(resourceSliceSection); + combinedStoreDisplay.appendChild(signInSliceSection); + mainContainer.appendChild(combinedStoreDisplay); + + // 4. ARCHITECTURE DESCRIPTION (Fourth) + const architectureSection = document.createElement('div'); + architectureSection.className = 'p-6 bg-gradient-to-r from-yellow-50 to-orange-50 border border-yellow-200 rounded-lg mt-6'; + architectureSection.innerHTML = ` +

4. Observable Store Architecture & Inheritance

+ +
+
+

🔍 Why Observable Stores?

+ +
+ +
+

🏗️ Inheritance Architecture

+
+
BaseResource (Abstract)
+
├── Provides: store property (Zustand store)
+
├── Resource Slice: { status, data, error, dispatch, ... }
+
└── Generic resource lifecycle management
+
↓ extends
+
SignInResource (Concrete)
+
├── Inherits: store from BaseResource
+
├── Adds: SignIn Slice: { status, setStatus, ... }
+
└── Domain-specific SignIn business logic
+
+
+ +
+

🎯 Store Composition Pattern

+
+
+

Resource Slice: Generic, inherited from BaseResource

+

store.getState().resource.{ status, data, error, dispatch }

+

Handles: API calls, loading states, error handling

+
+ +
+

SignIn Slice: Domain-specific, added by SignInResource

+

store.getState().signin.{ status, setStatus }

+

Handles: SignIn flow logic, authentication steps

+
+
+
+ +
+

⚡ Key Benefits of This Observable Pattern

+
+
+

🌐 Framework-Agnostic Reactive Integration

+

The .store property is a vanilla JS Zustand store that any framework can integrate with:

+
    +
  • React: const status = useStore(signIn.store, (state) => state.resource.status)
  • +
  • Vue: const status = computed(() => signIn.store.getState().resource.status)
  • +
  • Svelte: $: status = $signInStore.resource.status
  • +
  • Angular: signIn.store.subscribe(state => this.status = state.resource.status)
  • +
  • Vanilla JS: Direct subscription and state access
  • +
+
+ +
+

🔄 Non-Breaking Progressive Enhancement

+

New .store property enables gradual adoption without version coupling:

+
    +
  • • Framework SDKs can detect: if (signIn.store) { /* use reactive features */ }
  • +
  • • No breaking changes to existing APIs or workflows
  • +
  • • Framework SDKs work with any clerk-js version (old or new)
  • +
  • • Progressive enhancement: better experience when available, fallback when not
  • +
  • • Independent release cycles for framework integrations
  • +
+
+ +
+

🔍 Internal Architecture Observability

+

Observable store powers resource internals regardless of UI usage:

+
    +
  • Debug Visibility: Inspect resource state changes in real-time
  • +
  • Internal Consistency: All resource mutations flow through observable store
  • +
  • Development Tools: Store state can be logged, tracked, and debugged
  • +
  • Testing Benefits: Mock and assert on store state for better test coverage
  • +
  • Performance Monitoring: Track resource lifecycle and performance bottlenecks
  • +
+
+
+
+
+ `; + mainContainer.appendChild(architectureSection); + + let signIn: SignInResource & { signInStore?: any }; + let storeUnsubscribe: (() => void) | null = null; + + // Create updateStatus function to show both stores + const updateStatus = () => { + if (!signIn) { + statusContainer.innerHTML = ` +
+ Status: SignIn not initialized +
+ `; + combinedStoreStateDisplay.innerHTML = '
No store data
'; + resourceStoreDisplay.innerHTML = '
No resource slice data
'; + signInStoreDisplay.innerHTML = '
No SignIn slice data
'; + return; + } + + // Get basic SignIn properties + const status = signIn.status; + const identifier = signIn.identifier; + const error = signIn.signInError?.global; + + // Additional debugging info + const firstFactorStrategies = signIn.supportedFirstFactors?.map(f => f.strategy).join(', ') || 'none'; + + // Update status container + statusContainer.innerHTML = ` +
+
+ SignIn Status: + ${status || 'null'} +
+
+ Identifier: + ${identifier || 'none'} +
+
+ Available Factors: + ${firstFactorStrategies} +
+ ${error ? `
Error: ${error}
` : ''} +
+ `; + + // Get the full combined store state + const fullStoreState = signIn.store?.getState?.() || {}; + + // Display the complete combined store + combinedStoreStateDisplay.innerHTML = ` +
+
Complete Store Object:
+
${JSON.stringify(
+          Object.fromEntries(
+            Object.entries(fullStoreState).map(([key, value]) => [
+              key,
+              typeof value === 'function' ? `[Function: ${key}]` : value,
+            ]),
+          ),
+          null,
+          2,
+        )}
+
+ Access: signIn.store.getState() - Single consistent store API +
+
+ `; + + // Display Resource Slice properties only + try { + const resourceSliceProps = { + // Core resource state (namespaced under 'resource') + status: fullStoreState.resource?.status, + data: fullStoreState.resource?.data, + error: fullStoreState.resource?.error, + + // Resource slice methods + dispatch: typeof fullStoreState.resource?.dispatch, + getData: typeof fullStoreState.resource?.getData, + getError: typeof fullStoreState.resource?.getError, + hasError: typeof fullStoreState.resource?.hasError, + getStatus: typeof fullStoreState.resource?.getStatus, + }; + + resourceStoreDisplay.innerHTML = ` +
+
Resource Slice Properties (under 'resource' namespace):
+
${JSON.stringify(resourceSliceProps, null, 2)}
+
+ `; + } catch { + resourceStoreDisplay.innerHTML = '
Error accessing resource slice
'; + } + + // Display SignIn Slice properties only + try { + const signInSliceProps = { + // SignIn slice properties (namespaced under 'signin') + status: fullStoreState.signin?.status, + setStatus: typeof fullStoreState.signin?.setStatus, + + // Show current value + currentStatus: fullStoreState.signin?.status, + }; + + signInStoreDisplay.innerHTML = ` +
+
SignIn Slice Properties (under 'signin' namespace):
+
${JSON.stringify(signInSliceProps, null, 2)}
+
+ `; + } catch { + signInStoreDisplay.innerHTML = '
Error accessing SignIn slice
'; + } + }; + + // Create test buttons + const resetButton = createTestButton('Reset SignIn', () => { + if ((signIn as any)?.store) { + (signIn as any).store.getState().resource.dispatch({ type: 'RESET' }); + updateStatus(); + } + }, 'bg-red-500 hover:bg-red-600'); + + const loadingButton = createTestButton('Simulate Loading', () => { + if ((signIn as any)?.store) { + (signIn as any).store.getState().resource.dispatch({ type: 'FETCH_START' }); + updateStatus(); + } + }, 'bg-blue-500 hover:bg-blue-600'); + + const errorButton = createTestButton('Simulate Error', () => { + if ((signIn as any)?.store) { + (signIn as any).store.getState().resource.dispatch({ + type: 'FETCH_ERROR', + error: { message: 'Test error', meta: {}, errors: [] }, + }); + updateStatus(); + } + }, 'bg-red-500 hover:bg-red-600'); + + const signInStatusButton = createTestButton('Set SignIn Status', () => { + if (signIn?.store) { + const statuses = ['needs_first_factor', 'needs_second_factor', 'complete']; + const randomStatus = statuses[Math.floor(Math.random() * statuses.length)]; + signIn.store.getState().signin.setStatus(randomStatus as any); + updateStatus(); + } + }, 'bg-green-500 hover:bg-green-600'); + + const attemptPasswordButton = createTestButton('Attempt Password', async () => { + if (signIn && passwordInput.value) { + try { + statusContainer.innerHTML = ` +
+ Status: Attempting password authentication... +
+ `; + await signIn.attemptFirstFactor({ + strategy: 'password', + password: passwordInput.value, + }); + updateStatus(); + } catch (error) { + console.log('Password attempt completed (may have expected errors):', error); + updateStatus(); + } + } + }, 'bg-purple-500 hover:bg-purple-600'); + + const forceRefreshButton = createTestButton('Force Refresh', () => { + console.log('=== Force Refresh Debug ==='); + console.log('Current signIn:', signIn); + console.log('signIn.status:', signIn?.status); + console.log('signIn.identifier:', signIn?.identifier); + console.log('signIn.supportedFirstFactors:', signIn?.supportedFirstFactors); + console.log('signIn.store:', signIn?.store); + if (signIn?.store) { + console.log('Store state:', signIn.store.getState()); + } + updateStatus(); + }, 'bg-indigo-500 hover:bg-indigo-600'); + + const resetDemoButton = createTestButton('Reset Demo', () => { + console.log('=== Resetting Demo State ==='); + + // Unsubscribe from old store + if (storeUnsubscribe) { + storeUnsubscribe(); + storeUnsubscribe = null; + } + + // Reset to the current client SignIn state + if (Clerk.client) { + signIn = Clerk.client.signIn as SignInResource & { signInStore?: any }; + + // Subscribe to the current client SignIn store + if (signIn?.store) { + storeUnsubscribe = signIn.store.subscribe(() => { + updateStatus(); + }); + } + + console.log('Reset to client.signIn:', signIn); + console.log('New status:', signIn?.status); + console.log('New identifier:', signIn?.identifier); + + statusContainer.innerHTML = ` +
+ Status: Demo reset to current client state +
+ `; + + setTimeout(updateStatus, 100); + } + }, 'bg-yellow-500 hover:bg-yellow-600'); + + const clearSignInButton = createTestButton('Clear SignIn', async () => { + console.log('=== Clearing SignIn Attempt ==='); + + try { + // Unsubscribe from old store + if (storeUnsubscribe) { + storeUnsubscribe(); + storeUnsubscribe = null; + } + + statusContainer.innerHTML = ` +
+ Status: Clearing SignIn attempt... +
+ `; + + // Create a completely fresh SignIn attempt + if (Clerk.client) { + // First, try to abandon the current SignIn if it exists + if (Clerk.client.signIn && typeof (Clerk.client.signIn as any).abandon === 'function') { + try { + await (Clerk.client.signIn as any).abandon(); + console.log('Abandoned existing SignIn attempt'); + } catch (abandonError) { + console.log('Could not abandon SignIn (might not be needed):', abandonError); + } + } + + // Get the fresh SignIn instance + signIn = Clerk.client.signIn as SignInResource & { signInStore?: any }; + + // Subscribe to the fresh store + if (signIn?.store) { + storeUnsubscribe = signIn.store.subscribe(() => { + updateStatus(); + }); + } + + console.log('Fresh SignIn:', signIn); + console.log('Fresh status:', signIn?.status); + console.log('Fresh identifier:', signIn?.identifier); + + statusContainer.innerHTML = ` +
+ Status: SignIn attempt cleared - Fresh state +
+ `; + + setTimeout(updateStatus, 100); + } + } catch (error) { + console.error('Error clearing SignIn:', error); + statusContainer.innerHTML = ` +
+ Error: Could not clear SignIn attempt +
+ `; + } + }, 'bg-orange-500 hover:bg-orange-600'); + + const inspectStoreButton = createTestButton('Inspect Store', () => { + if (signIn?.store) { + const storeState = signIn.store.getState(); + console.log('=== Observable Store Analysis (Inheritance Pattern) ==='); + console.log('Combined Store State:', storeState); + console.log(''); + + console.log('=== Resource Slice (Inherited from BaseResource) ==='); + console.log('Purpose: Generic resource fetch lifecycle management'); + console.log('Structure: Complex state object with atomic updates'); + console.log('Access: signIn.store.getState().resource.*'); + console.log('Resource slice:', storeState.resource); + if (storeState.resource) { + Object.keys(storeState.resource).forEach(key => { + console.log(`resource.${key}:`, storeState.resource[key]); + }); + } + console.log(''); + + console.log('=== SignIn Slice (Domain-Specific) ==='); + console.log('Purpose: Domain-specific SignIn business logic'); + console.log('Structure: Simple primitive values with direct updates'); + console.log('Access: signIn.store.getState().signin.*'); + console.log('SignIn slice:', storeState.signin); + if (storeState.signin) { + Object.keys(storeState.signin).forEach(key => { + console.log(`signin.${key}:`, storeState.signin[key]); + }); + } + console.log(''); + + console.log('=== Observable Store Benefits (Inheritance) ==='); + console.log('🔷 BaseResource provides:'); + console.log(' - Observable store foundation'); + console.log(' - Generic resource lifecycle'); + console.log(' - Consistent API across resources'); + console.log(''); + console.log('🔶 SignInResource extends:'); + console.log(' - Inherits observable capabilities'); + console.log(' - Adds domain-specific logic'); + console.log(' - Maintains reactive state'); + console.log(''); + + console.log('=== Consistent Observable API ==='); + console.log('✅ All resources inherit from BaseResource'); + console.log('✅ All have .store property for observability'); + console.log('✅ Consistent slice structure across domains'); + console.log('✅ Reactive updates for all UI components'); + + // Update the display to show console inspection notice + statusContainer.innerHTML = ` +
+ Status: Observable store analysis logged to console (F12 → Console tab) +
+ `; + } + }, 'bg-teal-500 hover:bg-teal-600'); + + buttonsRow.appendChild(resetButton); + buttonsRow.appendChild(loadingButton); + buttonsRow.appendChild(errorButton); + buttonsRow.appendChild(signInStatusButton); + buttonsRow.appendChild(attemptPasswordButton); + buttonsRow.appendChild(forceRefreshButton); + buttonsRow.appendChild(resetDemoButton); + buttonsRow.appendChild(clearSignInButton); + buttonsRow.appendChild(inspectStoreButton); + + // Initialize SignIn instance + const initializeSignIn = async () => { + try { + // Show loading state + statusContainer.innerHTML = ` +
+ Status: Initializing SignIn... +
+ `; + + // Wait for Clerk to be loaded and client to be ready + const waitForClerk = async () => { + if (!Clerk.loaded) { + await new Promise(resolve => { + const checkLoaded = () => { + if (Clerk.loaded) { + resolve(); + } else { + setTimeout(checkLoaded, 100); + } + }; + checkLoaded(); + }); + } + + // Wait for client to be ready + if (!Clerk.client) { + await new Promise(resolve => { + const checkClient = () => { + if (Clerk.client) { + resolve(); + } else { + setTimeout(checkClient, 100); + } + }; + checkClient(); + }); + } + }; + + await waitForClerk(); + + // Create a basic SignIn instance to demonstrate store functionality + if (Clerk.client) { + signIn = Clerk.client.signIn as SignInResource & { signInStore?: any }; + + // Set up store subscription for automatic updates + if (signIn?.store) { + signIn.store.subscribe(updateStatus); + } + + // Initial update + updateStatus(); + + // Update status to show initialization complete - explain the initial state + statusContainer.innerHTML = ` +
+ Status: SignIn initialized - Ready for demo
+ Note: Initial state shows "needs_identifier" until you submit the form to create a SignIn with an identifier. +
+ `; + + // Update displays after a brief delay to show the initialized state + setTimeout(updateStatus, 200); + } + } catch (error) { + console.error('Failed to initialize:', error); + statusContainer.innerHTML = ` +
+ Error: ${error instanceof Error ? error.message : 'Failed to initialize'} +
+ `; + } + }; + + // Handle form submission to test actual sign-in flow + form.addEventListener('submit', async e => { + e.preventDefault(); + + try { + if (!Clerk.client) { + throw new Error('Clerk client not ready'); + } + + // Update status to show we're creating SignIn + statusContainer.innerHTML = ` +
+ Status: Creating SignIn with identifier: ${emailInput.value} +
+ `; + + console.log('Creating SignIn with identifier:', emailInput.value); + console.log('Current Clerk.client:', Clerk.client); + console.log('Current signIn before create:', signIn); + + // Create new SignIn instance + const newSignIn = await Clerk.client.signIn.create({ + identifier: emailInput.value, + }); + + console.log('New SignIn created:', newSignIn); + console.log('New SignIn status:', newSignIn.status); + console.log('New SignIn identifier:', newSignIn.identifier); + console.log('New SignIn store:', newSignIn.store); + + // Debug store structure in detail + if (newSignIn.store) { + const storeState = newSignIn.store.getState(); + console.log('=== Store Structure Debug ==='); + console.log('Store state:', storeState); + console.log('Store state keys:', Object.keys(storeState)); + + if (storeState.resource) { + console.log('Resource slice:', storeState.resource); + console.log('Resource dispatch available:', typeof storeState.resource.dispatch); + } else { + console.error('❌ Resource slice missing from store!'); + } + + if (storeState.signin) { + console.log('SignIn slice:', storeState.signin); + } else { + console.error('❌ SignIn slice missing from store!'); + } + + // Check if verification has its own store reference + if (newSignIn.firstFactorVerification) { + console.log('First factor verification:', newSignIn.firstFactorVerification); + console.log('Verification _store:', (newSignIn.firstFactorVerification as any)._store); + if ((newSignIn.firstFactorVerification as any)._store) { + const verificationStore = (newSignIn.firstFactorVerification as any)._store; + console.log('Verification store state:', verificationStore.getState?.()); + } + } + } + + signIn = newSignIn as SignInResource & { signInStore?: any }; + + // Subscribe to the new store - use consistent .store property + if (storeUnsubscribe) { + storeUnsubscribe(); + } + if (signIn.store?.subscribe) { + storeUnsubscribe = signIn.store.subscribe(() => { + console.log('Store updated, calling updateStatus'); + updateStatus(); + }); + console.log('Subscribed to store updates'); + } else { + console.warn('No store available on SignIn instance'); + } + + // Force an immediate update + console.log('Calling updateStatus immediately'); + updateStatus(); + + // Log current state after update + setTimeout(() => { + console.log('Final state check:'); + console.log('signIn.status:', signIn?.status); + console.log('signIn.identifier:', signIn?.identifier); + console.log('signIn.supportedFirstFactors:', signIn?.supportedFirstFactors); + updateStatus(); + }, 100); + } catch (error) { + console.error('Sign in error details:', error); + console.error('Error type:', typeof error); + if (error && typeof error === 'object' && 'constructor' in error) { + console.error('Error constructor:', (error as any).constructor?.name); + } + + // Handle specific Clerk errors + let errorMessage = 'Failed to create SignIn'; + if (error instanceof Error) { + if (error.message.includes("Couldn't find your account")) { + errorMessage = `Account not found: ${emailInput.value} doesn't exist in this Clerk application`; + } else { + errorMessage = error.message; + } + } + + statusContainer.innerHTML = ` +
+
SignIn Creation Failed:
+
${errorMessage}
+
+ ${ + error instanceof Error && error.message.includes("Couldn't find your account") + ? 'Try using an email that exists in your Clerk application, or enable sign-up to create new accounts.' + : 'Check browser console for detailed error information' + } +
+
+ `; + + // Don't call updateStatus since signIn wasn't updated + console.log('SignIn creation failed, keeping original signIn instance'); + } + }); + + // Cleanup function + const cleanup = () => { + if (storeUnsubscribe) { + storeUnsubscribe(); + } + }; + + // Store cleanup function on the element for potential future use + (element as any)._cleanup = cleanup; + + // Initialize on mount + void initializeSignIn(); + + // Debug initial Clerk state when everything loads + setTimeout(() => { + console.log('=== Initial Clerk State Debug ==='); + console.log('Clerk loaded:', Clerk.loaded); + console.log('Clerk client:', Clerk.client); + if (Clerk.client?.signIn) { + console.log('Initial client.signIn:', Clerk.client.signIn); + console.log('Initial signIn.store:', Clerk.client.signIn.store); + if (Clerk.client.signIn.store) { + const initialState = Clerk.client.signIn.store.getState(); + console.log('Initial store state:', initialState); + console.log('Initial store keys:', Object.keys(initialState)); + + // Check resource slice structure + if (initialState.resource) { + console.log('Initial resource slice:', initialState.resource); + console.log('Resource dispatch type:', typeof initialState.resource.dispatch); + console.log( + 'Resource methods:', + Object.keys(initialState.resource).filter(key => typeof initialState.resource[key] === 'function'), + ); + } + + // Check signin slice structure + if (initialState.signin) { + console.log('Initial signin slice:', initialState.signin); + } + } + } + }, 1000); +} + void (async () => { assertClerkIsLoaded(Clerk); fillLocalizationSelect(); @@ -333,6 +1175,22 @@ void (async () => { '/open-sign-up': () => { mountOpenSignUpButton(app, componentControls.signUp.getProps() ?? {}); }, + '/sign-in-observable': async () => { + // Wait for Clerk to be fully loaded before mounting the component + if (!Clerk.loaded) { + await new Promise(resolve => { + const checkLoaded = () => { + if (Clerk.loaded) { + resolve(); + } else { + setTimeout(checkLoaded, 100); + } + }; + checkLoaded(); + }); + } + mountSignInObservable(app); + }, }; const route = window.location.pathname as keyof typeof routes; @@ -344,7 +1202,7 @@ void (async () => { signInUrl: '/sign-in', signUpUrl: '/sign-up', }); - renderCurrentRoute(); + await renderCurrentRoute(); updateVariables(); updateOtherOptions(); } else { diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index b9b0b3b5b98..4eb18cc394b 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -188,6 +188,13 @@ >Sign In +
  • + Sign In Observable +
  • >; + + constructor() { + this._store = createResourceStore(); + } + + public get store() { + return this._store; + } + static get fapiClient(): FapiClient { return BaseResource.clerk.getFapiClient(); } @@ -187,22 +202,45 @@ export abstract class BaseResource { } protected async _baseGet(opts: BaseFetchOptions = {}): Promise { - const json = await BaseResource._fetch( - { - method: 'GET', - path: this.path(), - rotatingTokenNonce: opts.rotatingTokenNonce, - }, - opts, - ); + this.store.getState().resource.dispatch({ type: 'FETCH_START' }); - return this.fromJSON((json?.response || json) as J); + try { + const { forceUpdateClient, fetchMaxTries, ...fetchOpts } = opts; + const json = await BaseResource._fetch({ + method: 'GET', + path: this.path(opts.action), + ...fetchOpts, + }); + + const data = this.fromJSON((json?.response || json) as J); + this.store.getState().resource.dispatch({ type: 'FETCH_SUCCESS', data }); + return data; + } catch (error) { + this.store.getState().resource.dispatch({ + type: 'FETCH_ERROR', + error: error as ClerkAPIErrorJSON, + }); + throw error; + } } protected async _baseMutate(params: BaseMutateParams): Promise { - const { action, body, method, path } = params; - const json = await BaseResource._fetch({ method, path: path || this.path(action), body }); - return this.fromJSON((json?.response || json) as J); + this.store.getState().resource.dispatch({ type: 'FETCH_START' }); + + try { + const { action, body, method, path } = params; + const json = await BaseResource._fetch({ method, path: path || this.path(action), body }); + + const data = this.fromJSON((json?.response || json) as J); + this.store.getState().resource.dispatch({ type: 'FETCH_SUCCESS', data }); + return data; + } catch (error) { + this.store.getState().resource.dispatch({ + type: 'FETCH_ERROR', + error: error as ClerkAPIErrorJSON, + }); + throw error; + } } protected async _baseMutateBypass(params: BaseMutateParams): Promise { @@ -235,4 +273,8 @@ export abstract class BaseResource { const experimental = BaseResource.clerk?.__internal_getOption?.('experimental'); return experimental?.rethrowOfflineNetworkErrors || false; } + + public reset(): void { + this.store.getState().resource.dispatch({ type: 'RESET' }); + } } diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 3bab7e51413..dbf063b03ca 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -17,7 +17,6 @@ import type { EmailLinkConfig, EnterpriseSSOConfig, PassKeyConfig, - PasskeyFactor, PhoneCodeConfig, PrepareFirstFactorParams, PrepareSecondFactorParams, @@ -39,6 +38,8 @@ import type { Web3SignatureConfig, Web3SignatureFactor, } from '@clerk/types'; +import { devtools } from 'zustand/middleware'; +import { createStore } from 'zustand/vanilla'; import { generateSignatureWithCoinbaseWallet, @@ -67,31 +68,116 @@ import { clerkVerifyWeb3WalletCalledBeforeCreate, } from '../errors'; import { BaseResource, UserData, Verification } from './internal'; +import { createResourceSlice, type ResourceStore } from './state'; + +type SignInSliceState = { + signin: { + status: SignInStatus | null; + setStatus: (status: SignInStatus | null) => void; + error: { global: string | null; fields: Record }; + setError: (error: { global: string | null; fields: Record }) => void; + }; +}; + +/** + * Creates a SignIn slice following the Zustand slices pattern. + * This slice handles SignIn-specific state management. + * All SignIn state is namespaced under the 'signin' key. + */ +const createSignInSlice = (set: any, _get: any): SignInSliceState => ({ + signin: { + status: null, + setStatus: (status: SignInStatus | null) => { + set((state: any) => ({ + ...state, + signin: { + ...state.signin, + status: status, + }, + })); + }, + error: { global: null, fields: {} }, + setError: (error: { global: string | null; fields: Record }) => { + set((state: any) => ({ + ...state, + signin: { + ...state.signin, + error: error, + }, + })); + }, + }, +}); + +type CombinedSignInStore = ResourceStore & SignInSliceState; export class SignIn extends BaseResource implements SignInResource { pathRoot = '/client/sign_ins'; + createdSessionId: string | null = null; + firstFactorVerification: VerificationResource = new Verification(null); id?: string; - status: SignInStatus | null = null; - supportedIdentifiers: SignInIdentifier[] = []; + identifier: string | null = null; + secondFactorVerification: VerificationResource = new Verification(null); supportedFirstFactors: SignInFirstFactor[] | null = []; + supportedIdentifiers: SignInIdentifier[] = []; supportedSecondFactors: SignInSecondFactor[] | null = null; - firstFactorVerification: VerificationResource = new Verification(null); - secondFactorVerification: VerificationResource = new Verification(null); - identifier: string | null = null; - createdSessionId: string | null = null; userData: UserData = new UserData(null); constructor(data: SignInJSON | SignInJSONSnapshot | null = null) { super(); + // Override the base _store with our combined store using slices pattern with namespacing + this._store = createStore()( + devtools( + (set, get) => ({ + ...createResourceSlice(set, get), + ...createSignInSlice(set, get), + }), + { name: 'SignInStore' }, + ), + ) as any; this.fromJSON(data); } - create = (params: SignInCreateParams): Promise => { - return this._basePost({ - path: this.pathRoot, - body: params, - }); + /** + * Reactive status property backed by the store. + * Reading and writing goes directly to/from the store. + */ + get status(): SignInStatus | null { + return (this._store.getState() as unknown as CombinedSignInStore).signin.status; + } + + set status(newStatus: SignInStatus | null) { + (this._store.getState() as unknown as CombinedSignInStore).signin.setStatus(newStatus); + } + + /** + * Reactive signInError property backed by the store. + * Reading and writing goes directly to/from the store. + */ + get signInError(): { global: string | null; fields: Record } { + return (this._store.getState() as unknown as CombinedSignInStore).signin.error; + } + + set signInError(newError: { global: string | null; fields: Record }) { + (this._store.getState() as unknown as CombinedSignInStore).signin.setError(newError); + } + + private updateError(globalError: string | null, fieldErrors: Record = {}) { + this.signInError = { global: globalError, fields: fieldErrors }; + } + + create = async (params: SignInCreateParams): Promise => { + try { + const result = await this._basePost({ + path: this.pathRoot, + body: params, + }); + return result; + } catch (error) { + this.updateError(error instanceof Error ? error.message : 'An unexpected error occurred'); + throw error; + } }; resetPassword = (params: ResetPasswordParams): Promise => { @@ -160,22 +246,31 @@ export class SignIn extends BaseResource implements SignInResource { }); }; - attemptFirstFactor = (attemptFactor: AttemptFirstFactorParams): Promise => { - let config; - switch (attemptFactor.strategy) { - case 'passkey': - config = { - publicKeyCredential: JSON.stringify(serializePublicKeyCredentialAssertion(attemptFactor.publicKeyCredential)), - }; - break; - default: - config = { ...attemptFactor }; - } + attemptFirstFactor = async (attemptFactor: AttemptFirstFactorParams): Promise => { + try { + let config; + switch (attemptFactor.strategy) { + case 'passkey': + config = { + publicKeyCredential: JSON.stringify( + serializePublicKeyCredentialAssertion(attemptFactor.publicKeyCredential), + ), + }; + break; + default: + config = { ...attemptFactor }; + } - return this._basePost({ - body: { ...config, strategy: attemptFactor.strategy }, - action: 'attempt_first_factor', - }); + const result = await this._basePost({ + body: { ...config, strategy: attemptFactor.strategy }, + action: 'attempt_first_factor', + }); + + return result; + } catch (error) { + this.updateError(error instanceof Error ? error.message : 'An unexpected error occurred'); + throw error; + } }; createEmailLinkFlow = (): CreateEmailLinkFlowReturn => { @@ -311,7 +406,7 @@ export class SignIn extends BaseResource implements SignInResource { // // error code 4001 means the user rejected the request // Reference: https://docs.cdp.coinbase.com/wallet-sdk/docs/errors - if (provider === 'coinbase_wallet' && err.code === 4001) { + if (provider === 'coinbase_wallet' && err instanceof Error && 'code' in err && err.code === 4001) { signature = await generateSignature({ identifier, nonce: message, provider }); } else { throw err; @@ -386,19 +481,13 @@ export class SignIn extends BaseResource implements SignInResource { } if (flow === 'autofill' || flow === 'discoverable') { - // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level await this.create({ strategy: 'passkey' }); } else { - // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level - const passKeyFactor = this.supportedFirstFactors.find( - // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level - f => f.strategy === 'passkey', - ) as PasskeyFactor; + const passKeyFactor = this.supportedFirstFactors?.find(f => f.strategy === 'passkey'); if (!passKeyFactor) { clerkVerifyPasskeyCalledBeforeCreate(); } - // @ts-ignore As this is experimental we want to support it at runtime, but not at the type level await this.prepareFirstFactor(passKeyFactor); } @@ -445,18 +534,19 @@ export class SignIn extends BaseResource implements SignInResource { }; protected fromJSON(data: SignInJSON | SignInJSONSnapshot | null): this { - if (data) { - this.id = data.id; - this.status = data.status; - this.supportedIdentifiers = data.supported_identifiers; - this.identifier = data.identifier; - this.supportedFirstFactors = deepSnakeToCamel(data.supported_first_factors) as SignInFirstFactor[] | null; - this.supportedSecondFactors = deepSnakeToCamel(data.supported_second_factors) as SignInSecondFactor[] | null; - this.firstFactorVerification = new Verification(data.first_factor_verification); - this.secondFactorVerification = new Verification(data.second_factor_verification); - this.createdSessionId = data.created_session_id; - this.userData = new UserData(data.user_data); - } + if (!data) return this; + + this.createdSessionId = data.created_session_id; + this.firstFactorVerification = new Verification(data.first_factor_verification); + this.id = data.id; + this.identifier = data.identifier; + this.secondFactorVerification = new Verification(data.second_factor_verification); + this.status = data.status; + this.supportedFirstFactors = deepSnakeToCamel(data.supported_first_factors) as SignInFirstFactor[] | null; + this.supportedIdentifiers = data.supported_identifiers; + this.supportedSecondFactors = deepSnakeToCamel(data.supported_second_factors) as SignInSecondFactor[] | null; + this.userData = new UserData(data.user_data); + return this; } diff --git a/packages/clerk-js/src/core/resources/Verification.ts b/packages/clerk-js/src/core/resources/Verification.ts index 9de4244d3f9..679d21d0856 100644 --- a/packages/clerk-js/src/core/resources/Verification.ts +++ b/packages/clerk-js/src/core/resources/Verification.ts @@ -1,6 +1,6 @@ import { errorToJSON, parseError } from '@clerk/shared/error'; import type { - ClerkAPIError, + ClerkAPIErrorJSON, PasskeyVerificationResource, PhoneCodeChannel, PublicKeyCredentialCreationOptionsJSON, @@ -16,51 +16,129 @@ import type { VerificationResource, VerificationStatus, } from '@clerk/types'; +import { devtools } from 'zustand/middleware'; +import { createStore } from 'zustand/vanilla'; import { unixEpochToDate } from '../../utils/date'; import { convertJSONToPublicKeyCreateOptions } from '../../utils/passkeys'; import { BaseResource } from './internal'; +import { createResourceSlice, type ResourceStore } from './state'; + +/** + * Verification slice state type + */ +type VerificationSliceState = { + verification: { + error: ClerkAPIErrorJSON | null; + setError: (error: ClerkAPIErrorJSON | null) => void; + clearError: () => void; + hasError: () => boolean; + }; +}; + +/** + * Creates a verification slice following the Zustand slices pattern. + * This slice handles verification-specific state management. + * All verification state is namespaced under the 'verification' key. + */ +const createVerificationSlice = (set: any, get: any): VerificationSliceState => ({ + verification: { + error: null, + setError: (error: ClerkAPIErrorJSON | null) => { + set((state: any) => ({ + ...state, + verification: { + ...state.verification, + error: error, + }, + })); + }, + clearError: () => { + set((state: any) => ({ + ...state, + verification: { + ...state.verification, + error: null, + }, + })); + }, + hasError: () => { + const state = get(); + return state.verification.error !== null; + }, + }, +}); + +type CombinedVerificationStore = ResourceStore & VerificationSliceState; export class Verification extends BaseResource implements VerificationResource { pathRoot = ''; - status: VerificationStatus | null = null; - strategy: string | null = null; - nonce: string | null = null; - message: string | null = null; - externalVerificationRedirectURL: URL | null = null; attempts: number | null = null; + channel?: PhoneCodeChannel; expireAt: Date | null = null; - error: ClerkAPIError | null = null; + externalVerificationRedirectURL: URL | null = null; + message: string | null = null; + nonce: string | null = null; + status: VerificationStatus | null = null; + strategy: string | null = null; verifiedAtClient: string | null = null; - channel?: PhoneCodeChannel; constructor(data: VerificationJSON | VerificationJSONSnapshot | null) { super(); + // Override the base _store with our combined store using slices pattern with namespacing + this._store = createStore()( + devtools( + (set, get) => ({ + ...createResourceSlice(set, get), + ...createVerificationSlice(set, get), + }), + { name: 'VerificationStore' }, + ), + ) as any; this.fromJSON(data); } + /** + * Reactive error property backed by the store. + * Reading goes directly from the store. + */ + get error(): ClerkAPIErrorJSON | null { + return (this._store.getState() as CombinedVerificationStore).verification.error; + } + verifiedFromTheSameClient = (): boolean => { return this.verifiedAtClient === BaseResource.clerk?.client?.id; }; protected fromJSON(data: VerificationJSON | VerificationJSONSnapshot | null): this { - if (data) { - this.status = data.status; - this.verifiedAtClient = data.verified_at_client; - this.strategy = data.strategy; - this.nonce = data.nonce || null; - this.message = data.message || null; - if (data.external_verification_redirect_url) { - this.externalVerificationRedirectURL = new URL(data.external_verification_redirect_url); - } else { - this.externalVerificationRedirectURL = null; - } - this.attempts = data.attempts; - this.expireAt = unixEpochToDate(data.expire_at || undefined); - this.error = data.error ? parseError(data.error) : null; - this.channel = data.channel || undefined; + if (!data) { + return this; + } + + this.status = data.status; + this.verifiedAtClient = data.verified_at_client; + this.strategy = data.strategy; + this.nonce = data.nonce || null; + this.message = data.message || null; + if (data.external_verification_redirect_url) { + this.externalVerificationRedirectURL = new URL(data.external_verification_redirect_url); + } else { + this.externalVerificationRedirectURL = null; } + this.attempts = data.attempts; + this.expireAt = unixEpochToDate(data.expire_at || undefined); + + // Set error state directly in the verification slice + if (data.error) { + const parsedError = errorToJSON(parseError(data.error)); + (this._store.getState() as CombinedVerificationStore).verification.setError(parsedError); + } else { + (this._store.getState() as CombinedVerificationStore).verification.clearError(); + } + + this.channel = data.channel || undefined; + return this; } @@ -75,7 +153,7 @@ export class Verification extends BaseResource implements VerificationResource { external_verification_redirect_url: this.externalVerificationRedirectURL?.toString() || null, attempts: this.attempts, expire_at: this.expireAt?.getTime() || null, - error: errorToJSON(this.error), + error: this.error || { code: '', message: '' }, verified_at_client: this.verifiedAtClient, }; } @@ -86,7 +164,6 @@ export class PasskeyVerification extends Verification implements PasskeyVerifica constructor(data: VerificationJSON | VerificationJSONSnapshot | null) { super(data); - this.fromJSON(data); } /** @@ -94,11 +171,14 @@ export class PasskeyVerification extends Verification implements PasskeyVerifica */ protected fromJSON(data: VerificationJSON | VerificationJSONSnapshot | null): this { super.fromJSON(data); - if (data?.nonce) { + if (!data?.nonce) { + this.publicKey = null; + } else { this.publicKey = convertJSONToPublicKeyCreateOptions( JSON.parse(data.nonce) as PublicKeyCredentialCreationOptionsJSON, ); } + return this; } } @@ -110,17 +190,10 @@ export class SignUpVerifications implements SignUpVerificationsResource { externalAccount: VerificationResource; constructor(data: SignUpVerificationsJSON | SignUpVerificationsJSONSnapshot | null) { - if (data) { - this.emailAddress = new SignUpVerification(data.email_address); - this.phoneNumber = new SignUpVerification(data.phone_number); - this.web3Wallet = new SignUpVerification(data.web3_wallet); - this.externalAccount = new Verification(data.external_account); - } else { - this.emailAddress = new SignUpVerification(null); - this.phoneNumber = new SignUpVerification(null); - this.web3Wallet = new SignUpVerification(null); - this.externalAccount = new Verification(null); - } + this.emailAddress = new SignUpVerification(data?.email_address ?? null); + this.phoneNumber = new SignUpVerification(data?.phone_number ?? null); + this.web3Wallet = new SignUpVerification(data?.web3_wallet ?? null); + this.externalAccount = new Verification(data?.external_account ?? null); } public __internal_toSnapshot(): SignUpVerificationsJSONSnapshot { @@ -139,13 +212,8 @@ export class SignUpVerification extends Verification { constructor(data: SignUpVerificationJSON | SignUpVerificationJSONSnapshot | null) { super(data); - if (data) { - this.nextAction = data.next_action; - this.supportedStrategies = data.supported_strategies; - } else { - this.nextAction = ''; - this.supportedStrategies = []; - } + this.nextAction = data?.next_action ?? ''; + this.supportedStrategies = data?.supported_strategies ?? []; } public __internal_toSnapshot(): SignUpVerificationJSONSnapshot { diff --git a/packages/clerk-js/src/core/resources/state.ts b/packages/clerk-js/src/core/resources/state.ts new file mode 100644 index 00000000000..3440ccc5ce6 --- /dev/null +++ b/packages/clerk-js/src/core/resources/state.ts @@ -0,0 +1,133 @@ +import type { ClerkAPIErrorJSON } from '@clerk/types'; +import { devtools } from 'zustand/middleware'; +import { createStore } from 'zustand/vanilla'; + +/** + * Represents the possible states of a resource. + */ +export type ResourceState = + | { status: 'idle'; data: null; error: null } + | { status: 'loading'; data: null; error: null } + | { status: 'error'; data: null; error: ClerkAPIErrorJSON | null } + | { status: 'success'; data: T; error: null }; + +/** + * Resource actions for the Zustand store + */ +export type ResourceAction = + | { type: 'FETCH_START' } + | { type: 'FETCH_SUCCESS'; data: T } + | { type: 'FETCH_ERROR'; error: ClerkAPIErrorJSON } + | { type: 'RESET' }; + +/** + * Resource store shape using Zustand slices pattern + */ +export type ResourceStore = { + resource: { + status: 'idle' | 'loading' | 'error' | 'success'; + data: T | null; + error: ClerkAPIErrorJSON | null; + dispatch: (action: ResourceAction) => void; + getData: () => T | null; + getError: () => ClerkAPIErrorJSON | null; + hasError: () => boolean; + getStatus: () => 'idle' | 'loading' | 'error' | 'success'; + }; +}; + +/** + * Creates a resource slice following the Zustand slices pattern. + * This slice handles generic resource state management (loading, success, error states). + * All resource state is namespaced under the 'resource' key and flattened (no nested 'state' object). + */ +const createResourceSlice = (set: any, get: any): ResourceStore => { + const dispatch = (action: ResourceAction) => { + set((state: any) => { + let newResourceState; + + switch (action.type) { + case 'FETCH_START': + newResourceState = { + status: 'loading' as const, + data: state.resource.data, // Keep existing data during loading + error: null, + }; + break; + case 'FETCH_SUCCESS': + newResourceState = { + status: 'success' as const, + data: action.data, + error: null, + }; + break; + case 'FETCH_ERROR': + newResourceState = { + status: 'error' as const, + data: state.resource.data, // Keep existing data on error + error: action.error, + }; + break; + case 'RESET': + newResourceState = { + status: 'idle' as const, + data: null, + error: null, + }; + break; + default: + return state; + } + + return { + ...state, + resource: { + ...state.resource, + ...newResourceState, + }, + }; + }); + }; + + return { + resource: { + status: 'idle' as const, + data: null, + error: null, + dispatch, + getData: () => { + const state = get(); + return state.resource.data; + }, + getError: () => { + const state = get(); + return state.resource.error; + }, + hasError: () => { + const state = get(); + return state.resource.status === 'error'; + }, + getStatus: () => { + const state = get(); + return state.resource.status; + }, + }, + }; +}; + +/** + * Creates a basic resource store using just the resource slice. + * This is used by BaseResource for backward compatibility. + */ +export const createResourceStore = (name = 'ResourceStore') => { + return createStore>()( + devtools( + (set, get) => ({ + ...createResourceSlice(set, get), + }), + { name }, + ), + ); +}; + +export { createResourceSlice }; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 7a69de6312d..7dc06ed5c22 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -30,7 +30,6 @@ import { import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts'; import { Col, descriptors, Flow, localizationKeys } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; -import { useLoadingStatus } from '../../hooks'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { useRouter } from '../../router'; import type { FormControlState } from '../../utils'; @@ -77,7 +76,6 @@ const useAutoFillPasskey = () => { function SignInStartInternal(): JSX.Element { const card = useCardState(); const clerk = useClerk(); - const status = useLoadingStatus(); const { displayConfig, userSettings, authConfig } = useEnvironment(); const signIn = useCoreSignIn(); const { navigate } = useRouter(); @@ -196,8 +194,24 @@ function SignInStartInternal(): JSX.Element { } }, [identifierField.value, identifierAttributes]); + const signInStatus = signIn.status; + const signInFetchStatus = signIn.store.getState().status; + + useEffect(() => { + console.log('Component mounted'); + console.log('Initial organizationTicket:', organizationTicket); + console.log('Initial signInFetchStatus:', signInFetchStatus); + console.log('Initial signInStatus:', signInStatus); + }, []); + useEffect(() => { - if (!organizationTicket) { + console.log('useEffect triggered'); + console.log('organizationTicket:', organizationTicket); + console.log('signInFetchStatus:', signInFetchStatus); + console.log('signInStatus:', signInStatus); + + if (!organizationTicket || signInFetchStatus === 'fetching' || signInStatus === 'complete') { + console.log('Early return from useEffect'); return; } @@ -206,13 +220,12 @@ function SignInStartInternal(): JSX.Element { if (organizationTicket) { paramsToForward.set('__clerk_ticket', organizationTicket); } - // We explicitly navigate to 'create' in the combined flow to trigger a client-side navigation. Navigating to - // signUpUrl triggers a full page reload when used with the hash router. + console.log('Navigating to signUpUrl with params:', paramsToForward.toString()); void navigate(isCombinedFlow ? `create` : signUpUrl, { searchParams: paramsToForward }); return; } - status.setLoading(); + console.log('Setting card to loading state'); card.setLoading(); signIn .create({ @@ -220,51 +233,57 @@ function SignInStartInternal(): JSX.Element { ticket: organizationTicket, }) .then(res => { + console.log('API response:', res); switch (res.status) { case 'needs_first_factor': + console.log('Status: needs_first_factor'); if (hasOnlyEnterpriseSSOFirstFactors(res)) { + console.log('Authenticating with Enterprise SSO'); return authenticateWithEnterpriseSSO(); } return navigate('factor-one'); case 'needs_second_factor': + console.log('Status: needs_second_factor'); return navigate('factor-two'); case 'complete': + console.log('Status: complete'); removeClerkQueryParam('__clerk_ticket'); return clerk.setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl, }); default: { + console.error('Invalid API response status:', res.status); console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); return; } } }) .catch(err => { + console.error('Error during signIn.create:', err); return attemptToRecoverFromSignInError(err); }) .finally(() => { - // Keep the card in loading state during SSO redirect to prevent UI flicker - // This is necessary because there's a brief delay between initiating the SSO flow - // and the actual redirect to the external Identity Provider const isRedirectingToSSOProvider = hasOnlyEnterpriseSSOFirstFactors(signIn); if (isRedirectingToSSOProvider) return; - status.setIdle(); + console.log('Setting card to idle state'); card.setIdle(); }); - }, []); + }, [organizationTicket, signInFetchStatus, signInStatus]); useEffect(() => { + console.log('OAuth error handling useEffect triggered'); async function handleOauthError() { const defaultErrorHandler = () => { - // Error from server may be too much information for the end user, so set a generic error + console.error('Default error handler triggered'); card.setError('Unable to complete action at this time. If the problem persists please contact support.'); }; const error = signIn?.firstFactorVerification?.error; if (error) { + console.log('OAuth error detected:', error); switch (error.code) { case ERROR_CODES.NOT_ALLOWED_TO_SIGN_UP: case ERROR_CODES.OAUTH_ACCESS_DENIED: @@ -292,6 +311,7 @@ function SignInStartInternal(): JSX.Element { // TODO: This is a workaround in order to reset the sign in attempt // so that the oauth error does not persist on full page reloads. + console.log('Resetting sign-in attempt'); void (await signIn.create({})); } } @@ -368,6 +388,8 @@ function SignInStartInternal(): JSX.Element { switch (res.status) { case 'needs_identifier': + console.log('needs_identifier'); + console.log('res.supportedFirstFactors:', res.supportedFirstFactors); // Check if we need to initiate an enterprise sso flow if (res.supportedFirstFactors?.some(ff => ff.strategy === 'saml' || ff.strategy === 'enterprise_sso')) { await authenticateWithEnterpriseSSO(); @@ -495,12 +517,16 @@ function SignInStartInternal(): JSX.Element { return components[identifierField.type as keyof typeof components]; }, [identifierField.type]); - if (status.isLoading || clerkStatus === 'sign_up') { + if (clerkStatus === 'sign_up') { // clerkStatus being sign_up will trigger a navigation to the sign up flow, so show a loading card instead of // rendering the sign in flow. return ; } + if (signInStatus === 'complete') { + return
    Sign-in complete!
    ; + } + // @ts-expect-error `action` is not typed const { action, ...identifierFieldProps } = identifierField.props; return ( diff --git a/packages/react/package.json b/packages/react/package.json index 1b079d1466f..a7913d82422 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -78,7 +78,8 @@ "dependencies": { "@clerk/shared": "workspace:^", "@clerk/types": "workspace:^", - "tslib": "catalog:repo" + "tslib": "catalog:repo", + "zustand": "5.0.5" }, "devDependencies": { "@clerk/localizations": "workspace:*", diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 5a6cb60cb6b..973fa030781 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -2,6 +2,7 @@ export { useAuth } from './useAuth'; export { useEmailLink } from './useEmailLink'; export { useSignIn } from './useSignIn'; export { useSignUp } from './useSignUp'; +export { createResourceStoreHooks } from './useResourceStore'; export { useClerk, useOrganization, diff --git a/packages/react/src/hooks/useResourceStore.ts b/packages/react/src/hooks/useResourceStore.ts new file mode 100644 index 00000000000..537f2f3f66c --- /dev/null +++ b/packages/react/src/hooks/useResourceStore.ts @@ -0,0 +1,62 @@ +import type { StoreApi } from 'zustand'; +import { useStore } from 'zustand'; + +interface ResourceStore { + state: any; + dispatch: (action: any) => void; + getData: () => T | null; + getError: () => any; + status: () => 'idle' | 'loading' | 'error' | 'success'; + hasError: () => boolean; +} + +/** + * React hooks for using the resource store in React components. + * This provides React-specific integration for the framework-agnostic resource store. + */ +export const createResourceStoreHooks = (store: StoreApi>) => { + /** + * Hook to get the entire store state + */ + const useResourceStore = () => useStore(store); + + /** + * Hook to get the current resource state + */ + const useResourceState = () => useStore(store, state => state.state); + + /** + * Hook to get the dispatch function + */ + const useResourceDispatch = () => useStore(store, state => state.dispatch); + + /** + * Hook to get the current data + */ + const useResourceData = () => useStore(store, state => state.state.data); + + /** + * Hook to get the current error + */ + const useResourceError = () => useStore(store, state => state.state.error); + + /** + * Hook to get the current status + */ + const useResourceStatus = () => useStore(store, state => state.state.status); + + /** + * Hook to check if the resource has an error + */ + const useResourceHasError = () => useStore(store, state => !!state.state.error); + + return { + useResourceStore, + useResourceState, + useResourceDispatch, + useResourceData, + useResourceError, + useResourceStatus, + useResourceHasError, + }; +}; diff --git a/packages/react/src/hooks/useSignIn.ts b/packages/react/src/hooks/useSignIn.ts index e4f2fef6237..39811276633 100644 --- a/packages/react/src/hooks/useSignIn.ts +++ b/packages/react/src/hooks/useSignIn.ts @@ -1,69 +1,190 @@ -import { useClientContext } from '@clerk/shared/react'; +import { ClerkInstanceContext, useClientContext } from '@clerk/shared/react'; import { eventMethodCalled } from '@clerk/shared/telemetry'; -import type { UseSignInReturn } from '@clerk/types'; +import type { SetActive, SignInResource } from '@clerk/types'; +import { useCallback,useContext, useMemo } from 'react'; +import { useStore } from 'zustand'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvider'; +type QueuedCall = { + target: 'signIn' | 'setActive'; + method: string; + args: any[]; + resolve: (value: any) => void; + reject: (error: any) => void; +}; + /** - * The `useSignIn()` hook provides access to the [`SignIn`](https://clerk.com/docs/references/javascript/sign-in) object, which allows you to check the current state of a sign-in attempt and manage the sign-in flow. You can use this to create a [custom sign-in flow](https://clerk.com/docs/custom-flows/overview#sign-in-flow). - * - * @unionReturnHeadings - * ["Initialization", "Loaded"] - * - * @example - * ### Check the current state of a sign-in - * - * The following example uses the `useSignIn()` hook to access the [`SignIn`](https://clerk.com/docs/references/javascript/sign-in) object, which contains the current sign-in attempt status and methods to create a new sign-in attempt. The `isLoaded` property is used to handle the loading state. - * - * - * - * - * ```tsx {{ filename: 'src/pages/SignInPage.tsx' }} - * import { useSignIn } from '@clerk/clerk-react' - * - * export default function SignInPage() { - * const { isLoaded, signIn } = useSignIn() - * - * if (!isLoaded) { - * // Handle loading state - * return null - * } - * - * return
    The current sign-in attempt status is {signIn?.status}.
    - * } - * ``` - * - *
    - * - * - * {@include ../../docs/use-sign-in.md#nextjs-01} - * - * - *
    - * - * @example - * ### Create a custom sign-in flow with `useSignIn()` - * - * The `useSignIn()` hook can also be used to build fully custom sign-in flows, if Clerk's prebuilt components don't meet your specific needs or if you require more control over the authentication flow. Different sign-in flows include email and password, email and phone codes, email links, and multifactor (MFA). To learn more about using the `useSignIn()` hook to create custom flows, see the [custom flow guides](https://clerk.com/docs/custom-flows/overview). - * - * ```empty``` + * Enhanced SignInResource with observable capabilities for React. */ -export const useSignIn = (): UseSignInReturn => { - useAssertWrappedByClerkProvider('useSignIn'); +type ObservableSignInResource = SignInResource & { + /** + * The observable store state. This is not a function but the actual state. + * Components using this should access it directly from the useSignIn hook result. + */ + observableState?: any; +}; + +/** + * Return type for the useSignIn hook + */ +type UseSignInReturn = { + isLoaded: boolean; + signIn: ObservableSignInResource; + setActive: SetActive; + /** + * The observable store state. Use this to access the SignIn store state. + * This value will trigger re-renders when the store state changes. + */ + signInStore: any; +}; +/** + * A stable fallback store that maintains consistent behavior when no real store exists. + */ +const FALLBACK_STATE = {}; +const FALLBACK_STORE = { + getState: () => FALLBACK_STATE, + getInitialState: () => FALLBACK_STATE, + subscribe: () => () => {}, // Return unsubscribe function + setState: () => {}, + destroy: () => {} +}; + +export const useSignIn = (): UseSignInReturn => { + // Check if ClerkProvider context is available first + const clerkInstanceContext = useContext(ClerkInstanceContext); + const isomorphicClerk = useIsomorphicClerkContext(); const client = useClientContext(); - isomorphicClerk.telemetry?.record(eventMethodCalled('useSignIn')); - - if (!client) { - return { isLoaded: false, signIn: undefined, setActive: undefined }; + // Only assert ClerkProvider if we have some context - this allows proxy fallback + if (clerkInstanceContext) { + useAssertWrappedByClerkProvider('useSignIn'); } - return { - isLoaded: true, - signIn: client.signIn, - setActive: isomorphicClerk.setActive, - }; -}; + isomorphicClerk?.telemetry?.record(eventMethodCalled('useSignIn')); + + // Get the store reference - this must be done at the top level + const store = useMemo(() => { + if (!client?.signIn) return FALLBACK_STORE; + + // Try both 'store' and '_store' properties, but default to fallback + return (client.signIn as any).store || (client.signIn as any)._store || FALLBACK_STORE; + }, [client?.signIn]); + + // Always call useStore at the top level with a consistent store reference + const storeState = useStore(store); + + // Determine if we have a real store + const hasRealStore = store !== FALLBACK_STORE; + + // Compute the final store state + const signInStore = useMemo(() => { + if (!hasRealStore) { + return {}; + } + return storeState; + }, [hasRealStore, storeState]); + + const callQueue: QueuedCall[] = []; + + const processQueue = useCallback((signIn: SignInResource, setActive: SetActive) => { + while (callQueue.length > 0) { + const queuedCall = callQueue.shift(); + if (!queuedCall) continue; + + const { target, method, args, resolve, reject } = queuedCall; + try { + const targetObj = target === 'setActive' ? setActive : signIn; + const result = (targetObj as any)[method](...args); + resolve(result); + } catch (error) { + reject(error); + } + } + }, []); + + const createProxy = useCallback((target: 'signIn' | 'setActive'): T => { + const proxyTarget: any = {}; + + return new Proxy( + proxyTarget, + { + get(_, prop) { + // Prevent React from treating this proxy as a Promise by returning undefined for 'then' + if (prop === 'then') { + return undefined; + } + + // Handle Symbol properties and other non-method properties + if (typeof prop === 'symbol' || typeof prop !== 'string') { + return undefined; + } + + // For observableState property, return undefined in proxy mode + if (prop === 'observableState' && target === 'signIn') { + return undefined; + } + + return (...args: any[]) => { + return new Promise((resolve, reject) => { + callQueue.push({ + target, + method: String(prop), + args, + resolve, + reject, + }); + }); + }; + }, + has(_, prop) { + // Return false for 'then' to prevent Promise-like behavior + if (prop === 'then') { + return false; + } + // Return true for all other properties to indicate they exist on the proxy + return true; + }, + ownKeys(_) { + return Object.getOwnPropertyNames(proxyTarget); + }, + getOwnPropertyDescriptor(_, prop) { + return Object.getOwnPropertyDescriptor(proxyTarget, prop); + } + }, + ) as T; + }, []); + + // Memoize the result to prevent unnecessary re-renders + return useMemo(() => { + if (client) { + processQueue(client.signIn, isomorphicClerk.setActive); + + // Create an enhanced signIn object that includes the observable state + const enhancedSignIn: ObservableSignInResource = Object.create(client.signIn); + Object.defineProperty(enhancedSignIn, 'observableState', { + value: signInStore, + writable: false, + enumerable: true, + configurable: true + }); + + return { + isLoaded: true, + signIn: enhancedSignIn, + setActive: isomorphicClerk.setActive, + signInStore, + }; + } + + return { + isLoaded: true, + signIn: createProxy('signIn'), + setActive: createProxy('setActive'), + signInStore: {}, + }; + }, [client, isomorphicClerk?.setActive, signInStore, processQueue, createProxy]); +}; \ No newline at end of file diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 6022cd064df..19baa40c4f1 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -5,26 +5,29 @@ import type { SignUpResource } from './signUp'; import type { ClientJSONSnapshot } from './snapshots'; export interface ClientResource extends ClerkResource { - sessions: SessionResource[]; - signedInSessions: SignedInSessionResource[]; - signUp: SignUpResource; - signIn: SignInResource; - isNew: () => boolean; - create: () => Promise; - destroy: () => Promise; - removeSessions: () => Promise; - clearCache: () => void; - isEligibleForTouch: () => boolean; - buildTouchUrl: (params: { redirectUrl: URL }) => string; - lastActiveSessionId: string | null; captchaBypass: boolean; cookieExpiresAt: Date | null; createdAt: Date | null; + lastActiveSessionId: string | null; + sessions: SessionResource[]; + signedInSessions: SignedInSessionResource[]; + signIn: SignInResource; + signUp: SignUpResource; updatedAt: Date | null; - __internal_sendCaptchaToken: (params: unknown) => Promise; - __internal_toSnapshot: () => ClientJSONSnapshot; /** * @deprecated Use `signedInSessions` instead. */ activeSessions: ActiveSessionResource[]; + + buildTouchUrl: (params: { redirectUrl: URL }) => string; + clearCache: () => void; + create: () => Promise; + destroy: () => Promise; + isEligibleForTouch: () => boolean; + isNew: () => boolean; + removeSessions: () => Promise; + + __internal_sendCaptchaToken: (params: unknown) => Promise; + __internal_toSnapshot: () => ClientJSONSnapshot; + } diff --git a/packages/types/src/hooks.ts b/packages/types/src/hooks.ts index 7baac435ea4..e98ec0e1443 100644 --- a/packages/types/src/hooks.ts +++ b/packages/types/src/hooks.ts @@ -121,26 +121,22 @@ export type UseAuthReturn = /** * @inline */ -export type UseSignInReturn = - | { - /** - * A boolean that indicates whether Clerk has completed initialization. Initially `false`, becomes `true` once Clerk loads. - */ - isLoaded: false; - /** - * An object that contains the current sign-in attempt status and methods to create a new sign-in attempt. - */ - signIn: undefined; - /** - * A function that sets the active session. - */ - setActive: undefined; - } - | { - isLoaded: true; - signIn: SignInResource; - setActive: SetActive; - }; +export type UseSignInReturn = { + /** + * Always `true`. The hook returns proxy objects that queue method calls until Clerk initializes. + * Method calls are buffered and executed once initialization completes, allowing immediate usage + * without waiting for the loading state to resolve. + */ + isLoaded: true; + /** + * An object that contains the current sign-in attempt status and methods to create a new sign-in attempt. + */ + signIn: SignInResource; + /** + * A function that sets the active session. + */ + setActive: SetActive; +}; /** * @inline diff --git a/packages/types/src/resource.ts b/packages/types/src/resource.ts index 140af2e7fe2..ddffa24becd 100644 --- a/packages/types/src/resource.ts +++ b/packages/types/src/resource.ts @@ -2,6 +2,18 @@ export type ClerkResourceReloadParams = { rotatingTokenNonce?: string; }; +/** + * Minimal store interface to avoid importing zustand in types package + */ +export interface ResourceStoreApi { + getState: () => T; + setState: { + (partial: T | Partial | ((state: T) => T | Partial), replace?: false | undefined): void; + (state: T | ((state: T) => T), replace: true): void; + }; + subscribe: (listener: (state: T, prevState: T) => void) => () => void; +} + /** * Defines common properties and methods that all Clerk resources must implement. */ @@ -18,4 +30,8 @@ export interface ClerkResource { * Reload the resource and return the resource itself. */ reload(p?: ClerkResourceReloadParams): Promise; + /** + * The reactive store for this resource + */ + store: ResourceStoreApi; } diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index 0f8ccdbde66..91f9858fa4b 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -81,13 +81,14 @@ export interface SignInResource extends ClerkResource { /** * @deprecated This attribute will be removed in the next major version. */ - supportedIdentifiers: SignInIdentifier[]; - supportedFirstFactors: SignInFirstFactor[] | null; - supportedSecondFactors: SignInSecondFactor[] | null; + createdSessionId: string | null; firstFactorVerification: VerificationResource; - secondFactorVerification: VerificationResource; identifier: string | null; - createdSessionId: string | null; + secondFactorVerification: VerificationResource; + signInError: { global: string | null; fields: Record }; + supportedFirstFactors: SignInFirstFactor[] | null; + supportedIdentifiers: SignInIdentifier[]; + supportedSecondFactors: SignInSecondFactor[] | null; userData: UserData; create: (params: SignInCreateParams) => Promise; diff --git a/packages/vue/src/composables/useSignIn.ts b/packages/vue/src/composables/useSignIn.ts index b0ae14ed503..bce3842fbbb 100644 --- a/packages/vue/src/composables/useSignIn.ts +++ b/packages/vue/src/composables/useSignIn.ts @@ -1,12 +1,31 @@ import { eventMethodCalled } from '@clerk/shared/telemetry'; -import type { UseSignInReturn } from '@clerk/types'; +import type { SetActive,SignInResource, UseSignInReturn } from '@clerk/types'; import { computed, watch } from 'vue'; import type { ToComputedRefs } from '../utils'; import { toComputedRefs } from '../utils'; import { useClerkContext } from './useClerkContext'; -type UseSignIn = () => ToComputedRefs; +type UseSignIn = () => ToComputedRefs | ToComputedRefs; + +/** + * A deferred proxy type that represents a resource that is not yet available + * but will be hydrated once Clerk is loaded. This prevents unsafe type casting + * and provides proper static typing for methods that return Promises. + */ +type Deferred = { + [K in keyof T]: T[K] extends (...args: infer Args) => Promise + ? (...args: Args) => Promise + : T[K] extends (...args: infer Args) => infer Return + ? (...args: Args) => Promise + : T[K]; +}; + +type DeferredUseSignInReturn = { + isLoaded: true; + signIn: Deferred; + setActive: Deferred; +}; /** * Returns the current [`SignIn`](https://clerk.com/docs/references/javascript/sign-in) object which provides @@ -20,11 +39,7 @@ type UseSignIn = () => ToComputedRefs; * * * @@ -39,16 +54,74 @@ export const useSignIn: UseSignIn = () => { } }); - const result = computed(() => { + const result = computed(() => { if (!clerk.value || !clientCtx.value) { - return { isLoaded: false, signIn: undefined, setActive: undefined }; + // Create proxy objects that queue calls until clerk loads + const createProxy = (target: 'signIn' | 'setActive'): Deferred => { + return new Proxy({}, { + get(_, prop) { + // Prevent Vue from treating this proxy as a Promise by returning undefined for 'then' + if (prop === 'then') { + return undefined; + } + + // Handle Symbol properties and other non-method properties + if (typeof prop === 'symbol' || typeof prop !== 'string') { + return undefined; + } + + return (...args: any[]) => { + return new Promise((resolve, reject) => { + // Wait for next tick and try again + setTimeout(() => { + if (clerk.value && clientCtx.value) { + const targetObj = target === 'setActive' ? clerk.value.setActive : clientCtx.value.signIn; + try { + // Type-safe method call by checking if the property exists and is callable + if (targetObj && typeof targetObj === 'object' && prop in targetObj) { + const method = (targetObj as any)[prop]; + if (typeof method === 'function') { + const result = method.apply(targetObj, args); + resolve(result); + } else { + reject(new Error(`Property ${prop} is not a function on ${target}`)); + } + } else { + reject(new Error(`Method ${prop} not found on ${target}`)); + } + } catch (error) { + reject(error); + } + } else { + reject(new Error('Clerk not loaded')); + } + }, 0); + }); + }; + }, + has(_, prop) { + // Return false for 'then' to prevent Promise-like behavior + if (prop === 'then') { + return false; + } + // Return true for all other properties to indicate they exist on the proxy + return true; + }, + }) as Deferred; + }; + + return { + isLoaded: true, + signIn: createProxy('signIn'), + setActive: createProxy('setActive'), + } satisfies DeferredUseSignInReturn; } return { isLoaded: true, signIn: clientCtx.value.signIn, setActive: clerk.value.setActive, - }; + } satisfies UseSignInReturn; }); return toComputedRefs(result); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 723af234653..5ee23d06aba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -500,6 +500,9 @@ importers: swr: specifier: 2.3.3 version: 2.3.3(react@18.3.1) + zustand: + specifier: 5.0.5 + version: 5.0.5(@types/react@18.3.23)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) devDependencies: '@emotion/jest': specifier: ^11.13.0 @@ -814,6 +817,9 @@ importers: tslib: specifier: catalog:repo version: 2.8.1 + zustand: + specifier: 5.0.5 + version: 5.0.5(@types/react@18.3.23)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)) devDependencies: '@clerk/localizations': specifier: workspace:* @@ -2997,7 +3003,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -14696,6 +14702,24 @@ packages: zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zustand@5.0.5: + resolution: {integrity: sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -31783,6 +31807,12 @@ snapshots: zod@3.24.2: {} + zustand@5.0.5(@types/react@18.3.23)(react@18.3.1)(use-sync-external-store@1.4.0(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.23 + react: 18.3.1 + use-sync-external-store: 1.4.0(react@18.3.1) + zwitch@2.0.4: {} zx@8.4.1: {}