Skip to content
Open
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
3 changes: 2 additions & 1 deletion .changeset/expo-native-to-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
'@clerk/expo': minor
---

Add `@clerk/expo/native-to-web` to open your web app with the native app's user already signed in, in a WebView.
Add `@clerk/expo/native-to-web` to open your web app with the native app's user already signed in, in a WebView or in the system browser.

- `useAuthenticatedWebView({ destination })` prepares a one-time sign-in link and returns `webViewProps` to spread onto a `react-native-webview` `<WebView>`, plus a `status` of `preparing`, `ready`, `signed-out`, or `error`. Keep your loading overlay up while `status` is `preparing`, so users never see Clerk's sign-in page between your app and the destination. A remount or React Strict Mode reuses a link the WebView has not received yet.
- `useNativeToWeb().openExternalBrowser({ destination })` opens the destination in the default browser. On Android it needs `expo-intent-launcher`, so install it with `npx expo install expo-intent-launcher expo-application`.
- `prepareSessionTransfer({ destination, presentation })` from `@clerk/expo/native-to-web/advanced` returns the link for apps that present it themselves.

Failures reject or report a `SessionTransferError`, which you can detect with `isSessionTransferError()` and branch on through its `code`, such as `signed_out`, `feature_not_enabled`, or `expired`. Session transfers must be enabled for your instance, and `destination` must be on one of your instance's domains.
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"expo-auth-session": "^7.0.11",
"expo-constants": "^18.0.13",
"expo-crypto": "^15.0.9",
"expo-intent-launcher": "^13.0.8",
"expo-local-authentication": "^17.0.8",
"expo-secure-store": "^15.0.8",
"expo-web-browser": "^15.0.11",
Expand All @@ -168,6 +169,7 @@
"expo-auth-session": ">=5",
"expo-constants": ">=12",
"expo-crypto": ">=12",
"expo-intent-launcher": ">=12",
"expo-local-authentication": ">=13.5.0",
"expo-secure-store": ">=12.4.0",
"expo-web-browser": ">=12.5.0",
Expand Down Expand Up @@ -203,6 +205,9 @@
"expo-crypto": {
"optional": true
},
"expo-intent-launcher": {
"optional": true
},
"expo-local-authentication": {
"optional": true
},
Expand Down
2 changes: 1 addition & 1 deletion packages/expo/src/app-version/nativeAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function isExpoApplicationInstalled(): boolean {
return loadExpoApplication() !== null;
}

function loadExpoApplication(): ExpoApplication | null {
export function loadExpoApplication(): ExpoApplication | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('expo-application') as ExpoApplication;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import Module from 'node:module';

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { resolveBrowserLauncher } from '../launchBrowser.android';

const moduleWithLoad = Module as unknown as {
_load: (request: string, parent?: unknown, isMain?: boolean) => unknown;
};
const originalModuleLoad = moduleWithLoad._load;

const mocks = vi.hoisted(() => {
return {
appStateListeners: [] as Array<(state: string) => void>,
removeSubscription: vi.fn(),
startActivityAsync: vi.fn(),
applicationId: 'com.example.app' as string | null,
expoApplicationInstalled: true,
androidPackage: undefined as string | undefined,
};
});

vi.mock('react-native', () => {
return {
Platform: { OS: 'android' },
AppState: {
addEventListener: (_: string, listener: (state: string) => void) => {
mocks.appStateListeners.push(listener);
return { remove: mocks.removeSubscription };
},
},
};
});

const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time';

function pending() {
let resolve: (value: unknown) => void = () => {};
let reject: (reason: unknown) => void = () => {};
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

function changeAppState(state: string) {
mocks.appStateListeners.forEach(listener => listener(state));
}

async function outcome(promise: Promise<void>) {
const settled = await Promise.race([
promise.then(
() => 'resolved',
(error: unknown) => error,
),
new Promise(resolve => setTimeout(() => resolve('pending'), 0)),
]);
return settled;
}

describe('resolveBrowserLauncher on Android', () => {
beforeEach(() => {
mocks.appStateListeners = [];
mocks.removeSubscription.mockReset();
mocks.startActivityAsync.mockReset();
mocks.applicationId = 'com.example.app';
mocks.expoApplicationInstalled = true;
mocks.androidPackage = undefined;
vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => {
if (request === 'expo-intent-launcher') {
return { startActivityAsync: mocks.startActivityAsync };
}
if (request === 'expo-application') {
if (!mocks.expoApplicationInstalled) {
throw new Error("Cannot find module 'expo-application'");
}
return { applicationId: mocks.applicationId };
}
if (request === 'expo-constants') {
return {
default: { executionEnvironment: 'standalone', expoConfig: { android: { package: mocks.androidPackage } } },
};
}
return originalModuleLoad.call(Module, request, parent, isMain);
});
});

afterEach(() => {
vi.restoreAllMocks();
});

test('sends REFERRER_NAME with the application id and leaves the browser choice to Android', async () => {
mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 });

await resolveBrowserLauncher()(TRANSFER_URL);

expect(mocks.startActivityAsync).toHaveBeenCalledWith('android.intent.action.VIEW', {
data: TRANSFER_URL,
extra: { 'android.intent.extra.REFERRER_NAME': 'android-app://com.example.app' },
});
expect(mocks.startActivityAsync.mock.calls[0][1]).not.toHaveProperty('packageName');
});

test('falls back to the package name from the app config without expo-application', async () => {
mocks.expoApplicationInstalled = false;
mocks.androidPackage = 'com.example.config';
mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 });

await resolveBrowserLauncher()(TRANSFER_URL);

expect(mocks.startActivityAsync).toHaveBeenCalledWith(
'android.intent.action.VIEW',
expect.objectContaining({ extra: { 'android.intent.extra.REFERRER_NAME': 'android-app://com.example.config' } }),
);
});

test('throws missing_dependency when no package name is available', () => {
mocks.expoApplicationInstalled = false;

expect(() => resolveBrowserLauncher()).toThrow(
expect.objectContaining({ code: 'missing_dependency', message: expect.stringContaining('expo-application') }),
);
});

test('resolves when the launch settles', async () => {
mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 });

await expect(outcome(resolveBrowserLauncher()(TRANSFER_URL))).resolves.toBe('resolved');
expect(mocks.removeSubscription).toHaveBeenCalled();
});

test('resolves when the app leaves the foreground before the launch settles', async () => {
const launch = pending();
mocks.startActivityAsync.mockReturnValue(launch.promise);
const opening = resolveBrowserLauncher()(TRANSFER_URL);

changeAppState('active');
await expect(outcome(opening)).resolves.toBe('pending');
changeAppState('background');

await expect(outcome(opening)).resolves.toBe('resolved');
expect(mocks.removeSubscription).toHaveBeenCalled();
launch.reject(new Error('activity finished'));
await expect(outcome(opening)).resolves.toBe('resolved');
});

test('rejects with browser_unavailable when the launch fails first', async () => {
mocks.startActivityAsync.mockRejectedValue(new Error(`No Activity found to handle Intent { dat=${TRANSFER_URL} }`));

const result = await outcome(resolveBrowserLauncher()(TRANSFER_URL));

expect(result).toMatchObject({ name: 'SessionTransferError', code: 'browser_unavailable' });
expect(result).not.toHaveProperty('cause');
expect(String(result)).not.toContain('one-time');
expect(mocks.removeSubscription).toHaveBeenCalled();
});
});
Loading
Loading