Skip to content

Commit aefa9f1

Browse files
committed
Merge main into mike/expo-native-sync-engine
2 parents 9b00d5d + 56f6356 commit aefa9f1

13 files changed

Lines changed: 123 additions & 125 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
Fix the OrganizationProfile sidebar to hide API keys when the user has neither `org:sys_api_keys:read` nor `org:sys_api_keys:manage` permission.

‎packages/expo/src/provider/__tests__/nativeClientSyncBehavior/environment.tsx‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { TokenCache } from '../../../cache/types';
44
import type { FakeClerk } from './fakeClerk';
55

66
type BehaviorEnvironment = {
7-
js: FakeClerk | null;
7+
getClerkInstance: ((options?: unknown) => FakeClerk) | null;
88
nativeModule: unknown;
99
};
1010

@@ -13,21 +13,20 @@ const registryKey = Symbol.for('@clerk/expo/nativeClientSyncBehavior');
1313
const globalRegistry = globalThis as typeof globalThis & { [registryKey]?: BehaviorEnvironment };
1414

1515
export function activeEnvironment(): BehaviorEnvironment {
16-
globalRegistry[registryKey] ??= { js: null, nativeModule: null };
16+
globalRegistry[registryKey] ??= { getClerkInstance: null, nativeModule: null };
1717
return globalRegistry[registryKey];
1818
}
1919

2020
export function setActiveEnvironment(environment: BehaviorEnvironment): void {
2121
globalRegistry[registryKey] = environment;
2222
}
2323

24-
export function getClerkInstanceForActiveEnvironment(options?: { tokenCache?: TokenCache }): FakeClerk {
25-
const { js } = activeEnvironment();
26-
if (!js) {
24+
export function getClerkInstanceForActiveEnvironment(options?: unknown): FakeClerk {
25+
const { getClerkInstance } = activeEnvironment();
26+
if (!getClerkInstance) {
2727
throw new Error('No native client sync behavior environment is active.');
2828
}
29-
js.tokenCache = options?.tokenCache;
30-
return js;
29+
return getClerkInstance(options);
3130
}
3231

3332
/** Stands in for `InternalClerkProvider`, which loads the Clerk instance after mounting. */
@@ -38,7 +37,6 @@ export function LoadingClerkProvider({ children, Clerk }: { children?: ReactNode
3837
return <>{children}</>;
3938
}
4039

41-
/** A persistent token cache such as one backed by `expo-secure-store`. */
4240
export class FakePersistentTokenCache implements TokenCache {
4341
#values = new Map<string, string>();
4442

‎packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeClerk.ts‎

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import type { TokenCache } from '../../../cache/types';
2-
import { CLERK_CLIENT_JWT_KEY } from '../../../constants';
3-
import type { ClientTokenCache } from '../../nativeClientSync';
41
import type { FakeClerkServer, FakeServerClientJSON, FapiResponse } from './fakeServer';
52

3+
type FakeRequestInit = { url: URL; headers: Headers };
4+
type BeforeRequestHook = (requestInit: FakeRequestInit) => unknown;
5+
type AfterResponseHook = (requestInit: FakeRequestInit, response: { headers: Headers; payload: null }) => unknown;
6+
67
export type FakeSessionResource = {
78
id: string;
89
status: 'active';
@@ -72,39 +73,54 @@ export class FakeClientResource {
7273

7374
/**
7475
* Stand-in for the clerk-js instance returned by `getClerkInstance`, with the same surface the existing
75-
* ClerkProvider tests mock. It talks to the fake Frontend API through the token cache it was built with,
76-
* mirroring the `__internal_onBeforeRequest` / `__internal_onAfterResponse` hooks in `createClerkInstance`,
77-
* including the compare-and-set write against the token the request used.
76+
* ClerkProvider tests mock. Each fake Frontend API request runs through the `__internal_onBeforeRequest` /
77+
* `__internal_onAfterResponse` hooks the real `createClerkInstance` registers.
7878
*/
7979
export class FakeClerk {
8080
loaded = false;
8181
status = 'loading';
8282
client: FakeClientResource | undefined = undefined;
8383
session: FakeSessionResource | null = null;
8484
__internal_setActiveInProgress = false;
85-
/** Every state emitted to Clerk listeners (what `useAuth` consumers observe). */
8685
readonly emissions: JsEmission[] = [];
87-
tokenCache: (TokenCache & Partial<ClientTokenCache>) | undefined;
86+
#beforeRequestHooks: BeforeRequestHook[] = [];
87+
#afterResponseHooks: AfterResponseHook[] = [];
8888
#listeners: ListenerCallback[] = [];
8989
#statusListeners = new Set<StatusListener>();
9090
#onLoadedListeners: Array<() => void> = [];
9191
#loading: Promise<void> | null = null;
9292

9393
constructor(readonly server: FakeClerkServer) {}
9494

95+
__internal_onBeforeRequest(hook: BeforeRequestHook): void {
96+
this.#beforeRequestHooks.push(hook);
97+
}
98+
99+
__internal_onAfterResponse(hook: AfterResponseHook): void {
100+
this.#afterResponseHooks.push(hook);
101+
}
102+
95103
async fapi<T extends FapiResponse>(request: (token: string | null) => Promise<T>): Promise<T> {
96-
const token = (await this.tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
97-
const response = await request(token);
98-
if (response.token) {
99-
await (this.tokenCache?.saveClientToken
100-
? this.tokenCache.saveClientToken(response.token, token)
101-
: this.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, response.token));
104+
const requestInit = await this.#prepareRequest();
105+
const response = await request(requestInit.headers.get('authorization') || null);
106+
const headers = new Headers(response.token ? { authorization: response.token } : {});
107+
for (const hook of this.#afterResponseHooks) {
108+
await hook(requestInit, { headers, payload: null });
102109
}
103110
return response;
104111
}
105112

106113
async currentDeviceToken(): Promise<string | null> {
107-
return (await this.tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null;
114+
const requestInit = await this.#prepareRequest();
115+
return requestInit.headers.get('authorization') || null;
116+
}
117+
118+
async #prepareRequest(): Promise<FakeRequestInit> {
119+
const requestInit = { url: new URL('https://fapi.clerk.test/v1/client'), headers: new Headers() };
120+
for (const hook of this.#beforeRequestHooks) {
121+
await hook(requestInit);
122+
}
123+
return requestInit;
108124
}
109125

110126
load = (): Promise<void> => {
@@ -201,8 +217,6 @@ export class FakeClerk {
201217
this.#emit();
202218
};
203219

204-
// JS user actions.
205-
206220
async signIn(userId: string): Promise<string> {
207221
const response = await this.fapi(token => this.server.signIn('js', token, userId));
208222
this.#applyPiggybackedClient(response);

‎packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeNativeClerk.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ export class FakeNativeClerk {
2323
token: string | null;
2424
client: FakeServerClientJSON | null = null;
2525
isConfigured = false;
26-
/** Times native fetched its client from the Frontend API. */
2726
clientRefreshCount = 0;
2827
#listeners = new Set<NativeClientChangeListener>();
2928
#heldChanges: Array<[NativeClientChange, NativeChangeOrigin]> | null = null;
@@ -50,7 +49,6 @@ export class FakeNativeClerk {
5049
return () => this.#listeners.delete(listener);
5150
}
5251

53-
/** Native keeps its change notifications queued until `releaseChangeNotifications`. */
5452
holdChangeNotifications(): void {
5553
this.#heldChanges ??= [];
5654
}
@@ -63,7 +61,6 @@ export class FakeNativeClerk {
6361
}
6462
}
6563

66-
/** Native stays in its startup phase until the returned release function is called. */
6764
delayStartup(): () => void {
6865
let release!: () => void;
6966
this.#startupGate = new Promise(resolve => {
@@ -76,8 +73,6 @@ export class FakeNativeClerk {
7673
this.#nextRefreshError = error;
7774
}
7875

79-
// Primitives used by engine adapters.
80-
8176
async waitForStartup(): Promise<void> {
8277
await this.#startupGate;
8378
}
@@ -107,8 +102,6 @@ export class FakeNativeClerk {
107102
}
108103
}
109104

110-
// Native user actions (native UI components, native-only flows).
111-
112105
async signIn(userId: string): Promise<string> {
113106
const response = await this.#observe(() => this.server.signIn('native', this.token, userId));
114107
return response.sessionId;

‎packages/expo/src/provider/__tests__/nativeClientSyncBehavior/fakeServer.ts‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,6 @@ export class FakeClerkServer {
8787
await networkRoundTrip();
8888
}
8989

90-
// Seeding helpers (no request is logged).
91-
9290
seedClient({ users = [], activeUser }: { users?: string[]; activeUser?: string } = {}): {
9391
token: string;
9492
clientId: string;
@@ -127,8 +125,6 @@ export class FakeClerkServer {
127125
.length;
128126
}
129127

130-
// Frontend API
131-
132128
async getClient(caller: FapiCaller, token: string | null): Promise<FapiResponse> {
133129
await this.#request(caller, 'GET /client', token);
134130
return this.#respond(token, this.#clientFor(token));

‎packages/expo/src/provider/__tests__/nativeClientSyncBehavior/harness.ts‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,18 @@ import type { FakeNativeClerk } from './fakeNativeClerk';
44
import type { FakeClerkServer } from './fakeServer';
55

66
export type NativeSyncScenario = {
7-
/** Shared Frontend API; seed it before rendering to start from existing clients. */
87
server?: FakeClerkServer;
98
/**
109
* Device token in an app-provided persistent token cache. When omitted (and `persistentTokenCache`
1110
* is not set), ClerkProvider runs with its default in-memory token cache.
1211
*/
1312
jsDeviceToken?: string | null;
1413
persistentTokenCache?: boolean;
15-
/** Device token the native SDK has persisted from a previous launch. */
1614
nativeDeviceToken?: string | null;
1715
nativeModule?: 'present' | 'absent';
1816
/** The native SDK fails to configure, e.g. a native build that cannot initialize Clerk. */
1917
failNativeConfigure?: boolean;
2018
disableNativeClientSync?: boolean;
21-
/** Keep the native SDK in its startup phase until `releaseNativeStartup` is called. */
2219
delayNativeStartup?: boolean;
2320
strictMode?: boolean;
2421
};
@@ -31,20 +28,11 @@ export type NativeSyncHarness = {
3128
server: FakeClerkServer;
3229
native: FakeNativeClerk;
3330
js: FakeClerk;
34-
/** The app-provided persistent token cache, when the scenario uses one. */
3531
persistentTokenCache: FakePersistentTokenCache | null;
36-
/** The device token the next JS Frontend API request authenticates with. */
3732
jsDeviceToken: () => Promise<string | null>;
38-
/** Names of native module methods the engine has called. */
3933
nativeModuleCalls: string[];
40-
/** Runs a world action (JS or native) inside `act`. */
4134
run: <T>(action: () => T | Promise<T>) => Promise<T>;
42-
/** Lets in-flight requests, notifications, and renders drain. */
4335
settle: () => Promise<void>;
44-
/** What biometric flows do before a native operation: wait until native reflects JS. */
45-
awaitJsToNativeSync: () => Promise<void>;
46-
/** What biometric flows do after a native operation: bring JS up to date with native. */
47-
pullNativeToJs: () => Promise<void>;
4836
releaseNativeStartup: () => void;
4937
unmount: () => void;
5038
};
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// Swap this export to run the behavior suite against a different sync engine.
21
export { renderWithNativeSync } from './newEngineAdapter';
32
export { FakeClerkServer } from './fakeServer';
43
export type { NativeSyncHarness, NativeSyncScenario } from './harness';

‎packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx‎

Lines changed: 6 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -275,17 +275,15 @@ describe('native client sync behavior', () => {
275275
await expectConverged(h);
276276
});
277277

278-
it('surfaces a failed native refresh through the sync barrier until a later refresh succeeds', async () => {
279-
const { server, token } = seedDevice(['user_1']);
278+
it('converges after a failed native refresh', async () => {
279+
const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
280280
const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
281-
const failure = new Error('native refresh failed');
282281

283-
h.native.failNextClientRefresh(failure);
284-
await h.run(() => h.js.updateProfile());
285-
await expect(h.run(() => h.awaitJsToNativeSync())).rejects.toBe(failure);
282+
h.native.failNextClientRefresh(new Error('native refresh failed'));
283+
await h.run(() => h.js.setActive({ session: sessionIds.user_2 }));
284+
await h.settle();
286285

287-
await h.run(() => h.js.updateProfile());
288-
await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined();
286+
expect(h.native.activeSessionId).toBe(sessionIds.user_2);
289287
await expectConverged(h);
290288
});
291289

@@ -370,7 +368,6 @@ describe('native client sync behavior', () => {
370368
expect(await h.jsDeviceToken()).toBe(rotatedToken);
371369
expect(h.native.token).toBe(token);
372370
expect(h.nativeModuleCalls.filter(call => call !== 'configureNative' && call !== 'addListener')).toEqual([]);
373-
await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined();
374371
});
375372

376373
it('mirrors the shared device token into the app token cache', async () => {
@@ -397,64 +394,6 @@ describe('native client sync behavior', () => {
397394
});
398395
});
399396

400-
describe('explicit synchronization barrier (biometric flows)', () => {
401-
it('lets a native operation observe the latest JS state and JS observe the native result', async () => {
402-
const { server, token, sessionIds } = seedDevice(['user_1', 'user_2'], 'user_1');
403-
const h = await start({ server, jsDeviceToken: token, nativeDeviceToken: token });
404-
405-
await h.run(async () => {
406-
await h.js.setActive({ session: sessionIds.user_2 });
407-
await h.awaitJsToNativeSync();
408-
});
409-
expect(h.native.activeSessionId).toBe(sessionIds.user_2);
410-
411-
h.native.holdChangeNotifications();
412-
await h.run(async () => {
413-
await h.native.setActiveSession(sessionIds.user_1);
414-
await h.pullNativeToJs();
415-
});
416-
expect(h.js.session?.id).toBe(sessionIds.user_1);
417-
418-
await h.run(() => h.native.releaseChangeNotifications());
419-
await h.settle();
420-
expect(h.js.session?.id).toBe(sessionIds.user_1);
421-
await expectConverged(h);
422-
});
423-
424-
it('lets JS observe a session created by a native sign-in once the pull resolves', async () => {
425-
const h = await start();
426-
427-
await h.run(() => h.awaitJsToNativeSync());
428-
h.native.holdChangeNotifications();
429-
const sessionId = await h.run(async () => {
430-
const createdSessionId = await h.native.signIn('user_1');
431-
await h.pullNativeToJs();
432-
return createdSessionId;
433-
});
434-
435-
expect(h.js.client?.signedInSessions.map(session => session.id)).toContain(sessionId);
436-
expect(h.js.session?.id).toBe(sessionId);
437-
});
438-
439-
it('rejects with environment_unavailable when native does not finish a refresh in time', async () => {
440-
const h = await start({ delayNativeStartup: true });
441-
await h.run(() => h.js.signIn('user_1'));
442-
443-
vi.useFakeTimers();
444-
try {
445-
const barrier = expect(h.awaitJsToNativeSync()).rejects.toMatchObject({ code: 'environment_unavailable' });
446-
await vi.advanceTimersByTimeAsync(5_000);
447-
await barrier;
448-
} finally {
449-
vi.useRealTimers();
450-
}
451-
452-
await h.run(() => h.releaseNativeStartup());
453-
await expect(h.run(() => h.awaitJsToNativeSync())).resolves.toBeUndefined();
454-
await expectConverged(h);
455-
});
456-
});
457-
458397
describe('without native client sync', () => {
459398
it.each([
460399
['sync is disabled', { disableNativeClientSync: true }],

‎packages/expo/src/provider/__tests__/nativeClientSyncBehavior/newEngineAdapter.tsx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Clerk } from '@clerk/clerk-js';
12
import { act, render, waitFor } from '@testing-library/react';
23
import { StrictMode } from 'react';
34
import { vi } from 'vitest';
@@ -100,19 +101,20 @@ export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {})
100101
const releaseNativeStartup = scenario.delayNativeStartup ? native.delayStartup() : () => undefined;
101102
const nativeModuleCalls: string[] = [];
102103

104+
// Fresh engine modules per scenario: the native module is resolved at import time and the native
105+
// configuration, active engine and Clerk instance are module singletons.
106+
vi.resetModules();
107+
const { createClerkInstance } = await import('../../singleton/createClerkInstance');
103108
setActiveEnvironment({
104-
js,
109+
getClerkInstance: createClerkInstance(function () {
110+
return js;
111+
} as unknown as typeof Clerk) as unknown as (options?: unknown) => FakeClerk,
105112
nativeModule:
106113
scenario.nativeModule === 'absent'
107114
? null
108115
: createNativeModule(native, nativeModuleCalls, { failConfigure: !!scenario.failNativeConfigure }),
109116
});
110-
111-
// Fresh engine modules per scenario: the native module is resolved at import time and the native
112-
// configuration and active engine are module singletons.
113-
vi.resetModules();
114117
const { ClerkProvider } = await import('../../ClerkProvider');
115-
const { idle, pullFromNative } = await import('../../nativeClientSync');
116118

117119
const provider = (
118120
<ClerkProvider
@@ -153,8 +155,6 @@ export const renderWithNativeSync: RenderWithNativeSync = async (scenario = {})
153155
return result;
154156
},
155157
settle,
156-
awaitJsToNativeSync: () => idle(),
157-
pullNativeToJs: () => pullFromNative(),
158158
releaseNativeStartup,
159159
unmount: () => view.unmount(),
160160
};

0 commit comments

Comments
 (0)