Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clients-recover.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 69 additions & 98 deletions .github/workflows/expo-native-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -66,23 +66,65 @@ 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"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
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 }}
Expand All@@ -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
Expand All@@ -123,125 +173,46 @@ 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 }}
run: |
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 != ''
Expand DownExpand Up@@ -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 }}
Expand All@@ -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"
Expand DownExpand Up@@ -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.
Expand Down
31 changes: 26 additions & 5 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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('');
Expand All@@ -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 ?? '');
Expand Down
3 changes: 2 additions & 1 deletion integration/templates/expo-native/package.sdk-57.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions integration/tests/expo-native/flows/subflows/_warmup.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand Down
12 changes: 1 addition & 11 deletions integration/tests/expo-native/flows/subflows/open-app.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand All@@ -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'
28 changes: 28 additions & 0 deletions integration/tests/expo-native/run-android-flows.sh
Original file line numberDiff line numberDiff line change
@@ -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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clients-recover.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 69 additions & 98 deletions .github/workflows/expo-native-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -66,23 +66,65 @@ 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"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
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 }}
Expand All@@ -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
Expand All@@ -123,125 +173,46 @@ 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 }}
run: |
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 != ''
Expand DownExpand Up@@ -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 }}
Expand All@@ -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"
Expand DownExpand Up@@ -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.
Expand Down
31 changes: 26 additions & 5 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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('');
Expand All@@ -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 ?? '');
Expand Down
3 changes: 2 additions & 1 deletion integration/templates/expo-native/package.sdk-57.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions integration/tests/expo-native/flows/subflows/_warmup.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand Down
12 changes: 1 addition & 11 deletions integration/tests/expo-native/flows/subflows/open-app.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand All@@ -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'
28 changes: 28 additions & 0 deletions integration/tests/expo-native/run-android-flows.sh
Original file line numberDiff line numberDiff line change
@@ -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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clients-recover.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 69 additions & 98 deletions .github/workflows/expo-native-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -66,23 +66,65 @@ 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"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
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 }}
Expand All@@ -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
Expand All@@ -123,125 +173,46 @@ 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 }}
run: |
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 != ''
Expand DownExpand Up@@ -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 }}
Expand All@@ -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"
Expand DownExpand Up@@ -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.
Expand Down
31 changes: 26 additions & 5 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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('');
Expand All@@ -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 ?? '');
Expand Down
3 changes: 2 additions & 1 deletion integration/templates/expo-native/package.sdk-57.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions integration/tests/expo-native/flows/subflows/_warmup.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand Down
12 changes: 1 addition & 11 deletions integration/tests/expo-native/flows/subflows/open-app.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand All@@ -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'
28 changes: 28 additions & 0 deletions integration/tests/expo-native/run-android-flows.sh
Original file line numberDiff line numberDiff line change
@@ -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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clients-recover.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 69 additions & 98 deletions .github/workflows/expo-native-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -66,23 +66,65 @@ 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"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
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 }}
Expand All@@ -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
Expand All@@ -123,125 +173,46 @@ 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 }}
run: |
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 != ''
Expand DownExpand Up@@ -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 }}
Expand All@@ -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"
Expand DownExpand Up@@ -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.
Expand Down
31 changes: 26 additions & 5 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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('');
Expand All@@ -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 ?? '');
Expand Down
3 changes: 2 additions & 1 deletion integration/templates/expo-native/package.sdk-57.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions integration/tests/expo-native/flows/subflows/_warmup.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand Down
12 changes: 1 addition & 11 deletions integration/tests/expo-native/flows/subflows/open-app.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand All@@ -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'
28 changes: 28 additions & 0 deletions integration/tests/expo-native/run-android-flows.sh
Original file line numberDiff line numberDiff line change
@@ -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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clients-recover.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 69 additions & 98 deletions .github/workflows/expo-native-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -66,23 +66,65 @@ 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"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
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 }}
Expand All@@ -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
Expand All@@ -123,125 +173,46 @@ 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 }}
run: |
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 != ''
Expand DownExpand Up@@ -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 }}
Expand All@@ -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"
Expand DownExpand Up@@ -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.
Expand Down
31 changes: 26 additions & 5 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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('');
Expand All@@ -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 ?? '');
Expand Down
3 changes: 2 additions & 1 deletion integration/templates/expo-native/package.sdk-57.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions integration/tests/expo-native/flows/subflows/_warmup.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand Down
12 changes: 1 addition & 11 deletions integration/tests/expo-native/flows/subflows/open-app.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand All@@ -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'
28 changes: 28 additions & 0 deletions integration/tests/expo-native/run-android-flows.sh
Original file line numberDiff line numberDiff line change
@@ -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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clients-recover.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 69 additions & 98 deletions .github/workflows/expo-native-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -66,23 +66,65 @@ 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"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
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 }}
Expand All@@ -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
Expand All@@ -123,125 +173,46 @@ 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 }}
run: |
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 != ''
Expand DownExpand Up@@ -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 }}
Expand All@@ -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"
Expand DownExpand Up@@ -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.
Expand Down
31 changes: 26 additions & 5 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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('');
Expand All@@ -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 ?? '');
Expand Down
3 changes: 2 additions & 1 deletion integration/templates/expo-native/package.sdk-57.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions integration/tests/expo-native/flows/subflows/_warmup.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand Down
12 changes: 1 addition & 11 deletions integration/tests/expo-native/flows/subflows/open-app.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand All@@ -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'
28 changes: 28 additions & 0 deletions integration/tests/expo-native/run-android-flows.sh
Original file line numberDiff line numberDiff line change
@@ -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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clients-recover.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 69 additions & 98 deletions .github/workflows/expo-native-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -66,23 +66,65 @@ 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"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
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 }}
Expand All@@ -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
Expand All@@ -123,125 +173,46 @@ 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 }}
run: |
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 != ''
Expand DownExpand Up@@ -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 }}
Expand All@@ -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"
Expand DownExpand Up@@ -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.
Expand Down
31 changes: 26 additions & 5 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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('');
Expand All@@ -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 ?? '');
Expand Down
3 changes: 2 additions & 1 deletion integration/templates/expo-native/package.sdk-57.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions integration/tests/expo-native/flows/subflows/_warmup.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand Down
12 changes: 1 addition & 11 deletions integration/tests/expo-native/flows/subflows/open-app.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand All@@ -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'
28 changes: 28 additions & 0 deletions integration/tests/expo-native/run-android-flows.sh
Original file line numberDiff line numberDiff line change
@@ -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
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clients-recover.md
Original file line numberDiff line numberDiff line change
@@ -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.
167 changes: 69 additions & 98 deletions .github/workflows/expo-native-build.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -66,23 +66,65 @@ 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"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
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 }}
Expand All@@ -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
Expand All@@ -123,125 +173,46 @@ 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 }}
run: |
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 != ''
Expand DownExpand Up@@ -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 }}
Expand All@@ -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"
Expand DownExpand Up@@ -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.
Expand Down
31 changes: 26 additions & 5 deletions integration/templates/expo-native/components/JsSignInForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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('');
Expand All@@ -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 ?? '');
Expand Down
3 changes: 2 additions & 1 deletion integration/templates/expo-native/package.sdk-57.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions integration/tests/expo-native/flows/subflows/_warmup.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand Down
12 changes: 1 addition & 11 deletions integration/tests/expo-native/flows/subflows/open-app.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture
---
- launchApp:
clearState: true
clearKeychain: true
- extendedWaitUntil:
visible:
id: 'auth-state'
Expand All@@ -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'
28 changes: 28 additions & 0 deletions integration/tests/expo-native/run-android-flows.sh
Original file line numberDiff line numberDiff line change
@@ -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
Loading
Loading