Skip to content

fix(cloud): upgrade dialog flashes and closes when deleting an environment - #1046

Merged
nimish-ks merged 3 commits into
mainfrom
fix/free-tier-upsell-dialog-flash
Oct 4, 2026
Merged

nimish-ks merged 3 commits into
mainfrom
fix/free-tier-upsell-dialog-flash

Conversation

@nimish-ks

@nimish-ks nimish-ks commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

🔍 Overview

On Phase Cloud, when an owner/admin of a Free organisation opens an environment's manage dialog and clicks Delete, the "Upgrade to Pro to customize environments" dialog flashes and disappears (together with the manage dialog) instead of letting them start a trial. The same component backs every other upsell dialog in the console, so the first open of any of them after a page load was affected too.

Root cause, in frontend/components/settings/organisation/UpsellDialog.tsx:

  1. UpgradeDialog is loaded with next/dynamic, but without a loading component. In the App Router, next/dynamic only wraps the lazy component in its own <Suspense> when loading is set (or ssr: false). Without it, loading the chunk suspended all the way up to the route's app/loading.tsx boundary: React hid the whole page (display: none on every committed node, including both open dialogs) and showed the full-page spinner. Headless UI's dialog has a "close when the dialog disappears" watcher (useOnDisappear), which then closed the manage dialog and the upsell dialog. When the chunk arrived, the page came back without them.
  2. dynamic() was called inside the component body, so every render created a brand-new lazy component type. Each open therefore created a fresh React.lazy that suspended again even with the chunk cached (so the flash happened on every attempt, not just the first), and any re-render of UpsellDialog while it was open remounted UpgradeDialog, resetting its state and destroying an embedded Stripe checkout (creating a new Checkout Session each time).

💡 Proposed Changes

  • Create the dynamic UpgradeDialog once at module scope, so its identity is stable across renders.
  • Give it a loading fallback (the shared Spinner), so the chunk load suspends inside the dialog: the dialog stays open, shows a spinner briefly, then the pricing preview.
  • Rendering is still gated by isCloudHosted(), so self-hosted builds never load the Stripe chunk.
  • Add frontend/tests/components/UpsellDialog.test.tsx, a single test that runs the real App Router implementation of next/dynamic and asserts that an outer Suspense fallback is not rendered while the chunk loads (the dialog shows its own loading indicator instead). It fails on main.

UpsellDialog is the only next/dynamic usage in the frontend, so there is nothing else to update.

🖼️ Screenshots or Demo

Observed with a scripted Chromium session against a local cloud-mode console with Stripe stubbed (details under Testing). Timings are from the moment Delete is clicked in the manage dialog of a free-tier environment.

Before

Time What happens
+50 ms The whole page, including the open manage dialog, is replaced by the route's full-screen spinner (both dialog panels collapse to 0x0, body scroll lock released).
+340 ms The chunk has loaded and the page is back, but the manage dialog is already closing.
+1.2 s The upsell dialog closes too. Nothing is left open; no checkout was ever reached.

After

Time What happens
+70 ms The upsell dialog opens stacked over the manage dialog, showing a spinner inside the dialog. The page behind is untouched.
+340 ms The pricing preview replaces the spinner. Both dialogs stay open.
click "Start 14-day trial" The embedded checkout mounts ~160 ms later and stays mounted, with typed input intact, for the full 30 s observation window. Exactly one Checkout Session was created on the backend.

📝 Release Notes

  • Fixed: on the Free plan, the "Upgrade to Pro" dialog no longer flashes and closes itself when you try to delete an environment (or open any other upgrade prompt for the first time). The embedded checkout also keeps its state while you fill it in instead of being reset.

❓ Open Questions

  • None. If we add more next/dynamic imports inside dialogs, they need a loading component (or an explicit <Suspense>) for the same reason.

🧪 Testing

  • New: frontend/tests/components/UpsellDialog.test.tsx (1 test, see above). Verified it fails on the previous code and passes with the fix.
  • yarn lint, tsc --noEmit and the full frontend Jest suite pass.
  • End-to-end, locally, before and after the fix: console running in cloud mode (APP_HOST=cloud, password auth) against a local Postgres/Redis, with the backend's stripe SDK calls stubbed (customer, subscription, price, checkout session) and js.stripe.com/v3 replaced by a stub implementing initEmbeddedCheckout/mount/destroy. A scripted Chromium session signed up, created an organisation and app, opened the Development environment's manage dialog, clicked Delete, then "Start 14-day trial", and watched the result for 30 s. Before the fix the dialogs closed as described above; after the fix the stubbed checkout mounted inside the dialog and survived, with one Checkout Session created.
  • Not covered: a run against real Stripe test mode, and browsers other than Chromium.

🎯 Reviewer Focus

  • frontend/components/settings/organisation/UpsellDialog.tsx: the module-scope dynamic() call and its loading option.
  • frontend/tests/components/UpsellDialog.test.tsx: the next/dynamic mock maps to next/dist/shared/lib/app-dynamic so the test exercises the App Router behaviour.

➕ Additional Context

  • Next.js App Router next/dynamic: a Suspense boundary is only added when loading is provided or ssr: false (see next/dist/shared/lib/lazy-dynamic/loadable.js).
  • Headless UI Dialog closes itself when its element disappears from layout (useOnDisappear), which is what turned the page-level fallback into closed dialogs.

✨ How to Test the Changes Locally

  1. Run the console in cloud mode (APP_HOST=cloud / NEXT_PUBLIC_APP_HOST=cloud) with Stripe test keys and the STRIPE_* price IDs configured.
  2. As the owner of a Free organisation, open an app, hover an environment card and click its cog, then click Delete.
  3. Expected: the "Upgrade to Pro to customize environments" dialog opens over the manage dialog, shows a spinner briefly, then the pricing preview. Click Start 14-day trial: the embedded Stripe checkout appears and stays open while you type.
  4. Reload the page and repeat: the first open after a reload used to be the failing case.

💚 Did You...

  • Ensure linting passes (code style checks)?
  • Update dependencies and lockfiles (if required) - not required
  • Update migrations (if required) - not required
  • Regenerate graphql schema and types (if required) - not required
  • Verify the app builds locally?
  • Manually test the changes on different browsers/devices? - Chromium only (scripted), see Testing

claude added 2 commits October 3, 2026 13:54
…render

UpsellDialog called next/dynamic() inside its render body, so every
re-render produced a new UpgradeDialog component type. React then
unmounted and remounted UpgradeDialog, which reset its billingPeriod
state and destroyed the embedded Stripe checkout.

On pages that re-render regularly (the app environments page polls
secrets every 10s, and the organisation context polls every 10s) this
made the free-tier "Delete environment" upsell flash the checkout and
then fall back to the pricing preview before it could be completed,
creating an orphaned Checkout Session each time.

Hoist the dynamic import to module scope so the component identity is
stable across renders. The isCloudHosted() check still gates rendering,
so self-hosted builds never load the Stripe chunk.

Adds a regression test that re-renders the parent after the upgrade
dialog has moved to the checkout step and asserts it is not remounted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWBBra2rNh14NPTmdCifA5
…hole page

Hoisting the dynamic import fixed the remount, but on the first open after
a page load the upgrade dialog still disappeared: in the App Router,
next/dynamic only wraps the lazy component in a Suspense boundary when
`loading` is set (or ssr is false). Without it, loading the chunk
suspended up to the route's loading.tsx, React hid the whole page behind
the page spinner (including the open dialogs), and Headless UI's
disappear watcher then closed both dialogs. On the free tier that is the
"flash" users saw when clicking Delete on an environment.

Give the dynamic import a `loading` fallback so the suspension stays
inside the dialog, which now shows a small spinner for the chunk load and
then the pricing preview. Extend the regression test to assert that an
outer Suspense fallback is not rendered while the chunk loads, and run it
against the App Router implementation of next/dynamic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWBBra2rNh14NPTmdCifA5
@nimish-ks nimish-ks self-assigned this Oct 4, 2026
@nimish-ks nimish-ks changed the title fix: upgrade dialog flashes and closes when deleting an environment on the Free plan fix(cloud): upgrade dialog flashes and closes when deleting an environment Oct 4, 2026
Drop the explanatory comment block above the dynamic import and reduce
the UpsellDialog test to the single case that matters: the lazily loaded
UpgradeDialog chunk must not suspend above the dialog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWBBra2rNh14NPTmdCifA5
@nimish-ks
nimish-ks merged commit 0e4d838 into main Oct 4, 2026
15 checks passed
@nimish-ks
nimish-ks deleted the fix/free-tier-upsell-dialog-flash branch October 4, 2026 07:21
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.

3 participants