fix(cloud): upgrade dialog flashes and closes when deleting an environment - #1046
Merged
Merged
Conversation
…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
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
rohan-chaturvedi
approved these changes
Oct 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔍 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:UpgradeDialogis loaded withnext/dynamic, but without aloadingcomponent. In the App Router,next/dynamiconly wraps the lazy component in its own<Suspense>whenloadingis set (orssr: false). Without it, loading the chunk suspended all the way up to the route'sapp/loading.tsxboundary: React hid the whole page (display: noneon 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.dynamic()was called inside the component body, so every render created a brand-new lazy component type. Each open therefore created a freshReact.lazythat suspended again even with the chunk cached (so the flash happened on every attempt, not just the first), and any re-render ofUpsellDialogwhile it was open remountedUpgradeDialog, resetting its state and destroying an embedded Stripe checkout (creating a new Checkout Session each time).💡 Proposed Changes
UpgradeDialogonce at module scope, so its identity is stable across renders.loadingfallback (the sharedSpinner), so the chunk load suspends inside the dialog: the dialog stays open, shows a spinner briefly, then the pricing preview.isCloudHosted(), so self-hosted builds never load the Stripe chunk.frontend/tests/components/UpsellDialog.test.tsx, a single test that runs the real App Router implementation ofnext/dynamicand asserts that an outer Suspense fallback is not rendered while the chunk loads (the dialog shows its own loading indicator instead). It fails onmain.UpsellDialogis the onlynext/dynamicusage 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
After
📝 Release Notes
❓ Open Questions
next/dynamicimports inside dialogs, they need aloadingcomponent (or an explicit<Suspense>) for the same reason.🧪 Testing
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 --noEmitand the full frontend Jest suite pass.APP_HOST=cloud, password auth) against a local Postgres/Redis, with the backend'sstripeSDK calls stubbed (customer, subscription, price, checkout session) andjs.stripe.com/v3replaced by a stub implementinginitEmbeddedCheckout/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.🎯 Reviewer Focus
frontend/components/settings/organisation/UpsellDialog.tsx: the module-scopedynamic()call and itsloadingoption.frontend/tests/components/UpsellDialog.test.tsx: thenext/dynamicmock maps tonext/dist/shared/lib/app-dynamicso the test exercises the App Router behaviour.➕ Additional Context
next/dynamic: a Suspense boundary is only added whenloadingis provided orssr: false(seenext/dist/shared/lib/lazy-dynamic/loadable.js).Dialogcloses 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
APP_HOST=cloud/NEXT_PUBLIC_APP_HOST=cloud) with Stripe test keys and theSTRIPE_*price IDs configured.💚 Did You...