Skip to content

Conversation

@Ephem
Copy link
Contributor

@Ephem Ephem commented Dec 9, 2025

Description

This is the full implementation of PoCs #7194 and #7267.

The main goal of the PR is to refactor the SDKs away from using a top level addListener+state+context combo for auth-state, to using useSyncExternalStore.

  • Merge the ContextProviders from ui and react into shared/react
  • Removes the clerk.addListener() in that provider, also removes most contexts
    • Breaking change: Several context providers like UserContext are no longer exported from shared/react
  • Adds new hooks that subscribes directly to clerk via useSyncExternalStore
    • These "base" hooks, like useUserBase are also reexported as useUserContext to avoid breaking change for these
    • They have the exact same signature, leaving all other hooks untouched
    • We should discuss this, both naming and if we want to do more breaking changes here instead

More implementation notes

  • Fixes bug in useClearQueriesOnSignOut as a conditional hook was causing the new tests to fail
  • Removing initialAuthState from useAuth also fixes a bug where the initial auth state was being used during the transitive state
    • This bug would return null instead of undefined on the page right after sign-in, which put the app in an akward state where it momentarily believed you were still signed out - Could lead to redirects back to /sign-in
  • Renamed this.#setAccessors(); to this.#updateAccessors(); which now calls this.#emit() by default
    • Can pass skipInitialEmit to skip emitting
    • This was done because it's important to not let the clerk state and the app state tear
  • this.#emit()
    • Now saves this.__internal_lastEmittedResources, so useSES can read the last resources emitted in getSnapshot
    • Supports a new skipInitialEmit to avoid emitting and re-rendering on useSES -> subscribe
  • Minor refactors for readability in adjacent code in clerk-js
  • Some type improvements

With all this done, I noticed two timing issues in the bridge between the host React app and the Components app, which is a separate app. Both happened because components that should have unmounted had time to re-render and fire effects, because useSES emitted slightly earlier than setState.

  • In ui/src/Components.tsx, the nodes (current components and which node they should be portaled into) was being set via setState
    • The SignInRoutes has a fallback, which is to redirect back to /sign-in
    • After sign in from password in TanStack Router specifically, SignIn stayed mounted and re-rendered because of useSES, even though the node was gone and the next page had already been rendered
    • This hit the fallback <RedirectToSignIn /> route
    • This was fixed by:
      • Making the nodes a ref instead
      • Adding a useSyncExternalStore where we don't use the return value, to make sure <Components /> re-renders before the child and unmounts it
  • In BaseRouter.tsx, the async baseNavigate was doing setRouteParts as part of the navigation
    • This ensured the state was set before returning to the caller
    • This did not ensure the component had re-rendered as a response to the route change
    • On sign in, useSES in a child ran first and similar to the above case, triggered a redirect back to /sign-in
    • This was fixed by a flushSync to ensure the components had properly re-rendered, guaranteeing setActive does not leave the transitive state before the host app AND the Clerk components are ready

Both of these fixes might be considered controversial, happy for feedback!

Note that the unmount flow has multiple .then() which schedules unmounting into several microtasks even when UI is available. We might want to make this synchronous for even tighter control, but this was not necessary to fix these bugs.

Note also that these bugs result from having multiple React apps. The normal React lifecycle should guarantee they don't happen in host apps.

Background and motivation

Feel free to skip this if you already have familiarity, but as this is a fairly large change, I wanted to write down the full reasoning for this change for posterity.

As I see it, Clerk has different kinds of state. JWT state, piggybacking state and fetched state. Worth noting is that the fetched state has previously used SWR under the hood, but is about to change over to a custom React Query implementation, but both of these already uses useSyncExternalStore and are untouched by this PR.

The JWT state is fairly self-explanatory, and the client piggybacking state is asynchronous state that is included as part of other API-calls as a self-refreshing mechanism. The clerk-js base abstraction updates this for example when it polls for the /tokens, and pushes the changes to the SDKs.

The JWT state and the piggybacking state together represents the foundational "auth state". This represents the user, the active organization etc.

Current solution

clerk-js has an addListener method. The React SDKs subscribes to this in an effect in their <ClerkProvider> and sets an internal state on every update. This state is then massaged a bit and placed on a few different contexts, and the different hooks, like useAuth, useUser etc read from these contexts.

<ClerkProvider> also supports passing in an initialState. If this is passed in, you can access it during SSR, but because there is no good way to prefetch the piggybacking or fetched state currently, only the useAuth state is really feasible to server render.

In Next, doing <ClerkProvider dynamic> automatically provides the initialState for useAuth behind the scenes. The reason we don't always do this is that it opts out of static generation and caching.

Current challenges and issues

Subscribing at the top has a few downsides however. It's currently hard to make things more streamy by allowing initialState to be represented as a promise, as we currently need to await it at the top. This is something we want to do to support cacheComponents and Partial prerendering better, allowing for finer-grained caching. This also opens up for making things suspenseful.

It also doesn't play well with concurrent rendering. A concrete example of this is an active bug that can happen when one subtree suspends long enough during page load that clerk-js has time to load. When it unsuspends, it reads the loaded auth state and not the initial one, causing hydration mismatches.

Another downside is how context is hard to use for fine grained updates, making it hard to implement things like selectors on top of it.

useSyncExternalStore is the canonical way to safely subscribe to an external store, and has none of these downsides.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Summary by CodeRabbit

  • New Features

    • Optional listener subscribe flag to skip initial emission; last-emitted auth/session snapshot exposed.
  • Refactor

    • Hooks and providers reworked to derive auth/org state consistently from initial/server state.
    • Public API surface trimmed (several legacy context exports removed); useAuth now receives options rather than an initialAuthState.
  • Tests

    • Added integration tests and demo page for organization-switching transitions.

✏️ Tip: You can customize this high-level summary in your review settings.

Ephem added 30 commits November 10, 2025 14:35
user: this.user,
organization: this.organization,
};
this.__internal_lastEmittedResources = resources;
Copy link
Member

Choose a reason for hiding this comment

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

clever!

With useSyncExternalStore, children components were re-rendering first, before this component had a chance to re-render and unmount them. This could cause situations where components that should already have been unmounted affected an unrelated page because their useEffect ran.
Rename and move file from AuthContext to useAuthBase
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Some of this is identical to the deleted AuthContext.ts, but git couldn't track it.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
.changeset/full-parents-crash.md (1)

31-31: Minor grammar fix: use hyphen for compound adjective.

When "signed out" functions as a compound adjective modifying "state", use a hyphen: "signed-out state" instead of "signed out state".

🔎 Apply this diff:
- * A bug where `useAuth` would sometimes briefly return the `initialState` rather than `undefined` - * This could in certain situations incorrectly lead to a brief `user: null` on the first page after signing in, indicating a signed out state + * A bug where `useAuth` would sometimes briefly return the `initialState` rather than `undefined` + * This could in certain situations incorrectly lead to a brief `user: null` on the first page after signing in, indicating a signed-out state
📜 Review details

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

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0c4909f and 598c13a.

📒 Files selected for processing (2)
  • .changeset/full-parents-crash.md (1 hunks)
  • packages/shared/src/types/clerk.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/shared/src/types/clerk.ts
🧰 Additional context used
🪛 LanguageTool
.changeset/full-parents-crash.md

[grammar] ~31-~31: Use a hyphen to join words.
Context: ...ge after signing in, indicating a signed out state * Hydration mismatches in cert...

(QB_NEW_EN_HYPHEN)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (27)
  • GitHub Check: Integration Tests (nextjs, chrome, 15)
  • GitHub Check: Integration Tests (nextjs, chrome, 16, RQ)
  • GitHub Check: Integration Tests (quickstart, chrome, 16)
  • GitHub Check: Integration Tests (quickstart, chrome, 15)
  • GitHub Check: Integration Tests (billing, chrome, RQ)
  • GitHub Check: Integration Tests (machine, chrome)
  • GitHub Check: Integration Tests (nextjs, chrome, 16)
  • GitHub Check: Integration Tests (nuxt, chrome)
  • GitHub Check: Integration Tests (react-router, chrome)
  • GitHub Check: Integration Tests (ap-flows, chrome)
  • GitHub Check: Integration Tests (machine, chrome, RQ)
  • GitHub Check: Integration Tests (billing, chrome)
  • GitHub Check: Integration Tests (vue, chrome)
  • GitHub Check: Integration Tests (custom, chrome)
  • GitHub Check: Integration Tests (handshake:staging, chrome)
  • GitHub Check: Integration Tests (astro, chrome)
  • GitHub Check: Integration Tests (tanstack-react-start, chrome)
  • GitHub Check: Integration Tests (handshake, chrome)
  • GitHub Check: Integration Tests (sessions:staging, chrome)
  • GitHub Check: Integration Tests (express, chrome)
  • GitHub Check: Integration Tests (localhost, chrome)
  • GitHub Check: Integration Tests (sessions, chrome)
  • GitHub Check: Integration Tests (generic, chrome)
  • GitHub Check: Formatting | Dedupe | Changeset
  • GitHub Check: Build Packages
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: semgrep-cloud-platform/scan
🔇 Additional comments (1)
.changeset/full-parents-crash.md (1)

1-32: Changelog entry accurately reflects PR scope.

The changeset clearly documents the migration to useSyncExternalStore with appropriate version bumps, well-articulated breaking changes, clear migration guidance, new features, and bug fixes. Content aligns with PR objectives and the AI summary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment