fix(pro): switch Clerk to no-rhc bundle so sign-in modal mounts on /pro#3227
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryFixes a production-blocking bug on Confidence Score: 5/5Safe 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
Sequence DiagramsequenceDiagram
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
|
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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.
|
Good catch — addressed in 70d96d3. The first fix only wrapped
Also cleared the cached App.tsx's path is fine as-is: its |
|
This was resolved in the second commit on this PR — Current ```ts if (!c.user) { 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 |
_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.
|
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 Now a failed load leaves `clerk` null, `clerkLoadPromise` gets cleared, and the next user click re-enters `_loadClerk()` from scratch. |
…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.
…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.
…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.
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).
…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.
…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).
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@^6main 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 internalassertComponentsReadyand throws. The bundled-with-UI variant is exposed at@clerk/clerk-js/no-rhc(sameClerknamed 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.tsxswallowed the rejection with.catch(err => console.error(err))— never reported.checkout.ts:104calledc.openSignIn()unwrapped inside an async function; its synchronous throw rejected the outer promise, but the calling site never.catch()'d it.Changes
Tagged with `surface: 'pro-marketing'` so future regressions alarm immediately.
Skill extracted: `clerk-js-bundler-headless-ui`.
Test plan