diff --git a/.changeset/calm-clients-recover.md b/.changeset/calm-clients-recover.md new file mode 100644 index 00000000000..ece4f3e2793 --- /dev/null +++ b/.changeset/calm-clients-recover.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix JS auth state recovery after rejecting a divergent native client so signed-in sessions can still sign out. diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index 8903b647f0c..01f664256ce 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -32,9 +32,9 @@ env: jobs: native-build: if: ${{ github.head_ref != 'changeset-release/main' }} - name: Expo ${{ matrix.expo-sdk }} Build / ${{ matrix.platform }} + name: Expo ${{ matrix.expo-sdk }} ${{ matrix.run-e2e == true && 'Build + E2E' || 'Build' }} / ${{ matrix.platform }} runs-on: ${{ matrix.runner }} - timeout-minutes: 45 + timeout-minutes: ${{ matrix.run-e2e == true && 60 || 45 }} strategy: fail-fast: false matrix: @@ -66,16 +66,57 @@ jobs: with: persist-credentials: false - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - name: Compute native build cache key + id: native-build-key + env: + PLATFORM: ${{ matrix.platform }} + run: | + build_hash="$( + git ls-files -s -- \ + .npmrc \ + package.json \ + pnpm-lock.yaml \ + pnpm-workspace.yaml \ + tsconfig.json \ + turbo.json \ + packages/clerk-js \ + packages/expo \ + packages/expo-google-signin \ + packages/react \ + packages/shared \ + "$FIXTURE_DIR" | + git hash-object --stdin + )" + if [ "$PLATFORM" = "android" ]; then + artifact="$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk" + else + artifact="$FIXTURE_DIR/ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app" + fi + echo "hash=$build_hash" >> "$GITHUB_OUTPUT" + echo "artifact=$artifact" >> "$GITHUB_OUTPUT" + + - name: Restore native build + id: native-build-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ steps.native-build-key.outputs.artifact }} + # Bump the version when native build commands change. + key: expo-native-build-v1-${{ runner.os }}-${{ matrix.expo-sdk }}-${{ matrix.platform }}-${{ matrix.run-e2e == true && 'e2e' || 'build' }}-${{ env.E2E_INSTANCE_NAME }}-${{ steps.native-build-key.outputs.hash }} + + - if: steps.native-build-cache.outputs.cache-hit != 'true' + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - if: steps.native-build-cache.outputs.cache-hit != 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24.15.0 cache: pnpm - name: Install monorepo dependencies + if: steps.native-build-cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Build and pack Clerk packages + if: steps.native-build-cache.outputs.cache-hit != 'true' run: | pnpm --filter @clerk/expo... build mkdir -p "$SDK_PACK_DIR" @@ -83,6 +124,7 @@ jobs: pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR" - name: Install fixture dependencies + if: steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} env: EXPO_SDK: ${{ matrix.expo-sdk }} @@ -100,6 +142,14 @@ jobs: DEV_CLIENT="expo-dev-client" if [ "$RUN_E2E" = "true" ]; then DEV_CLIENT=""; fi pnpm expo install expo-auth-session expo-constants expo-crypto $DEV_CLIENT expo-secure-store expo-web-browser + if [ "$RUN_E2E" = "true" ]; then + REACT_VERSION=$(node -p 'require("react/package.json").version') + REACT_DOM_VERSION=$(node -p 'require("react-dom/package.json").version') + if [ "$REACT_VERSION" != "$REACT_DOM_VERSION" ]; then + echo "::error::React version mismatch: react@$REACT_VERSION, react-dom@$REACT_DOM_VERSION" + exit 1 + fi + fi - name: Check e2e secret availability id: gate @@ -123,6 +173,7 @@ jobs: run: node scripts/resolve-instance-keys.mjs INTEGRATION_STAGING_INSTANCE_KEYS "$E2E_INSTANCE_NAME" - name: Write fixture .env + if: steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} env: E2E_PK: ${{ steps.keys.outputs.pk }} @@ -130,118 +181,38 @@ jobs: echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${E2E_PK:-$FIXTURE_PUBLISHABLE_KEY}" > .env - name: Set up JDK 17 - if: ${{ matrix.platform == 'android' }} + if: matrix.platform == 'android' uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: 17 - name: Prebuild Android fixture - if: ${{ matrix.platform == 'android' }} + if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} run: pnpm expo prebuild --clean --platform android - name: Build Android fixture - if: ${{ matrix.platform == 'android' }} + if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} run: pnpm build:android - name: Prebuild iOS fixture - if: ${{ matrix.platform == 'ios' }} + if: matrix.platform == 'ios' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} run: pnpm expo prebuild --clean --platform ios - name: Build iOS fixture - if: ${{ matrix.platform == 'ios' }} + if: matrix.platform == 'ios' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} run: pnpm build:ios - # tar the .app so its bundle structure survives the artifact round-trip. - - name: Package iOS app for e2e - if: matrix.platform == 'ios' && matrix.run-e2e == true && steps.keys.outputs.pk != '' - working-directory: ${{ env.FIXTURE_DIR }} - run: | - tar -C ios/build/Build/Products/Release-iphonesimulator \ - -cf "$RUNNER_TEMP/ClerkExpoNativeBuildFixture.app.tar" \ - ClerkExpoNativeBuildFixture.app - - - name: Upload iOS fixture app - if: matrix.platform == 'ios' && matrix.run-e2e == true && steps.keys.outputs.pk != '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: expo-fixture-ios - path: ${{ runner.temp }}/ClerkExpoNativeBuildFixture.app.tar - retention-days: 1 - if-no-files-found: error - - - name: Upload Android fixture app - if: matrix.platform == 'android' && matrix.run-e2e == true && steps.keys.outputs.pk != '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: expo-fixture-android - path: ${{ env.FIXTURE_DIR }}/android/app/build/outputs/apk/release/app-release.apk - retention-days: 1 - if-no-files-found: error - - e2e: - name: E2E / ${{ matrix.platform }} - needs: native-build - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - platform: ios - runner: blacksmith-6vcpu-macos-26 - - platform: android - runner: blacksmith-8vcpu-ubuntu-2204 - - steps: - - name: Checkout repo - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Save native build + if: steps.native-build-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - persist-credentials: false - - # Only Node (for the key resolver) is needed; the e2e job builds nothing. - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24.15.0 - - - name: Check e2e secret availability - id: gate - env: - KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }} - run: | - if [ -n "$KEYS" ]; then - echo "ok=true" >> "$GITHUB_OUTPUT" - else - echo "ok=false" >> "$GITHUB_OUTPUT" - echo "::notice::INTEGRATION_STAGING_INSTANCE_KEYS unavailable (fork PR?); skipping e2e." - fi - - - name: Resolve Clerk instance keys - id: keys - if: steps.gate.outputs.ok == 'true' - continue-on-error: true - env: - INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }} - run: node scripts/resolve-instance-keys.mjs INTEGRATION_STAGING_INSTANCE_KEYS "$E2E_INSTANCE_NAME" - - - name: Download fixture app - if: steps.keys.outputs.pk != '' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: expo-fixture-${{ matrix.platform }} - path: ${{ runner.temp }}/fixture-app - - # Maestro is a JVM app and needs a JDK on the Linux runner (macOS ships one). - - name: Set up JDK 17 - if: matrix.platform == 'android' && steps.keys.outputs.pk != '' - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 - with: - distribution: temurin - java-version: 17 + path: ${{ steps.native-build-key.outputs.artifact }} + key: ${{ steps.native-build-cache.outputs.cache-primary-key }} - name: Cache Maestro if: steps.keys.outputs.pk != '' @@ -314,6 +285,7 @@ jobs: # Burn-in: e2e cannot fail the check yet. Flip to hard-fail in a # follow-up once the suite has proven quiet. continue-on-error: true + working-directory: ${{ env.FIXTURE_DIR }} env: CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }} CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }} @@ -328,8 +300,7 @@ jobs: xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.AutoCapitalization -bool NO || true xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.AutoCorrection -bool NO || true xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.Prediction -bool NO || true - tar -C "$RUNNER_TEMP/fixture-app" -xf "$RUNNER_TEMP/fixture-app/ClerkExpoNativeBuildFixture.app.tar" - xcrun simctl install "$SIM_UDID" "$RUNNER_TEMP/fixture-app/ClerkExpoNativeBuildFixture.app" + xcrun simctl install "$SIM_UDID" ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app # Stream the app's console output into the debug artifact so a hang has # actionable evidence (keychain/network errors) instead of just screenshots. mkdir -p "$RUNNER_TEMP/maestro-debug" @@ -391,9 +362,9 @@ jobs: # The action runs each script line in a separate sh -c; the folded # scalar (>-) plus && keeps everything in one shell invocation. script: >- - adb install -r "$RUNNER_TEMP/fixture-app/app-release.apk" && cd integration/tests/expo-native && - MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-flows.sh adb shell am force-stop com.clerk.exponativebuildfixture + MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-android-flows.sh + "$GITHUB_WORKSPACE/$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk" # Runs before the artifact upload so the credentials are already dead # by the time the debug files become downloadable. diff --git a/integration/templates/expo-native/components/JsSignInForm.tsx b/integration/templates/expo-native/components/JsSignInForm.tsx index 5a76cb98cae..2d0b062937b 100644 --- a/integration/templates/expo-native/components/JsSignInForm.tsx +++ b/integration/templates/expo-native/components/JsSignInForm.tsx @@ -2,11 +2,6 @@ import { useSignIn } from '@clerk/expo'; import { useState } from 'react'; import { Button, StyleSheet, TextInput } from 'react-native'; -/** - * Headless JS-runtime sign-in so the JS client owns the session, which the - * native-token-divergence flow requires (the native AuthView would create the - * session on the native client instead). - */ export function JsSignInForm({ onStatus }: { onStatus: (status: string) => void }) { const { signIn } = useSignIn(); const [identifier, setIdentifier] = useState(''); @@ -21,6 +16,32 @@ export function JsSignInForm({ onStatus }: { onStatus: (status: string) => void reportError(error.message ?? ''); return; } + + if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_client_trust') { + const supportsEmailCode = signIn.supportedSecondFactors.some(factor => factor.strategy === 'email_code'); + if (!supportsEmailCode) { + reportError(`Unsupported sign-in state: ${signIn.status}`); + return; + } + + const { error: sendCodeError } = await signIn.mfa.sendEmailCode(); + if (sendCodeError) { + reportError(sendCodeError.message ?? ''); + return; + } + + const { error: verifyCodeError } = await signIn.mfa.verifyEmailCode({ code: '424242' }); + if (verifyCodeError) { + reportError(verifyCodeError.message ?? ''); + return; + } + } + + if (signIn.status !== 'complete' || !signIn.createdSessionId) { + reportError(`Sign-in incomplete: ${signIn.status}`); + return; + } + const { error: finalizeError } = await signIn.finalize(); if (finalizeError) { reportError(finalizeError.message ?? ''); diff --git a/integration/templates/expo-native/package.sdk-57.json b/integration/templates/expo-native/package.sdk-57.json index 8d80ee8f8ce..9523431c830 100644 --- a/integration/templates/expo-native/package.sdk-57.json +++ b/integration/templates/expo-native/package.sdk-57.json @@ -11,7 +11,8 @@ }, "dependencies": { "expo": "~57.0.1", - "react": "19.2.7", + "react": "19.2.8", + "react-dom": "19.2.8", "react-native": "0.86.0" }, "devDependencies": { diff --git a/integration/tests/expo-native/flows/subflows/_warmup.yaml b/integration/tests/expo-native/flows/subflows/_warmup.yaml index 4de07d419a9..bde6f57e9f3 100644 --- a/integration/tests/expo-native/flows/subflows/_warmup.yaml +++ b/integration/tests/expo-native/flows/subflows/_warmup.yaml @@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture --- - launchApp: clearState: true + clearKeychain: true - extendedWaitUntil: visible: id: 'auth-state' diff --git a/integration/tests/expo-native/flows/subflows/open-app.yaml b/integration/tests/expo-native/flows/subflows/open-app.yaml index b0aeb35ed36..681f2d289c4 100644 --- a/integration/tests/expo-native/flows/subflows/open-app.yaml +++ b/integration/tests/expo-native/flows/subflows/open-app.yaml @@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture --- - launchApp: clearState: true + clearKeychain: true - extendedWaitUntil: visible: id: 'auth-state' @@ -13,15 +14,4 @@ appId: com.clerk.exponativebuildfixture - extendedWaitUntil: visible: 'signed (in|out)' timeout: 45000 -# clearState does not wipe the iOS Keychain, so a session from a previous -# flow (or retry attempt) can survive the relaunch. Sign out via JS first. -- runFlow: - when: - visible: 'signed in' - commands: - - tapOn: - id: 'sign-out-button' - - extendedWaitUntil: - visible: 'signed out' - timeout: 15000 - assertVisible: 'signed out' diff --git a/integration/tests/expo-native/run-android-flows.sh b/integration/tests/expo-native/run-android-flows.sh new file mode 100755 index 00000000000..29a8e165b84 --- /dev/null +++ b/integration/tests/expo-native/run-android-flows.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +apk_path=${1:?APK path is required} +command -v adb >/dev/null 2>&1 || { + echo 'adb is required' + exit 1 +} + +adb install -r "$apk_path" + +logcat_pid= +stop_logcat() { + [ -n "$logcat_pid" ] || return 0 + kill "$logcat_pid" >/dev/null 2>&1 || true + wait "$logcat_pid" 2>/dev/null || true +} +trap stop_logcat EXIT + +if [ -n "${MAESTRO_DEBUG_OUTPUT:-}" ]; then + mkdir -p "$MAESTRO_DEBUG_OUTPUT" + adb logcat -c || true + adb logcat -v threadtime > "$MAESTRO_DEBUG_OUTPUT/android-logcat.log" 2>&1 & + logcat_pid=$! +fi + +./run-flows.sh adb shell am force-stop com.clerk.exponativebuildfixture diff --git a/integration/tests/expo-native/run-flows.sh b/integration/tests/expo-native/run-flows.sh index e08d864d7a5..4c3172f4fec 100755 --- a/integration/tests/expo-native/run-flows.sh +++ b/integration/tests/expo-native/run-flows.sh @@ -30,10 +30,48 @@ if [ -n "${MAESTRO_DEBUG_OUTPUT:-}" ]; then debug_args=(--debug-output "$MAESTRO_DEBUG_OUTPUT" --flatten-debug-output) fi -# Warmup: parse the JS bundle + populate the a11y tree once so the first real -# flow doesn't flake on cold-start; force-stop afterwards so the first -# launchApp clearState doesn't race a still-foregrounded app process. -maestro test ${debug_args+"${debug_args[@]}"} flows/subflows/_warmup.yaml || true +record_result() { + local flow=$1 + local result=$2 + local attempts=$3 + local duration=$4 + + echo "Flow $flow: $result after $attempts attempt(s) in ${duration}s" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf '| `%s` | %s | %s | %ss |\n' "$flow" "$result" "$attempts" "$duration" >> "$GITHUB_STEP_SUMMARY" + fi +} + +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo '### Maestro flow timings' + echo '| Flow | Result | Attempts | Duration |' + echo '| --- | --- | ---: | ---: |' + } >> "$GITHUB_STEP_SUMMARY" +fi + +# Warm up the JS bundle and accessibility tree before running the flows. +warmup_started=$SECONDS +warmup_result=failed +for warmup_attempt in 1 2; do + if maestro test ${debug_args+"${debug_args[@]}"} flows/subflows/_warmup.yaml; then + warmup_result=passed + break + fi + force_stop "$@" + if [ "$warmup_attempt" -eq 1 ]; then + echo "::warning::Warmup failed attempt 1, retrying after 10s..." + sleep 10 + fi +done +warmup_duration=$((SECONDS - warmup_started)) +record_result "_warmup" "$warmup_result" "$warmup_attempt" "$warmup_duration" +if [ "$warmup_result" != passed ]; then + echo "::error::Warmup failed after 2 attempts; aborting Maestro flows" + exit 1 +fi + +# Force-stop so the first launchApp clearState doesn't race the warm process. force_stop "$@" # Every flows/*.yaml is a cross-platform test (platform differences live in @@ -41,22 +79,28 @@ force_stop "$@" status=0 for flow in flows/*.yaml; do [ -e "$flow" ] || continue + flow_started=$SECONDS + flow_result=failed for attempt in 1 2; do if maestro test \ --env CLERK_TEST_EMAIL="$CLERK_TEST_EMAIL" \ --env CLERK_TEST_PASSWORD="$CLERK_TEST_PASSWORD" \ ${debug_args+"${debug_args[@]}"} \ "$flow"; then + flow_result=passed break fi if [ "$attempt" -eq 2 ]; then echo "::error::Flow $flow failed after 2 attempts" status=1 + force_stop "$@" break fi echo "::warning::Flow $flow failed attempt $attempt, retrying after 10s..." force_stop "$@" sleep 10 done + flow_duration=$((SECONDS - flow_started)) + record_result "$flow" "$flow_result" "$attempt" "$flow_duration" done exit $status diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index 14efd8ca1d1..beb485fcb37 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -817,20 +817,34 @@ describe('ClerkProvider native client sync', () => { }; const originalUpdateClient = mocks.clerkInstance.updateClient; - // Clerk JS mutates the client resource in place and resolves with that same object, so the - // refreshed client must not be modelled as a distinct object: the foreign-client guard has to - // compare against values captured before the refresh, not a reference that mutated underneath. - const client: Record = { + const client = { id: 'client_1', - signedInSessions: [signedInSession], - lastActiveSessionId: 'session_1', + sessions: [signedInSession], + lastActiveSessionId: 'session_1' as string | null, + get signedInSessions() { + return this.sessions; + }, + __internal_toSnapshot() { + return { + id: this.id, + sessions: this.sessions, + last_active_session_id: this.lastActiveSessionId, + }; + }, + fromJSON(snapshot: { id: string; sessions: (typeof signedInSession)[]; last_active_session_id: string | null }) { + this.id = snapshot.id; + this.sessions = snapshot.sessions; + this.lastActiveSessionId = snapshot.last_active_session_id; + return this; + }, + fetch() { + this.id = 'client_2'; + this.sessions = []; + this.lastActiveSessionId = null; + return Promise.resolve(this); + }, }; - client.fetch = vi.fn().mockImplementation(() => { - client.id = 'client_2'; - client.signedInSessions = []; - client.lastActiveSessionId = null; - return Promise.resolve(client); - }); + const restoreClient = vi.spyOn(client, 'fromJSON'); mocks.clerkInstance.client = client; mocks.clerkInstance.session = signedInSession; @@ -864,12 +878,14 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(client.fetch).toHaveBeenCalled(); + expect(restoreClient).toHaveBeenCalled(); }); - // The refreshed client belongs to a different client id and carries no signed-in sessions, so - // it must be discarded rather than applied over the session JS already has. - expect(originalUpdateClient).not.toHaveBeenCalled(); + expect(client.id).toBe('client_1'); + expect(client.sessions).toEqual([signedInSession]); + expect(client.signedInSessions).toEqual([signedInSession]); + expect(client.lastActiveSessionId).toBe('session_1'); + expect(originalUpdateClient).toHaveBeenCalledWith(client); expect(mocks.clerkInstance.session).toBe(signedInSession); }); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index d1efe9df72b..5c564d92f32 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1,4 +1,4 @@ -import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; +import type { ClientJSONSnapshot, ClientResource, SignedInSessionResource } from '@clerk/shared/types'; import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; @@ -31,6 +31,7 @@ export type SyncableClerkInstance = { type RefreshableClientResource = ClientResource & { fetch?: (options?: { fetchMaxTries?: number }) => Promise; + fromJSON?: (data: ClientJSONSnapshot) => ClientResource; }; type NativeRefreshFromJsOptions = { @@ -222,24 +223,31 @@ function fetchRefreshedJsClient(clerkInstance: SyncableClerkInstance): Promise ClientResource) | null; }; -function snapshotClientIdentity(client: ClientResource | null | undefined): ClientIdentitySnapshot { +function snapshotClientState(client: ClientResource | null | undefined): ClientStateSnapshot { + const resource = client as RefreshableClientResource | undefined; + const fromJSON = resource?.fromJSON?.bind(resource); + let restore: ClientStateSnapshot['restore'] = null; + + if (resource && fromJSON) { + const state = resource.__internal_toSnapshot(); + restore = () => fromJSON(state); + } + return { id: client?.id ?? null, hasSignedInSession: Boolean(client && getDefaultSignedInSession(client)), + restore, }; } -// Refreshing mutates the client in place and resolves with that same object, so this compares -// against values captured before the refresh rather than a reference that mutated underneath. -function isForeignSessionlessClient( - previousSnapshot: ClientIdentitySnapshot, - refreshedClient: ClientResource, -): boolean { +// Client.fetch mutates the resource, so compare against pre-fetch values. +function isForeignSessionlessClient(previousSnapshot: ClientStateSnapshot, refreshedClient: ClientResource): boolean { if (!previousSnapshot.id || !refreshedClient.id || previousSnapshot.id === refreshedClient.id) { return false; } @@ -266,7 +274,7 @@ async function refreshJsClientFromNativeState({ suppressTokenCacheNotificationsRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - const previousClientSnapshot = snapshotClientIdentity(clerkInstance.client); + const previousClientSnapshot = snapshotClientState(clerkInstance.client); const restorePreviousDeviceToken = async () => { if (!rejectForeignSessionlessClient || !shouldSyncDeviceToken || previousDeviceToken === undefined) { @@ -295,6 +303,13 @@ async function refreshJsClientFromNativeState({ if (refreshedClient) { if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClientSnapshot, refreshedClient)) { await restorePreviousDeviceToken(); + const restoredClient = previousClientSnapshot.restore?.(); + if (restoredClient) { + clerkInstance.updateClient?.(restoredClient); + await reconcileJsActiveSessionFromClient({ + clerkInstance, + }); + } return true; }