Skip to content

fix(expo): make native client sync client-driven#8929

Merged
wobsoriano merged 2 commits into
mainfrom
mike/expo-client-driven-native-sync
Jun 19, 2026
Merged

fix(expo): make native client sync client-driven#8929
wobsoriano merged 2 commits into
mainfrom
mike/expo-client-driven-native-sync

Conversation

@mikepitre

@mikepitre mikepitre commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • rework Expo JS/native client sync so client and device-token changes are signaled explicitly in both directions
  • refresh JS from native client changes without suppressing the JS client update emit
  • update iOS/Android native module sync APIs and tests around the new client-driven contract

Summary by CodeRabbit

Summary by CodeRabbit

  • Bug Fixes
    • Improved synchronization between native and JavaScript client states in Expo.
    • Device-token updates now propagate more reliably across both platforms, triggering refreshes only when relevant state changes occur.
    • Native client change events now use a clearer “changed” breakdown and will ignore invalid payloads to prevent incorrect updates.
    • Native refresh/recovery behavior is aligned to use the device token consistently, reducing sync gaps and redundant refresh cycles.

@vercel

vercel Bot commented Jun 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jun 19, 2026 2:16pm
swingset Ready Ready Preview, Comment Jun 19, 2026 2:16pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: dad67873-3547-4e20-95ff-5c78a36ce9ff

📥 Commits

Reviewing files that changed from the base of the PR and between 3d49aa7 and 5bfdca0.

📒 Files selected for processing (3)
  • packages/expo/src/provider/ClerkProvider.tsx
  • packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx
  • packages/expo/src/provider/nativeClientSync.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/expo/src/provider/ClerkProvider.tsx
  • packages/expo/src/provider/tests/ClerkProvider.nativeClientSync.test.tsx
  • packages/expo/src/provider/nativeClientSync.tsx

📝 Walkthrough

Walkthrough

Replaces the syncFromJsClientToken native/JS sync API with syncClientStateFromJs across Android (Kotlin), iOS (Swift/ObjC), and TypeScript. The new API carries a deviceToken plus explicit didChangeClient/didChangeDeviceToken boolean flags. Native modules adopt snapshot-based change detection and emit structured changed payloads; the JS orchestration layer is refactored around device-token cache helpers, queued flag merging, and updated refresh/recovery paths.

Changes

Device-token sync contract and bridge migration

Layer / File(s) Summary
Shared JS/native contracts and payload type guards
packages/expo/src/specs/NativeClerkModule.ts, packages/expo/src/specs/NativeClerkModule.android.ts, packages/expo/src/utils/native-module.ts, packages/expo/src/hooks/useNativeClientEvents.ts
TurboModule specs, ClerkExpoNativeModule type, isClerkExpoModule runtime guard, NativeClientSnapshot interface, and isNativeClientSnapshot type guard all change from syncFromJsClientToken/clientToken to syncClientStateFromJs/deviceToken+changed flags.
iOS bridge: ClientStateChanges model and syncClientStateFromJs
packages/expo/ios/ClerkNativeBridge.swift, packages/expo/ios/ClerkExpoModule.swift, packages/expo/ios/ClerkExpoModule.m
ClientStateChanges struct added; clientChangedPayload gains a nested changed dict; observeClient diffs snapshots for per-field emission; syncFromJsClientToken replaced by syncClientStateFromJs in Swift and ObjC bridge exports.
Android: ClientStateSnapshot model and syncClientStateFromJs
packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
ClientStateSnapshot/ClientStateChanges data models introduced; observer diffs snapshots and suppresses echoed events; module-exported async function replaced from syncFromJsClientToken to syncClientStateFromJs with conditional device-token update and flag-driven refresh.
JS nativeClientSync: device-token queues, refresh, and recovery
packages/expo/src/provider/nativeClientSync.tsx, packages/expo/src/provider/ClerkProvider.tsx
Listener type renamed to DeviceTokenCacheListener; cache helpers read/write native device token with polling; native→JS refresh syncs device token before reloading; recovery path switches to recoverJsClientFromNativeDeviceToken; pending options merge didChangeClient/didChangeDeviceToken; JS→native dispatch calls syncClientStateFromJs.
Tests, mocks, and release note
packages/expo/src/hooks/__tests__/*, packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx, packages/expo/src/utils/__tests__/native-module.test.ts, .changeset/client-driven-native-sync.md
All test mocks updated from syncFromJsClientToken to syncClientStateFromJs; event fixtures migrate to changed/deviceToken shape; queueing, recovery, and echo-suppression assertions rewritten for new boolean flags; new client-only-change coverage added; changeset note included.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(173, 216, 230, 0.5)
    Note over JSClient,ClerkNativeBridge: JS → Native sync
    participant JSClient
    participant NativeClientSync
    participant ClerkExpoNativeModule
    participant ClerkNativeBridge
    JSClient->>NativeClientSync: client changed (enqueue didChangeClient=true)
    NativeClientSync->>ClerkExpoNativeModule: syncClientStateFromJs(deviceToken, sourceId, true, false)
    ClerkExpoNativeModule->>ClerkNativeBridge: refreshClient (skip deviceToken update)
    ClerkNativeBridge-->>ClerkExpoNativeModule: updated snapshot
    ClerkExpoNativeModule->>NativeClientSync: clerkNativeClientChanged { changed: {client:true, deviceToken:false}, deviceToken }
  end
  rect rgba(255, 200, 150, 0.5)
    Note over NativeClientSync,JSClient: Native → JS reconciliation
    NativeClientSync->>NativeClientSync: isNativeClientSnapshot guard
    NativeClientSync->>NativeClientSync: syncDeviceTokenToCache (if didChangeDeviceToken)
    NativeClientSync->>NativeClientSync: refreshJsClientFromServer
    NativeClientSync->>JSClient: reconcile active session / reload
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • clerk/javascript#8879: Both PRs modify the Expo native/JS client synchronization surface around the same module bridge and event flow (e.g., ClerkExpoModule/ClerkNativeBridge switching away from refreshClient and wiring a JS→native sync method plus native client-change events), though this PR further evolves it from syncFromJsClientToken/clientToken payloads to syncClientStateFromJs/deviceToken + per-field changed diffs.
  • clerk/javascript#8920: Both PRs modify Expo's native module loading/validation logic (e.g., packages/expo/src/utils/native-module.ts) around which sync method/contract the module must expose (syncFromJsClientToken vs syncClientStateFromJs) and how event-listener methods affect bootstrap, so the changes overlap at the module compatibility/type-guard layer.

Suggested reviewers

  • wobsoriano

Poem

🐇 A hop through tokens old and new,
deviceToken flags now shine right through!
didChangeClient, didChangeDeviceToken — two flags bright,
Snapshots diff and echoes take flight. ✨
No stale client left behind tonight! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: transitioning native client synchronization to a client-driven approach by replacing single-token/refresh-driven interfaces with explicit change-flag propagation in both JS-to-native and native-to-JS directions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@changeset-bot

changeset-bot Bot commented Jun 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5bfdca0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@clerk/expo Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the expo label Jun 19, 2026
Base automatically changed from mike/fix-expo-ios-spm-podspec-linking to main June 19, 2026 10:06
@mikepitre mikepitre marked this pull request as ready for review June 19, 2026 13:18
@pkg-pr-new

pkg-pr-new Bot commented Jun 19, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@8929

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@8929

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@8929

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@8929

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@8929

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@8929

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@8929

@clerk/express

npm i https://pkg.pr.new/@clerk/express@8929

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@8929

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@8929

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@8929

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@8929

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@8929

@clerk/react

npm i https://pkg.pr.new/@clerk/react@8929

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@8929

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@8929

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@8929

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@8929

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@8929

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@8929

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@8929

commit: 5bfdca0

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-06-19T14:18:08.601Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 0
🔴 Breaking changes 0
🟡 Non-breaking changes 0
🟢 Additions 0

No API Changes Detected

All packages have stable APIs with no detected changes.


Report generated by Break Check

Last ran on 5bfdca0.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d49aa77d0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/expo/src/utils/native-module.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/expo/ios/ClerkNativeBridge.swift`:
- Around line 285-290: The code in the `ClerkNativeBridge.swift` file only
handles the case where didChangeDeviceToken is true AND the token is non-empty,
but silently ignores when didChangeDeviceToken is true with a null or empty
token. Add handling for the null/empty token case when didChangeDeviceToken is
true by adding an else condition after the token non-empty check (line 285-290
block) that explicitly clears the device token on Clerk.shared when the token is
null or empty. This ensures that token-clear signals from JS are properly
processed and prevent state drift between JS and native implementations.

In `@packages/expo/src/provider/nativeClientSync.tsx`:
- Around line 64-70: The suppressTokenCacheNotificationsRef is currently a
boolean that can be reset by one pending operation while another is still in
progress, causing unwanted notifications. Convert this from a boolean flag to a
depth counter (change initialization from useRef(false) to useRef(0)) to make it
re-entrant. Update the condition in notifyDeviceTokenListeners where it checks
suppressTokenCacheNotificationsRef.current to instead check if the counter is
greater than 0. Then find all places where
suppressTokenCacheNotificationsRef.current is set to true or false and change
them to increment (++) or decrement (--) the counter instead, matching the
pattern already used by suppressJsClientChangedRef. This ensures that nested or
overlapping cache writes are properly tracked and notifications remain
suppressed until all pending operations complete.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 3245efbe-1cdc-417a-a185-d8099800c0c3

📥 Commits

Reviewing files that changed from the base of the PR and between bfe280a and 3d49aa7.

📒 Files selected for processing (15)
  • .changeset/client-driven-native-sync.md
  • packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
  • packages/expo/ios/ClerkExpoModule.m
  • packages/expo/ios/ClerkExpoModule.swift
  • packages/expo/ios/ClerkNativeBridge.swift
  • packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts
  • packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts
  • packages/expo/src/hooks/useNativeClientEvents.ts
  • packages/expo/src/provider/ClerkProvider.tsx
  • packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx
  • packages/expo/src/provider/nativeClientSync.tsx
  • packages/expo/src/specs/NativeClerkModule.android.ts
  • packages/expo/src/specs/NativeClerkModule.ts
  • packages/expo/src/utils/__tests__/native-module.test.ts
  • packages/expo/src/utils/native-module.ts

Comment on lines +285 to 290
if didChangeDeviceToken, let token = deviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
if Clerk.shared.deviceToken != token {
_ = try await Clerk.shared.updateDeviceToken(token)
await Self.waitForLoadedClient()
}
}

@coderabbitai coderabbitai Bot Jun 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Handle nullable deviceToken when didChangeDeviceToken is true.

At Line 285, the update path only runs for non-empty tokens, but the bridge contract explicitly allows deviceToken: null. If JS signals a token-clear (didChangeDeviceToken=true, deviceToken=null), native keeps the old token and then emits a post-sync snapshot that may report no device-token change, leaving JS/native state drift unresolved.

Please add explicit clear semantics for the null/blank case (or enforce/reject this input consistently across the TS + native contract so this state is never silently ignored).

Also applies to: 292-307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/expo/ios/ClerkNativeBridge.swift` around lines 285 - 290, The code
in the `ClerkNativeBridge.swift` file only handles the case where
didChangeDeviceToken is true AND the token is non-empty, but silently ignores
when didChangeDeviceToken is true with a null or empty token. Add handling for
the null/empty token case when didChangeDeviceToken is true by adding an else
condition after the token non-empty check (line 285-290 block) that explicitly
clears the device token on Clerk.shared when the token is null or empty. This
ensures that token-clear signals from JS are properly processed and prevent
state drift between JS and native implementations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the contract mismatch. I am going to leave this out of this PR and handle it in a follow-up because JS can express a token clear today, but the native SDK integration APIs currently only support nonblank token updates (updateDeviceToken(...) rejects empty/blank tokens on both iOS and Android). Clearing native correctly should be added as a first-class native SDK primitive, then Expo can wire deviceToken: null to that API without reaching into storage internals or doing a broader reset here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikepitre, that context makes complete sense — deferring until a proper native SDK clearDeviceToken() primitive exists is the right call. Reaching into storage internals just to handle the null case here would create a leaky abstraction.

Would you like me to open a GitHub issue to track this follow-up? I can include the relevant context (the JS-to-native contract gap, the updateDeviceToken rejection of blank tokens, and the need for a first-class clear primitive on both iOS and Android) so it's easy to pick up later.


✏️ Learnings added
Learnt from: mikepitre
Repo: clerk/javascript PR: 8929
File: packages/expo/ios/ClerkNativeBridge.swift:285-290
Timestamp: 2026-06-19T14:14:29.078Z
Learning: In `packages/expo/ios/ClerkNativeBridge.swift` and the Android equivalent, the native SDK's `updateDeviceToken(...)` API rejects empty/blank tokens on both iOS and Android. There is no first-class native SDK primitive for clearing the device token. The Expo bridge currently leaves `deviceToken: null` signals silently unhandled (the condition at line 285 only fires for non-empty tokens). A proper `clearDeviceToken()` primitive needs to be added to the native SDK first, after which `packages/expo` can wire `didChangeDeviceToken=true, deviceToken=null` to that new API. This is a known deferred gap as of PR `#8929`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread packages/expo/src/provider/nativeClientSync.tsx

@wobsoriano wobsoriano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested in both iOS and android locally ✅

@wobsoriano wobsoriano merged commit 65b15c0 into main Jun 19, 2026
49 checks passed
@wobsoriano wobsoriano deleted the mike/expo-client-driven-native-sync branch June 19, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants