Skip to content

chore(ci): enforce pro-test bundle freshness — local hook + CI backstop#3229

Merged
koala73 merged 3 commits into
mainfrom
chore/enforce-pro-bundle-freshness
Apr 20, 2026
Merged

chore(ci): enforce pro-test bundle freshness — local hook + CI backstop#3229
koala73 merged 3 commits into
mainfrom
chore/enforce-pro-bundle-freshness

Conversation

@koala73

@koala73 koala73 commented Apr 20, 2026

Copy link
Copy Markdown
Owner

Why

`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:

Without enforcement, this happens again the next time someone touches the pricing page, checkout, or any other pro-test surface.

What

Two-layer guard, mirroring the proto-freshness pattern that already exists in the same hook (`.husky/pre-push:146-178`):

1. `.husky/pre-push` (local fast feedback)

If `pro-test/` changed vs `origin/main`:

  1. Install `pro-test` deps if missing.
  2. `cd pro-test && npm run build`
  3. `git diff --exit-code public/pro/` — block push if dirty.
  4. Fail loudly on untracked files in `public/pro/`.

2. `.github/workflows/pro-bundle-freshness.yml` (CI backstop)

On any PR touching `pro-test/` or `public/pro/`:

  1. Checkout, setup-node 22 with pro-test/package-lock.json cache.
  2. `cd pro-test && npm ci && npm run build`
  3. `git diff --exit-code public/pro/` + untracked-files check.

This catches PRs even if the author bypassed the local hook (`--no-verify`, different machine, etc).

Test plan

  • Merge → on next PR that edits `pro-test/src/` without rebuilding, the new CI check should fail with a clear message.
  • Local: in a branch with a pro-test/src edit but no `public/pro/` rebuild, `git push` should be blocked by the hook.
  • Local: the same branch after `cd pro-test && npm run build && git add public/pro/ && git commit --amend --no-edit` should push cleanly.
  • PRs that don't touch pro-test/ at all should not trigger either check (path filters).

Notes

  • The hook's "diff against origin/main" gate means a fix-forward branch that only touches `public/pro/` (like fix(pro): regenerate /pro bundle so deployed assets reflect #3227 Clerk fix #3228) skips the local build step. CI covers that case via the `public/pro/**` path filter on the workflow trigger.
  • I considered making the hook auto-stage the rebuilt files, but mirroring the existing proto-freshness pattern (block + tell the user the exact commands to run) keeps the diff to one obvious commit and avoids amending behind the dev's back.

…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.
@vercel

vercel Bot commented Apr 20, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Apr 20, 2026 10:38am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a two-layer guard to prevent stale public/pro/ bundles from reaching production: a pre-push hook that rebuilds pro-test and blocks the push if the committed output doesn't match, and a CI workflow that runs the same check as a backstop for bypassed hooks. The implementation correctly mirrors the existing proto-freshness pattern in the hook and uses pinned action SHAs consistent with other workflows in the repo.

Confidence Score: 5/5

Safe to merge — both layers work correctly; the two findings are minor usability and reliability polish that don't affect correctness of the guard itself

All findings are P2. The core logic (diff-based staleness detection, untracked-files check, path-filtered CI trigger) is sound. The stdout-suppression and stale-node_modules issues are real but non-blocking — the guard still catches the primary failure mode described in the PR.

.husky/pre-push — two minor polish items around build output visibility and dep freshness

Important Files Changed

Filename Overview
.husky/pre-push Adds pro-test bundle freshness check mirroring the existing proto-freshness pattern; two minor issues: build stdout is suppressed making failures opaque, and the node_modules presence-only check can use stale deps diverging from CI
.github/workflows/pro-bundle-freshness.yml New CI backstop workflow; correctly pinned action SHAs, minimal permissions, path-filtered trigger, and consistent with other repo workflows — no issues found

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[git push] --> B{pre-push hook}
    B --> C{pro-test/ changed\nvs origin/main?}
    C -- No --> D[Skip — no pro-test changes]
    C -- Yes --> E{node_modules\nexists?}
    E -- No --> F[npm install --prefer-offline]
    E -- Yes --> G[Use existing node_modules]
    F --> H[cd pro-test && npm run build]
    G --> H
    H -- fail --> I[❌ Block push: pro-test build failed]
    H -- ok --> J{git diff public/pro/ dirty?}
    J -- dirty --> K[❌ Block push: public/pro/ is stale]
    J -- clean --> L{Untracked files in public/pro/?}
    L -- yes --> M[❌ Block push: uncommitted bundle files]
    L -- no --> N[✅ Push proceeds]

    subgraph CI [CI Backstop — pro-bundle-freshness.yml]
        O[PR opened/updated] --> P{Touches pro-test/** or public/pro/**?}
        P -- No --> Q[Workflow skipped]
        P -- Yes --> R[actions/checkout + setup-node@22]
        R --> S[npm ci]
        S --> T[npm run build]
        T --> U{git diff public/pro/ dirty?}
        U -- dirty --> V[❌ CI fails: public/pro/ stale]
        U -- clean --> W{Untracked files?}
        W -- yes --> X[❌ CI fails: uncommitted bundle files]
        W -- no --> Y[✅ CI passes]
    end
Loading

Reviews (1): Last reviewed commit: "chore(ci): enforce pro-test bundle fresh..." | Re-trigger Greptile

Comment thread .husky/pre-push
Comment on lines +192 to +195
(cd pro-test && npm run build >/dev/null) || {
echo "ERROR: pro-test build failed"
exit 1
}

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 Build stdout silenced, failures are opaque

>/dev/null discards all stdout from the vite build. If the build fails, the developer only sees "ERROR: pro-test build failed" with no context about why — missing import, type error, OOM, etc. The proto-freshness check directly above (make generate) shows full output. CI also shows full build output. Consider removing >/dev/null or redirecting only to a temp log file printed on failure, to keep parity with the CI experience.

Suggested change
(cd pro-test && npm run build >/dev/null) || {
echo "ERROR: pro-test build failed"
exit 1
}
(cd pro-test && npm run build) || {

Comment thread .husky/pre-push
Comment on lines +188 to +191
if [ ! -d pro-test/node_modules ]; then
echo " Installing pro-test deps..."
(cd pro-test && npm install --prefer-offline) || exit 1
fi

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 Stale node_modules can silently diverge from CI

The hook only installs if pro-test/node_modules is absent. If package.json or package-lock.json changed since the last install (e.g. a new Clerk dep), the local build runs against the old dependency tree, potentially producing different chunk hashes than npm ci in CI. The hook could pass locally while CI fails — or vice versa — without any visible indication. Since the check for a lockfile change already exists elsewhere in this hook (line 39–48 for scripts/), a similar lockfile-drift check here, or replacing npm install with npm ci, would keep local and CI builds in sync.

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.
@koala73

koala73 commented Apr 20, 2026

Copy link
Copy Markdown
Owner Author

Good catch, fixed in `9210b1a71`.

The check now uses `$CHANGED_FILES` (the branch delta computed at line 77 from `git diff origin/main...HEAD`), matching the convention the test-runner gates at lines 93–97 already use. Also honors `$RUN_ALL` as the safety fallback when the branch delta can't be computed.

```diff

  • if git diff --name-only origin/main -- pro-test/ | grep -q .; then
  • if [ "$RUN_ALL" = true ] || echo "$CHANGED_FILES" | grep -q "^pro-test/"; then
    ```

Now an unrelated branch with dirty pro-test/ scratch edits in the worktree will skip the check entirely instead of triggering a slow rebuild. The check only fires when the branch's actual commits touch `pro-test/`.

(Pre-existing note: the proto-freshness check immediately above this one — lines 147–178 — has the same worktree-vs-branch bug. Out of scope for this PR, but worth a follow-up to convert it to the same `$CHANGED_FILES` form for consistency.)

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

koala73 commented Apr 20, 2026

Copy link
Copy Markdown
Owner Author

Fair, fixed in `463f073f4`. Hook trigger now matches the workflow path filter exactly:

```diff

  • if [ "$RUN_ALL" = true ] || echo "$CHANGED_FILES" | grep -q "^pro-test/"; then
  • if [ "$RUN_ALL" = true ] || echo "$CHANGED_FILES" | grep -qE "^(pro-test|public/pro)/"; then
    ```

Now a bundle-only PR (fix-forward rebuild like #3228, or a hand-edit to a committed asset) gets the same local rebuild + diff verification that CI runs. Behavior is identical for genuine source PRs — they hit the `pro-test/` arm — but the `public/pro/` arm closes the gap you flagged.

Skipped-message updated to "no pro-test/ or public/pro/ changes in branch" to reflect both triggers.

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
koala73 merged commit 234ec9b into main Apr 20, 2026
10 checks passed
@koala73
koala73 deleted the chore/enforce-pro-bundle-freshness branch April 20, 2026 11:21
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).
koala73 added a commit that referenced this pull request Apr 20, 2026
Reviewer flagged that the first iteration's `window.top !== window`
check was too broad. The repo explicitly markets "Embeddable iframe
panels" as an Enterprise feature
(pro-test/src/locales/en.json: whiteLabelDesc), so legitimate
customer embeds must keep firing premium RPCs normally. Only the /pro
marketing preview — which is known-anonymous and generates expected
401 noise — should short-circuit.

Fix: replace the blanket iframe check with a unique marker that only
/pro's preview iframe carries.

  - pro-test/src/App.tsx: iframe src switched from
    `?alert=false` (dead param, unused in main app) to
    `?embed=pro-preview`. Rebuilt public/pro/ to ship the change.

  - src/utils/embedded-preview.ts: two-gate check now. Gate 1 still
    requires `window.top !== window` so the marker leaking into a
    top-level URL doesn't disable premium RPCs for the top-level app.
    Gate 2 requires `?embed=pro-preview` in location.search so only
    the known embedder matches. Enterprise white-label embeds without
    this marker behave exactly like a top-level visit.

Same three premium fetchers + the one country-intel path still gate
on IS_EMBEDDED_PREVIEW; the semantic change is purely in how the
flag is computed.

Per PR #3229 / #3228 lesson, the pro-test rebuild ships in the same
PR as the source change — public/pro/assets/index-*.js and index.html
reflect the new iframe src.
koala73 added a commit that referenced this pull request Apr 20, 2026
…review iframe (#3235)

* fix(preview): skip premium RPCs when main app runs inside /pro preview iframe

pro-test/src/App.tsx embeds the full main app as a "live preview"
via <iframe src="https://worldmonitor.app?alert=false" sandbox="...">.
The iframe boots an anonymous main-app session, which fires premium
RPCs (get-regional-snapshot, get-tariff-trends, list-comtrade-flows,
and on country-click the fetchProSections batch) with no Clerk bearer
available. Every call 401s, the circuit breakers catch and fall
through to empty fallbacks (so the preview renders fine), but the
401s surface on the PARENT /pro page's DevTools console and Sentry
because `sandbox` includes `allow-same-origin`.

Net effect: /pro pricing page shows a flood of fake-looking errors
that cost us a session of debugging to trace back to the iframe.
PR #3233's premiumFetch swap didn't help here (there's simply no
token to inject for an anonymous iframe).

Introduce `src/utils/embedded-preview.ts::IS_EMBEDDED_PREVIEW`, a
module-level boolean evaluated once at load from
`window.top !== window` (with try/catch for cross-origin sandboxes),
and short-circuit three init-time premium entry points when true:

  - RegionalIntelligenceBoard.loadCurrent → renderEmpty()
  - fetchTariffTrends → return emptyTariffs
  - fetchComtradeFlows → return emptyComtrade

Plus one defensive gate in country-intel.fetchProSections for the
case a user clicks a country inside the iframe preview.

Each gate returns the exact same empty fallback the breaker would
have produced after a 401, so visual behavior is unchanged — the
preview iframe still shows the dashboard layout with empty premium
panels, just without the network request and its console/Sentry
trail.

Live-tab /pro page should now see zero 401s from regional-snapshot /
tariff-trends / comtrade-flows on load.

* fix(preview): narrow iframe gate to ?embed=pro-preview marker only

Reviewer flagged that the first iteration's `window.top !== window`
check was too broad. The repo explicitly markets "Embeddable iframe
panels" as an Enterprise feature
(pro-test/src/locales/en.json: whiteLabelDesc), so legitimate
customer embeds must keep firing premium RPCs normally. Only the /pro
marketing preview — which is known-anonymous and generates expected
401 noise — should short-circuit.

Fix: replace the blanket iframe check with a unique marker that only
/pro's preview iframe carries.

  - pro-test/src/App.tsx: iframe src switched from
    `?alert=false` (dead param, unused in main app) to
    `?embed=pro-preview`. Rebuilt public/pro/ to ship the change.

  - src/utils/embedded-preview.ts: two-gate check now. Gate 1 still
    requires `window.top !== window` so the marker leaking into a
    top-level URL doesn't disable premium RPCs for the top-level app.
    Gate 2 requires `?embed=pro-preview` in location.search so only
    the known embedder matches. Enterprise white-label embeds without
    this marker behave exactly like a top-level visit.

Same three premium fetchers + the one country-intel path still gate
on IS_EMBEDDED_PREVIEW; the semantic change is purely in how the
flag is computed.

Per PR #3229 / #3228 lesson, the pro-test rebuild ships in the same
PR as the source change — public/pro/assets/index-*.js and index.html
reflect the new iframe src.
koala73 added a commit that referenced this pull request Apr 21, 2026
…users (#3250)

* fix(pro-marketing): reflect auth state in nav, hide pro banner for pro users

Two related signed-in-experience bugs caught by the user during the
post-purchase flow:

1. /pro Navbar's SIGN IN button never reacted to auth state. The
   component was a static const Navbar = () => <nav>...</nav>; with
   no Clerk subscription, so signing in left the SIGN IN button in
   place even though the user was authenticated.

2. The "Pro is launched — Upgrade to Pro" announcement banner on the
   main app showed for ALL visitors including paying Pro subscribers.
   Pitching upgrade to a customer who already paid is a small but
   real annoyance, and it stays sticky for 7 days via the localStorage
   dismiss key — so a returning paying user dismisses it once and
   then never sees the (genuinely useful) banner again if they later
   downgrade.

## Changes

### pro-test/src/App.tsx — useClerkUser hook + ClerkUserButton

- New useClerkUser() hook subscribes to Clerk via clerk.addListener
  and returns { user, isLoaded } so any component can react to auth
  changes (sign-in, sign-out, account switch).
- New ClerkUserButton component mounts Clerk's native UserButton
  widget (avatar + dropdown with profile/sign-out) into a div via
  clerk.mountUserButton — inherits the existing dark-theme appearance
  options from services/checkout.ts::ensureClerk.
- Navbar swaps SIGN IN button for ClerkUserButton when user is
  signed in. Slot is intentionally empty during isLoaded=false to
  avoid a SIGN IN → avatar flicker for returning users.
- Hero hides its redundant SIGN IN CTA when signed in; collapses to
  just "Choose Plan" which is the relevant action for returning users.
- Public/pro/ rebuilt to ship the change (per PR #3229's bundle-
  freshness rule).

### src/components/ProBanner.ts — premium-aware show + reactive auto-hide

- showProBanner returns early if hasPremiumAccess() — same authoritative
  signal used by the frontend's panel-gating layer (unions API key,
  tester key, Clerk pro role, AND Convex Dodo entitlement).
- onEntitlementChange listener auto-dismisses the banner if a Convex
  snapshot arrives mid-session that flips the user to premium (e.g.
  Dodo webhook lands while they're sitting on the dashboard). Does NOT
  write the dismiss timestamp, so the banner reappears correctly if
  they later downgrade.

## Test plan

### pro-test (sign-in UI)

- [ ] Anonymous user loads /pro → SIGN IN button visible in nav.
- [ ] Click SIGN IN, complete Clerk modal → button replaced with
      Clerk's UserButton avatar dropdown.
- [ ] Open dropdown, click Sign Out → reverts to SIGN IN button.
- [ ] Hard reload as signed-in user → SIGN IN button never flashes;
      avatar appears once Clerk loads.

### main app (banner gating)

- [ ] Anonymous user loads / → "Pro is launched" banner shows.
- [ ] Click ✕ to dismiss → banner stays dismissed for 7 days
      (existing behavior preserved).
- [ ] Pro user (active Convex entitlement) loads / → banner does
      NOT appear, regardless of dismiss state.
- [ ] Free user opens /, then completes checkout in another tab and
      Convex publishes the entitlement snapshot → banner auto-hides
      in the dashboard tab without reload.
- [ ] Pro user whose subscription lapses (validUntil < now) → banner
      reappears on next page load, since dismiss timestamp wasn't
      written by the entitlement-change auto-hide.

* fix(pro-banner): symmetric show/hide on entitlement change

Reviewer caught that the previous iteration only handled the upgrade
direction (premium snapshot → hide banner) but never re-showed the
banner on a downgrade. App.ts calls showProBanner() once at init, so
without a symmetric show path, a session that started premium and
then lost entitlement (cancellation, billing grace expiry, plan
downgrade for the same user) would stay banner-less for the rest of
the SPA session — until a full reload re-ran App.ts init.

Net effect of the bug: the comment claiming "the banner reappears
correctly if they later downgrade or the entitlement lapses" was
false in practice for any in-tab transition.

Two changes:

  1. Cache the container on every showProBanner() call, including
     the early-return paths. App.ts always calls showProBanner()
     once at init regardless of premium state, so this guarantees
     the listener has the container reference even when the initial
     mount was skipped (premium user, dismissed, in iframe).

  2. Make onEntitlementChange handler symmetric:
       - premium snapshot + visible → hide (existing behavior)
       - non-premium snapshot + not visible + cached container +
         not dismissed + not in iframe → re-mount via showProBanner

The non-premium re-mount goes through showProBanner() so it gets the
same gate checks as the initial path (isDismissed, iframe, premium).
We can never surface a banner the user has already explicitly ✕'d
this week.

Edge cases handled:
  - User starts premium, no banner shown, downgrades mid-session
    → listener fires, premium false, no bannerEl, container cached,
       not dismissed → showProBanner mounts banner ✓
  - User starts free, sees banner, upgrades mid-session
    → listener fires, premium true, bannerEl present → fade out ✓
  - User starts free, dismisses banner, upgrades, downgrades
    → listener fires on downgrade, premium false, no bannerEl,
       container cached, isDismissed=true → showProBanner returns early ✓
  - User starts free, banner showing, multiple entitlement snapshots
    arrive without state change → premium=false && bannerEl present,
    neither branch fires, idempotent no-op ✓

* fix(pro-banner): defer initial mount while entitlement is loading

Greptile P1 round-2: hasPremiumAccess() at line 48 reads isEntitled()
synchronously, but the Convex entitlement subscription is fired
non-awaited at App.ts:868 (`void initEntitlementSubscription()`).
showProBanner() runs at App.ts:923 during init Phase 1, before the
first Convex snapshot arrives.

So a Convex-only paying user (Clerk role 'free' + Dodo entitlement
tier=1) sees this sequence:

  t=0    init runs → hasPremiumAccess() === false (isEntitled() reads
         currentState===null) → "Upgrade to Pro" banner mounts
  t=~1s  Convex snapshot arrives → onEntitlementChange fires → my
         listener detects premium=true && bannerEl !== null → fade out

That's a 1+ second flash of "you should upgrade!" content for someone
who has already paid. Worst case is closer to ~10s on a cold-start
Convex client, which is much worse — looks like the upgrade pitch is
the actual UI.

Defer the initial mount when (1) the user is signed in (so they
plausibly have a Convex entitlement) AND (2) the entitlement state
hasn't loaded yet (currentState === null). The existing
onEntitlementChange listener will mount it later if the first
snapshot confirms the user is actually free.

Two reasons this is gated on "signed in":
  - Anonymous users will never have a Convex entitlement, so
    deferring would mean the banner NEVER mounts for them. Bad
    regression: anon visitors are the highest-value audience for
    the upgrade pitch.
  - For signed-in users, the worst case if no entitlement EVER
    arrives is the banner stays absent — which is identical to a
    paying user's correct state, so it fails-closed safely.

Edge case behavior:
  - Anonymous user: no Clerk session → first condition false →
    banner mounts immediately ✓
  - Signed-in free user with first snapshot pre-loaded somehow:
    second condition false → banner mounts immediately ✓
  - Signed-in user, snapshot pending: deferred → listener mounts
    on first snapshot if user turns out free ✓
  - Signed-in user, snapshot pending, user turns out premium: never
    mounted ✓ (the desired path)
  - Signed-in user, snapshot pending, never arrives (Convex outage):
    banner never shows → see above, this fails-closed safely
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