Skip to content

Commit 5dcfe18

Browse files
committed
fix(expo): deduplicate native client startup requests
1 parent 05c8918 commit 5dcfe18

8 files changed

Lines changed: 480 additions & 93 deletions

File tree

.changeset/calm-clients-start.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/expo': patch
3+
---
4+
5+
Reduce redundant native and JavaScript client refreshes during Expo startup.

packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,15 @@ class ClerkExpoModule : Module() {
215215

216216
coroutineScope.launch {
217217
try {
218+
val normalizedBearerToken = bearerToken?.trim()?.takeIf { it.isNotEmpty() }
219+
218220
if (!Clerk.isInitialized.value) {
219221
// First-time initialization — write the bearer token to SharedPreferences
220222
// before initializing so the SDK boots with the correct client.
221-
if (!bearerToken.isNullOrEmpty()) {
223+
if (normalizedBearerToken != null) {
222224
context.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE)
223225
.edit()
224-
.putString("DEVICE_TOKEN", bearerToken)
226+
.putString("DEVICE_TOKEN", normalizedBearerToken)
225227
.apply()
226228
}
227229

@@ -248,7 +250,7 @@ class ClerkExpoModule : Module() {
248250
}
249251
// If a bearer token was provided, wait for native client state to hydrate
250252
// before resolving the configure call.
251-
if (!bearerToken.isNullOrEmpty()) {
253+
if (normalizedBearerToken != null) {
252254
withTimeout(5_000L) {
253255
Clerk.clientFlow.first { it != null }
254256
}
@@ -270,6 +272,7 @@ class ClerkExpoModule : Module() {
270272
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null)
271273
} else {
272274
configuredPublishableKey = pubKey
275+
lastObservedClientState = clientStateSnapshot()
273276
promise.resolve(null)
274277
}
275278
return@launch
@@ -303,10 +306,13 @@ class ClerkExpoModule : Module() {
303306
return@launch
304307
}
305308

306-
if (!bearerToken.isNullOrEmpty()) {
307-
val result = Clerk.updateDeviceToken(bearerToken)
308-
if (result is ClerkResult.Failure) {
309-
debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}")
309+
if (normalizedBearerToken != null) {
310+
val clientState = clientStateSnapshot()
311+
if (clientState.deviceToken != normalizedBearerToken || clientState.client == null) {
312+
val result = Clerk.updateDeviceToken(normalizedBearerToken)
313+
if (result is ClerkResult.Failure) {
314+
debugLog(TAG, "configure - updateDeviceToken after reconfigure failed: ${result.error}")
315+
}
310316
}
311317

312318
try {
@@ -319,17 +325,28 @@ class ClerkExpoModule : Module() {
319325
}
320326

321327
configuredPublishableKey = pubKey
328+
lastObservedClientState = clientStateSnapshot()
322329
promise.resolve(null)
323330
return@launch
324331
}
325332

326333
// Already initialized — use the public SDK API to update
327334
// the device token and trigger a client/environment refresh.
328335
startClientStateObserver()
329-
if (!bearerToken.isNullOrEmpty()) {
330-
val result = Clerk.updateDeviceToken(bearerToken)
336+
if (normalizedBearerToken != null) {
337+
val clientState = clientStateSnapshot()
338+
val result = if (
339+
clientState.deviceToken != normalizedBearerToken ||
340+
clientState.client == null
341+
) {
342+
Clerk.updateDeviceToken(normalizedBearerToken)
343+
} else {
344+
// A remounted JS runtime can have the same token while native
345+
// client state is stale, so preserve one refresh in that case.
346+
Clerk.refreshClient()
347+
}
331348
if (result is ClerkResult.Failure) {
332-
debugLog(TAG, "configure - updateDeviceToken failed: ${result.error}")
349+
debugLog(TAG, "configure - client refresh failed: ${result.error}")
333350
}
334351

335352
// Wait for client state to hydrate with the new token (up to 5s).
@@ -342,6 +359,7 @@ class ClerkExpoModule : Module() {
342359
}
343360
}
344361

362+
lastObservedClientState = clientStateSnapshot()
345363
promise.resolve(null)
346364
} catch (e: Exception) {
347365
promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e)
@@ -382,6 +400,7 @@ class ClerkExpoModule : Module() {
382400
try {
383401
jsOriginatedClientSyncDepth += 1
384402
val previousClientState = clientStateSnapshot()
403+
var refreshedClientWhileUpdatingToken = false
385404

386405
if (didChangeDeviceToken && !deviceToken.isNullOrBlank()) {
387406
val currentDeviceToken = try {
@@ -401,6 +420,7 @@ class ClerkExpoModule : Module() {
401420
return@launch
402421
}
403422
is ClerkResult.Success -> {
423+
refreshedClientWhileUpdatingToken = true
404424
try {
405425
withTimeout(5_000L) {
406426
Clerk.clientFlow.first { it != null }
@@ -413,7 +433,7 @@ class ClerkExpoModule : Module() {
413433
}
414434
}
415435

416-
if (didChangeClient || didChangeDeviceToken) {
436+
if (!refreshedClientWhileUpdatingToken && (didChangeClient || didChangeDeviceToken)) {
417437
when (val result = Clerk.refreshClient()) {
418438
is ClerkResult.Failure -> {
419439
promise.reject(

packages/expo/ios/ClerkNativeBridge.swift

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ final class ClerkNativeBridge {
4848

4949
private var clientObservationGeneration = 0
5050
private var lastObservedClientState: ClientStateSnapshot?
51+
private var configurationDepth = 0
52+
private var jsOriginatedClientSyncDepth = 0
5153

5254
private init() {}
5355

@@ -74,6 +76,12 @@ final class ClerkNativeBridge {
7476

7577
@MainActor
7678
func configure(publishableKey: String, bearerToken: String? = nil) async throws {
79+
configurationDepth += 1
80+
defer {
81+
lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil
82+
configurationDepth = max(0, configurationDepth - 1)
83+
}
84+
7785
loadThemes()
7886

7987
if Self.shouldReconfigure(for: publishableKey) {
@@ -84,16 +92,21 @@ final class ClerkNativeBridge {
8492

8593
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
8694
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
87-
Self.emitClientChangedIfReceivedToken(bearerToken)
8895
Self.postConfiguredNotification()
8996
return
9097
}
9198

9299
if Self.clerkConfigured {
93100
startClientObserver()
94-
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
95-
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
96-
Self.emitClientChangedIfReceivedToken(bearerToken)
101+
let didUpdateDeviceToken = try await Self.syncTokenState(bearerToken: bearerToken)
102+
if didUpdateDeviceToken {
103+
await Self.waitForLoadedClient()
104+
} else if let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
105+
// A remounted JS runtime can have the same token while native client
106+
// state is stale, so preserve one refresh in that case.
107+
_ = try await Clerk.shared.refreshClient()
108+
await Self.waitForLoadedClient()
109+
}
97110
return
98111
}
99112

@@ -104,16 +117,9 @@ final class ClerkNativeBridge {
104117

105118
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
106119
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
107-
Self.emitClientChangedIfReceivedToken(bearerToken)
108120
Self.postConfiguredNotification()
109121
}
110122

111-
@MainActor
112-
private static func emitClientChangedIfReceivedToken(_ bearerToken: String?) {
113-
guard let token = bearerToken, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
114-
Self.emitClientChanged(Self.clientChangedPayload(changes: .init(client: false, deviceToken: true)))
115-
}
116-
117123
@MainActor
118124
private func startClientObserver(reset: Bool = false) {
119125
guard reset || clientObservationGeneration == 0 else {
@@ -139,13 +145,15 @@ final class ClerkNativeBridge {
139145
let newClientState = Self.clientStateSnapshot()
140146
if let previousClientState = self.lastObservedClientState, newClientState != previousClientState {
141147
self.lastObservedClientState = newClientState
142-
let payload = Self.clientChangedPayload(
143-
changes: .init(
144-
client: newClientState.client != previousClientState.client,
145-
deviceToken: newClientState.deviceToken != previousClientState.deviceToken
148+
if self.configurationDepth == 0, self.jsOriginatedClientSyncDepth == 0 {
149+
let payload = Self.clientChangedPayload(
150+
changes: .init(
151+
client: newClientState.client != previousClientState.client,
152+
deviceToken: newClientState.deviceToken != previousClientState.deviceToken
153+
)
146154
)
147-
)
148-
Self.emitClientChanged(payload)
155+
Self.emitClientChanged(payload)
156+
}
149157
}
150158

151159
self.observeClient(generation: generation)
@@ -180,8 +188,14 @@ final class ClerkNativeBridge {
180188

181189
@MainActor
182190
private static func syncTokenState(bearerToken: String?) async throws -> Bool {
183-
guard let token = bearerToken, !token.isEmpty else {
184-
return Clerk.shared.deviceToken != nil
191+
await waitForLoadedClient()
192+
193+
guard let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty
194+
else {
195+
return false
196+
}
197+
guard Clerk.shared.deviceToken != token || Clerk.shared.client == nil else {
198+
return false
185199
}
186200
_ = try await Clerk.shared.updateDeviceToken(token)
187201
return true
@@ -283,15 +297,38 @@ final class ClerkNativeBridge {
283297
guard Self.clerkConfigured else { return }
284298

285299
let previousClientState = Self.clientStateSnapshot()
300+
var completedSuccessfully = false
301+
jsOriginatedClientSyncDepth += 1
302+
defer {
303+
let finalClientState = Self.clientStateSnapshot()
304+
lastObservedClientState = finalClientState
305+
jsOriginatedClientSyncDepth = max(0, jsOriginatedClientSyncDepth - 1)
306+
307+
if !completedSuccessfully, finalClientState != previousClientState {
308+
Self.emitClientChanged(
309+
Self.clientChangedPayload(
310+
changes: .init(
311+
client: finalClientState.client != previousClientState.client,
312+
deviceToken: finalClientState.deviceToken != previousClientState.deviceToken
313+
)
314+
)
315+
)
316+
}
317+
}
318+
319+
var refreshedClientWhileUpdatingToken = false
286320

287-
if didChangeDeviceToken, let token = deviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
321+
if didChangeDeviceToken,
322+
let token = deviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty
323+
{
288324
if Clerk.shared.deviceToken != token {
289325
_ = try await Clerk.shared.updateDeviceToken(token)
290326
await Self.waitForLoadedClient()
327+
refreshedClientWhileUpdatingToken = true
291328
}
292329
}
293330

294-
if didChangeClient || didChangeDeviceToken {
331+
if !refreshedClientWhileUpdatingToken, didChangeClient || didChangeDeviceToken {
295332
_ = try await Clerk.shared.refreshClient()
296333
await Self.waitForLoadedClient()
297334
}
@@ -307,6 +344,7 @@ final class ClerkNativeBridge {
307344
)
308345
)
309346
)
347+
completedSuccessfully = true
310348
}
311349

312350
private static func postConfiguredNotification() {

packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,22 @@ describe('useNativeClientEvents', () => {
6767
unmount();
6868
});
6969

70+
test('subscribes only while native client events are enabled', () => {
71+
const { rerender, unmount } = renderHook(({ enabled }) => useNativeClientEvents(enabled), {
72+
initialProps: { enabled: false },
73+
});
74+
75+
expect(mocks.moduleAddListener).not.toHaveBeenCalled();
76+
77+
rerender({ enabled: true });
78+
expect(mocks.moduleAddListener).toHaveBeenCalledTimes(1);
79+
80+
rerender({ enabled: false });
81+
expect(mocks.remove).toHaveBeenCalledTimes(1);
82+
83+
unmount();
84+
});
85+
7086
test('does not subscribe modules without an Expo event emitter', () => {
7187
mocks.nativeModule = {
7288
configure: vi.fn(),

packages/expo/src/hooks/useNativeClientEvents.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,15 @@ function isNativeClientSnapshot(snapshot: NativeClientSnapshot | undefined): sna
5454
/**
5555
* Listens for native client events that should sync JS client state.
5656
*/
57-
export function useNativeClientEvents(): UseNativeClientEventsReturn {
57+
export function useNativeClientEvents(enabled = true): UseNativeClientEventsReturn {
5858
const [nativeClientEvent, setNativeClientEvent] = useState<NativeClientEvent | null>(null);
5959

6060
useEffect(() => {
61+
if (!enabled) {
62+
setNativeClientEvent(null);
63+
return;
64+
}
65+
6166
if (!isNativeSupported || !ClerkExpo) {
6267
return;
6368
}
@@ -87,7 +92,7 @@ export function useNativeClientEvents(): UseNativeClientEventsReturn {
8792
return () => {
8893
subscription?.remove();
8994
};
90-
}, []);
95+
}, [enabled]);
9196

9297
return {
9398
nativeClientEvent,

packages/expo/src/provider/ClerkProvider.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,15 @@ export function ClerkProvider<TUi extends Ui = Ui>(props: ClerkProviderProps<TUi
9090
: null;
9191

9292
const suppressJsClientChangedRef = useRef(0);
93-
const isMountedRef = useNativeClientBootstrap({
93+
const { isMountedRef, isNativeClientReady } = useNativeClientBootstrap({
9494
publishableKey: pk,
95+
nativeRefreshFromJsControllerRef,
9596
suppressTokenCacheNotificationsRef,
9697
tokenCache: syncableTokenCache,
9798
clerkInstance,
9899
});
99100
useNativeClientEventSync({
101+
enabled: isNativeClientReady,
100102
clerkInstance,
101103
isMountedRef,
102104
nativeRefreshFromJsControllerRef,
@@ -134,6 +136,7 @@ export function ClerkProvider<TUi extends Ui = Ui>(props: ClerkProviderProps<TUi
134136
>
135137
{isNative() && (
136138
<NativeClientSync
139+
enabled={isNativeClientReady}
137140
clerkInstance={clerkInstance}
138141
nativeRefreshFromJsControllerRef={nativeRefreshFromJsControllerRef}
139142
suppressJsClientChangedRef={suppressJsClientChangedRef}

0 commit comments

Comments
 (0)