Skip to content

fix(pro): switch Clerk to no-rhc bundle so sign-in modal mounts on /pro#3227

Merged
koala73 merged 3 commits into
mainfrom
fix/clerk-headless-ui-bundling-on-pro
Apr 20, 2026
Merged

fix(pro): switch Clerk to no-rhc bundle so sign-in modal mounts on /pro#3227
koala73 merged 3 commits into
mainfrom
fix/clerk-headless-ui-bundling-on-pro

Conversation

@koala73

@koala73 koala73 commented Apr 20, 2026

Copy link
Copy Markdown
Owner

Summary

The /pro marketing page was throwing "Clerk was not loaded with Ui components" the moment an unauthenticated user clicked Sign In or GET STARTED on a pricing tier — blocking every paid conversion. The error was also silent in Sentry, so we had no visibility.

Root cause: @clerk/clerk-js@^6 main export (dist/clerk.mjs) is the headless build — no UI controller. clerk.load() resolves fine, but the next call into any UI surface (openSignIn, openSignUp, mountSignIn, openUserProfile, …) hits an internal assertComponentsReady and throws. The bundled-with-UI variant is exposed at @clerk/clerk-js/no-rhc (same Clerk named export, drop-in).

This works fine when Clerk is loaded via <script> (the UMD bundle includes UI), which is why the bug only surfaces once you bundle through Vite.

Why Sentry was blind:

  • App.tsx swallowed the rejection with .catch(err => console.error(err)) — never reported.
  • checkout.ts:104 called c.openSignIn() unwrapped inside an async function; its synchronous throw rejected the outer promise, but the calling site never .catch()'d it.

Changes

  • `pro-test/src/services/checkout.ts` — switched dynamic import to `@clerk/clerk-js/no-rhc`; wrapped `c.openSignIn()` in try/catch with explicit `Sentry.captureException`.
  • `pro-test/src/App.tsx` — added `Sentry.captureException` in the openSignIn `.catch` handler.

Tagged with `surface: 'pro-marketing'` so future regressions alarm immediately.

Skill extracted: `clerk-js-bundler-headless-ui`.

Test plan

  • Build pro-test (`cd pro-test && npm run build`) and load /pro as an anonymous user
  • Click header SIGN IN → Clerk modal opens (no console error)
  • Click pricing GET STARTED on Pro tier → Clerk modal opens (no console error)
  • Sign in → checkout auto-resumes via `pendingProductId` listener and opens Dodo overlay
  • Verify the bundled chunk for `/pro` includes both `assertComponentsReady` AND UI controller code (proves no-rhc was picked up, not the headless build)
  • Force a sign-in failure (e.g., disconnect network mid-load) and confirm the error now surfaces in Sentry under tag `surface: pro-marketing`

The /pro marketing page was throwing "Clerk was not loaded with Ui
components" the moment an unauthenticated user clicked Sign In or
GET STARTED on a pricing tier, blocking every conversion.

@clerk/clerk-js v6 main export (`dist/clerk.mjs`) is the headless
build — it has no UI controller and expects `clerkUICtor` passed to
`clerk.load()`. Calling `openSignIn()` on it always throws. The
bundled-with-UI variant is exposed at `@clerk/clerk-js/no-rhc`
(same `Clerk` named export, drop-in).

Also adds explicit `Sentry.captureException` at both call sites,
because the rejection was being swallowed by `.catch(console.error)`
in App.tsx and by an unwrapped `c.openSignIn()` in checkout.ts —
which is why this regression had zero Sentry trail in production.
@vercel

vercel Bot commented Apr 20, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 20, 2026 9:44am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes a production-blocking bug on /pro where @clerk/clerk-js's default (headless) Vite bundle throws assertComponentsReady synchronously when any UI surface is called, preventing sign-in and checkout for unauthenticated users. The switch to @clerk/clerk-js/no-rhc and the added Sentry instrumentation are correct.

Confidence Score: 5/5

Safe to merge — core fix is correct and remaining findings are quality/resilience improvements only

The root-cause fix (switching to the no-rhc bundle) is accurate and well-scoped. Both P2 findings (async rejection not awaited, clerkLoadPromise not cleared on failure) are pre-existing patterns or edge cases that don't affect the primary user path; neither introduces a regression.

pro-test/src/services/checkout.ts — two P2 resilience items around error handling

Important Files Changed

Filename Overview
pro-test/src/services/checkout.ts Switches Clerk import to /no-rhc bundle (core fix) and wraps openSignIn() in try/catch with Sentry reporting; two minor issues: async rejections from openSignIn aren't caught, and clerkLoadPromise isn't cleared on rejection
pro-test/src/App.tsx Adds Sentry import and captureException in the openSignIn .catch() handler; correctly uses promise-chain error propagation so both sync and async failures from openSignIn are reported

Sequence Diagram

sequenceDiagram
    actor User
    participant App as App.tsx / PricingSection
    participant CS as checkout.ts
    participant Clerk as Clerk (no-rhc)
    participant Sentry

    User->>App: Click Sign In / GET STARTED
    App->>CS: ensureClerk()
    alt First call
        CS->>Clerk: import('@clerk/clerk-js/no-rhc')
        Clerk-->>CS: Clerk class (UI bundle)
        CS->>Clerk: new Clerk(key) + .load()
        Clerk-->>CS: clerk instance (UI ready)
    end
    CS-->>App: clerk instance
    App->>Clerk: c.openSignIn()
    alt Success
        Clerk-->>User: Sign-in modal
    else Throws (sync) — caught by try/catch or .catch()
        CS->>Sentry: captureException(err)
        App->>Sentry: captureException(err)
    end
    User->>Clerk: Completes sign-in
    Clerk->>CS: addListener fires (user set)
    CS->>CS: doCheckout(pendingProductId)
    CS-->>User: Dodo checkout overlay
Loading

Comments Outside Diff (1)

  1. pro-test/src/services/checkout.ts, line 24-29 (link)

    P2 clerkLoadPromise not cleared on rejection

    If _loadClerk() fails (e.g., transient network error loading the /no-rhc chunk), clerkLoadPromise is permanently set to the rejected promise. Every subsequent call to ensureClerk() hits if (clerkLoadPromise) return clerkLoadPromise and immediately returns the same rejection — effectively breaking all auth/checkout flows for the rest of the page session without any way to retry short of a full reload.

Reviews (1): Last reviewed commit: "fix(pro): switch Clerk to no-rhc bundle ..." | Re-trigger Greptile

Comment on lines +105 to +112
try {
c.openSignIn();
} catch (err) {
console.error('[checkout] Failed to open sign in:', err);
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'checkout-sign-in' } });
pendingProductId = null;
pendingOptions = null;
}

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.

P2 openSignIn() async rejection silently dropped

If c.openSignIn() returns a rejected Promise (an async failure path), the try/catch here won't capture it — only synchronous throws are caught. In contrast, App.tsx's openSignIn() correctly chains .catch() on the returned promise. The primary fix (switching to the no-rhc bundle) eliminates the synchronous throw, but any future async error from openSignIn would create an unhandled rejection and go unreported to Sentry.

Suggested change
try {
c.openSignIn();
} catch (err) {
console.error('[checkout] Failed to open sign in:', err);
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'checkout-sign-in' } });
pendingProductId = null;
pendingOptions = null;
}
try {
await c.openSignIn();
} catch (err) {
console.error('[checkout] Failed to open sign in:', err);
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'checkout-sign-in' } });
pendingProductId = null;
pendingOptions = null;
}

Note: startCheckout is already async, so await here is free.

…gnIn

The PricingSection CTA fires-and-forgets `startCheckout()` with no
.catch. The previous fix only wrapped `c.openSignIn()`, so any
rejection from `await ensureClerk()` (dynamic import failure, network
loss mid-load, clerk.load() throwing) still escaped as an unhandled
promise — defeating the Sentry coverage we added.

Now `startCheckout()` reports both load and openSignIn failures
explicitly and returns false rather than rejecting.

Also clear the cached `clerkLoadPromise` on failure so the next
button click can retry from scratch instead of replaying a rejected
promise forever.
@koala73

koala73 commented Apr 20, 2026

Copy link
Copy Markdown
Owner Author

Good catch — addressed in 70d96d3.

The first fix only wrapped c.openSignIn(), so any rejection from await ensureClerk() (dynamic import 4xx/5xx, network loss mid-load, clerk.load() throwing) still escaped as an unhandled promise from the fire-and-forget startCheckout() call in PricingSection.

startCheckout() now wraps the await ensureClerk() separately with its own Sentry tag (action: 'load-clerk'), and returns false rather than rejecting, so the pricing CTA caller is fully covered.

Also cleared the cached clerkLoadPromise on failure — without that, a transient load failure would poison the singleton and every subsequent button click would replay the same rejected promise instead of retrying.

App.tsx's path is fine as-is: its .then(c => c.openSignIn()).catch(...) chains a single catch that already covers both failure modes.

@koala73

koala73 commented Apr 20, 2026

Copy link
Copy Markdown
Owner Author

This was resolved in the second commit on this PR — 70d96d380 — which landed before this comment. Your checkout may be pointing at the first commit (5b895d4c7), hence the line numbers matching the pre-fix state.

Current pro-test/src/services/checkout.ts on fix/clerk-headless-ui-bundling-on-pro HEAD, lines 103–123:

```ts
let c: InstanceType;
try {
c = await ensureClerk();
} catch (err) {
console.error('[checkout] Failed to load Clerk:', err);
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'load-clerk' } });
return false;
}

if (!c.user) {
pendingProductId = productId;
pendingOptions = options ?? null;
try {
c.openSignIn();
} catch (err) {
console.error('[checkout] Failed to open sign in:', err);
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'checkout-sign-in' } });
pendingProductId = null;
pendingOptions = null;
}
return false;
}
```

Both failure modes are now covered with distinct Sentry tags (`action: 'load-clerk'` vs `action: 'checkout-sign-in'`). `startCheckout` returns `false` rather than rejecting, so the fire-and-forget caller in `PricingSection.tsx` can't drop an unhandled rejection either. A git fetch && git checkout 70d96d380 -- pro-test/src/services/checkout.ts should confirm.

_loadClerk() was assigning the module-level `clerk` singleton before
awaiting `clerk.load()`. If load() rejected (transient network failure,
malformed publishable key, Clerk frontend-api 4xx/5xx), the half-
initialized instance stayed cached. The next ensureClerk() call then
short-circuited on `if (clerk) return clerk;` and returned the broken
instance, bypassing the retry path that commit 70d96d3 added.

Hold the new instance in a local var, await load(), only then publish
to the module slot. A failed load now leaves `clerk` null and the
cleared `clerkLoadPromise` allows a genuine retry on the next click.
@koala73

koala73 commented Apr 20, 2026

Copy link
Copy Markdown
Owner Author

Good catch, this one was real. Fixed in `f530c9c4e`.

`_loadClerk()` was assigning `clerk = new C(key)` before awaiting `clerk.load()`. A rejection from `load()` left the half-initialized instance cached at the module level, and the next call short-circuited on `if (clerk) return clerk;` — bypassing the `clerkLoadPromise = null` retry path from commit `70d96d380` entirely.

Fix: hold the new instance in a local, await `load()`, only then publish to the module slot:

```ts
const instance = new C(key);
await instance.load({ ... });
clerk = instance; // only after success
clerk.addListener(() => { ... }); // safe from here on
return clerk;
```

Now a failed load leaves `clerk` null, `clerkLoadPromise` gets cleared, and the next user click re-enters `_loadClerk()` from scratch.

@koala73
koala73 merged commit 7979b4d into main Apr 20, 2026
10 checks passed
@koala73
koala73 deleted the fix/clerk-headless-ui-bundling-on-pro branch April 20, 2026 09:45
koala73 added a commit that referenced this pull request Apr 20, 2026
…3227

PR #3227 fixed pro-test/src/services/checkout.ts to import
@clerk/clerk-js/no-rhc instead of the headless main export, but the
deployed bundle in public/pro/assets/ was never regenerated. The
Vercel deploy ships whatever is committed under public/pro/ — the
root build script does not run pro-test's vite build — so
production /pro continued serving the old broken clerk-C6kUTNKl.js
even after #3227 merged. Sign-in still threw "Clerk was not loaded
with Ui components".

Rebuild: cd pro-test && npm run build, which writes the new chunks
to ../public/pro/assets/. Deletes the stale clerk-C6kUTNKl.js +
index-J1JYVlDk.js, adds clerk.no-rhc-UeQvd9Xf.js +
index-CFLOgmG-.js, and updates pro/index.html to reference them.
koala73 added a commit that referenced this pull request Apr 20, 2026
…taleness

public/pro/ is committed to the repo and served verbatim by Vercel.
The root build script only runs the main app's vite build — it does
NOT run pro-test's build. So any PR that changes pro-test/src/**
without manually running `cd pro-test && npm run build` and committing
the regenerated chunks ships to production with a stale bundle.

This footgun just cost us: PR #3227 fixed the Clerk "not loaded with
Ui components" sign-in bug in source, merged, deployed — and the live
site still threw the error because the committed chunk under
public/pro/assets/ was the pre-fix build. PR #3228 fix-forwarded by
rebuilding.

Two-layer enforcement so it doesn't happen again:

1. .husky/pre-push — mirrors the existing proto freshness block.
   If pro-test/ changed vs origin/main, rebuild and `git diff
   --exit-code public/pro/`. Blocks the push with a clear message if
   the bundle is stale or untracked files appear.

2. .github/workflows/pro-bundle-freshness.yml — CI backstop on any
   PR touching pro-test/** or public/pro/**. Runs `npm ci + npm run
   build` in pro-test and fails the check if the working tree shows
   any diff or untracked files under public/pro/. Required before
   merge, so bypassing the local hook still can't land a stale
   bundle.

Note: the hook's diff-against-origin/main check means it skips the
build when pushing a branch that already matches main on pro-test/
(e.g. fix-forward branches that only touch public/pro/). CI covers
that case via its public/pro/** path filter.
koala73 added a commit that referenced this pull request Apr 20, 2026
…3227 (#3228)

PR #3227 fixed pro-test/src/services/checkout.ts to import
@clerk/clerk-js/no-rhc instead of the headless main export, but the
deployed bundle in public/pro/assets/ was never regenerated. The
Vercel deploy ships whatever is committed under public/pro/ — the
root build script does not run pro-test's vite build — so
production /pro continued serving the old broken clerk-C6kUTNKl.js
even after #3227 merged. Sign-in still threw "Clerk was not loaded
with Ui components".

Rebuild: cd pro-test && npm run build, which writes the new chunks
to ../public/pro/assets/. Deletes the stale clerk-C6kUTNKl.js +
index-J1JYVlDk.js, adds clerk.no-rhc-UeQvd9Xf.js +
index-CFLOgmG-.js, and updates pro/index.html to reference them.
koala73 added a commit that referenced this pull request Apr 20, 2026
The actual root cause behind the "Clerk was not loaded with Ui
components" sign-in failure on /pro is NOT the import path — it's
that pro-test was on @clerk/clerk-js v6.4.0 while the main app
(which works fine) is on v5.125.7.

Clerk v6 fundamentally changed `clerk.load()`: the UI controller
is no longer auto-mounted by default. Both `@clerk/clerk-js` (the
default v6 entry) and `@clerk/clerk-js/no-rhc` (the bundled-UI
variant) expect the caller to either:
  - load Clerk's UI bundle from CDN and pass `window.__internal_ClerkUICtor`
    to `clerk.load({ ui: { ClerkUI } })`, or
  - manually wire up `clerkUICtor`.

That's why my earlier "switch to no-rhc" fix (PR #3227 + #3228)
didn't actually unbreak production — both v6 variants throw the same
assertion. The error stack on the deployed bundle confirmed it:
`assertComponentsReady` from `clerk.no-rhc-UeQvd9Xf.js`.

Fix: pin pro-test to `@clerk/clerk-js@^5.125.7` to match the main
app's working version. v5 still auto-mounts UI on `clerk.load()` —
no extra wiring needed. The plain `import { Clerk } from '@clerk/clerk-js'`
pattern (which the main app uses verbatim and which pro-test had
before #3227) just works under v5.

Verification of the rebuilt bundle (chunk: clerk-PNSFEZs8.js):
  - 3.05 MB (matches main app's clerk-DC7Q2aDh.js: 3.05 MB)
  - 44 occurrences of mountComponent (matches main: 44)
  - 3 occurrences of SignInComponent (matches main: 3)
  - 0 occurrences of "Clerk was not loaded with Ui" (the assertion
    error string is absent; UI is unconditionally mounted)

Includes the rebuilt public/pro/ artifacts so this fix is actually
deployed (PR #3229's CI check will catch any future PR that touches
pro-test/src without rebuilding).
koala73 added a commit that referenced this pull request Apr 20, 2026
…op (#3229)

* chore(ci): enforce pro-test bundle freshness, prevent silent deploy staleness

public/pro/ is committed to the repo and served verbatim by Vercel.
The root build script only runs the main app's vite build — it does
NOT run pro-test's build. So any PR that changes pro-test/src/**
without manually running `cd pro-test && npm run build` and committing
the regenerated chunks ships to production with a stale bundle.

This footgun just cost us: PR #3227 fixed the Clerk "not loaded with
Ui components" sign-in bug in source, merged, deployed — and the live
site still threw the error because the committed chunk under
public/pro/assets/ was the pre-fix build. PR #3228 fix-forwarded by
rebuilding.

Two-layer enforcement so it doesn't happen again:

1. .husky/pre-push — mirrors the existing proto freshness block.
   If pro-test/ changed vs origin/main, rebuild and `git diff
   --exit-code public/pro/`. Blocks the push with a clear message if
   the bundle is stale or untracked files appear.

2. .github/workflows/pro-bundle-freshness.yml — CI backstop on any
   PR touching pro-test/** or public/pro/**. Runs `npm ci + npm run
   build` in pro-test and fails the check if the working tree shows
   any diff or untracked files under public/pro/. Required before
   merge, so bypassing the local hook still can't land a stale
   bundle.

Note: the hook's diff-against-origin/main check means it skips the
build when pushing a branch that already matches main on pro-test/
(e.g. fix-forward branches that only touch public/pro/). CI covers
that case via its public/pro/** path filter.

* fix(hooks): scope pro-test freshness check to branch delta, not worktree

The first version of this hook used `git diff --name-only origin/main
-- pro-test/`, which compares the WORKING TREE to origin/main. That
fires on unstaged local pro-test/ scratch edits and blocks pushing
unrelated branches purely because of dirty checkout state.

Switch to `$CHANGED_FILES` (computed earlier at line 77 from
`git diff origin/main...HEAD`), which scopes the check to commits on
the branch being pushed. This matches the convention the test-runner
gates already use (lines 93-97). Also honor `$RUN_ALL` as the safety
fallback when the branch delta can't be computed.

* fix(hooks): trigger pro freshness check on public/pro/ too, match CI

The first scoping fix used `^pro-test/` only, but the CI workflow
keys off both `pro-test/**` AND `public/pro/**`. That left a gap: a
bundle-only PR (e.g. a fix-forward rebuild like #3228, or a hand-edit
to a committed asset) skipped the local check entirely while CI would
still validate it. The hook and CI are now consistent.

Trigger condition: `^(pro-test|public/pro)/` — the rebuild + diff
check now fires whenever the branch delta touches either side of the
source/artifact pair, matching the CI workflow's path filter.
koala73 added a commit that referenced this pull request Apr 20, 2026
…3232)

The actual root cause behind the "Clerk was not loaded with Ui
components" sign-in failure on /pro is NOT the import path — it's
that pro-test was on @clerk/clerk-js v6.4.0 while the main app
(which works fine) is on v5.125.7.

Clerk v6 fundamentally changed `clerk.load()`: the UI controller
is no longer auto-mounted by default. Both `@clerk/clerk-js` (the
default v6 entry) and `@clerk/clerk-js/no-rhc` (the bundled-UI
variant) expect the caller to either:
  - load Clerk's UI bundle from CDN and pass `window.__internal_ClerkUICtor`
    to `clerk.load({ ui: { ClerkUI } })`, or
  - manually wire up `clerkUICtor`.

That's why my earlier "switch to no-rhc" fix (PR #3227 + #3228)
didn't actually unbreak production — both v6 variants throw the same
assertion. The error stack on the deployed bundle confirmed it:
`assertComponentsReady` from `clerk.no-rhc-UeQvd9Xf.js`.

Fix: pin pro-test to `@clerk/clerk-js@^5.125.7` to match the main
app's working version. v5 still auto-mounts UI on `clerk.load()` —
no extra wiring needed. The plain `import { Clerk } from '@clerk/clerk-js'`
pattern (which the main app uses verbatim and which pro-test had
before #3227) just works under v5.

Verification of the rebuilt bundle (chunk: clerk-PNSFEZs8.js):
  - 3.05 MB (matches main app's clerk-DC7Q2aDh.js: 3.05 MB)
  - 44 occurrences of mountComponent (matches main: 44)
  - 3 occurrences of SignInComponent (matches main: 3)
  - 0 occurrences of "Clerk was not loaded with Ui" (the assertion
    error string is absent; UI is unconditionally mounted)

Includes the rebuilt public/pro/ artifacts so this fix is actually
deployed (PR #3229's CI check will catch any future PR that touches
pro-test/src without rebuilding).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant