Repository navigation
fix(clusters): Back leaves the new-cluster page after a saved-draft or Stripe redirect - #1851
dawsontoth wants to merge 1 commit into
Conversation
A saved cluster draft makes the clusters list redirect to the new-cluster form, and that redirect pushed a history entry. Back from the form then landed on the list, a page the user never saw, and while the form was still loading (before it clears the draft) the list redirected straight back to the form, so Back did nothing at all. Redirect in place instead. Billing's Stripe return page had the same shape: it strips the setup-intent secret from its own URL and then pushed the form, so Back from the form landed on a confirm page with no secret, which spins forever. It now replaces itself too. Refs #1326 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request updates the navigation behavior when redirecting from a saved cluster draft or returning from Stripe billing setup to use history replacement (replace: true) instead of pushing to the history stack, preventing back-button redirect loops. Corresponding unit tests and design documentation have been updated. Feedback on the new test file suggests resetting the JSDOM window history in afterEach to prevent test pollution and removing an unused setupIntent property from the hoisted state.
| afterEach(() => { | ||
| cleanup(); | ||
| state.navigate.mockReset(); | ||
| state.stripe.retrieveSetupIntent.mockReset(); | ||
| state.saved = null; | ||
| }); |
There was a problem hiding this comment.
To prevent test pollution and ensure test isolation, reset the JSDOM window history/URL back to the root in the afterEach block. Since returnFromStripe modifies the global window.history state, not resetting it can lead to unexpected side effects or flakiness in other tests running in the same environment.
| afterEach(() => { | |
| cleanup(); | |
| state.navigate.mockReset(); | |
| state.stripe.retrieveSetupIntent.mockReset(); | |
| state.saved = null; | |
| }); | |
| afterEach(() => { | |
| cleanup(); | |
| state.navigate.mockReset(); | |
| state.stripe.retrieveSetupIntent.mockReset(); | |
| state.saved = null; | |
| window.history.pushState(null, '', '/'); | |
| }); |
| const state = vi.hoisted(() => ({ | ||
| navigate: vi.fn(), | ||
| saved: null as unknown, | ||
| setupIntent: {} as { status: string; payment_method: string | null }, | ||
| stripe: { retrieveSetupIntent: vi.fn() }, | ||
| })); |
There was a problem hiding this comment.
⊙ Problem
Back on the new-cluster page could fail to leave it (#1326). The issue guessed "a double navigate without replacement", and that is what it is, though not in
ClusterForm.tsxorclusters/routes.tswhere triage looked. When a cluster draft is saved (Try Again, a billing redirect, the?createCluster=deep link), the clusters list redirects to the form with<Navigate>, and that redirect pushed a history entry. The form clears the draft only once it has mounted and loaded its plans, regions and versions.Reproduced on current
stagein a real browser (Playwright, local dev server, mocked API), going from the org picker to an org with a saved draft:#/org-fixture/new-cluster, so Back did nothing.The normal flows (New Cluster button, Try Again from a card, the billing step, an org with no clusters) already went back correctly and still do.
💡 Solution
The list's draft redirect now replaces its history entry, so Back from the form goes to wherever the user was before the list.
Also fixed, same shape: billing's Stripe return page strips the setup-intent secret from its own URL and then pushed the form (or the cluster edit, or the billing page). Back from the form therefore landed on that confirm page with no secret, which shows a spinner forever because it only processes when a secret is present. It now replaces itself. The secret stays out of the replaced entry: TanStack's hash history builds the href from
window.location.searchat navigation time (@tanstack/historycreateHashHistory), which the existingreplaceStatehas already cleared.🔧 Changes
src/features/clusters/ClustersList.tsx— the saved-draft redirect passesreplace.src/features/organization/billing/confirm/ProcessSetupIntent.tsx—navigateBacknavigates withreplace: true, after the existingreplaceStatethat strips the secret.src/features/clusters/DESIGN.md— records why both redirects must replace.Noticed and not changed here: the list's redirect sends every draft to
new-cluster, including a draft saved from an edit (it carriesclusterId), so such a draft reopens as a create form.ProcessSetupIntentalready routes those to the edit page. Filed as #1852 rather than widened into this PR.Stacked on #1849 (base
claude/1311-guide-region-swap), which is stacked on #1842; retarget tostageas those merge. The change does not touch the lines PR #1780 edits inClustersList.tsx.✅ Verification
Route: live browser repro before and after (real TanStack hash router), plus unit tests pinning the navigation options. The Stripe return path cannot be driven locally (no Stripe key in the dev environment), so that half is verified at the mechanism level only.
#/) with history one entry shorter, and Back during the slow load also returns to#/and stays there.src/features/clusters/ClustersList.test.tsx— theNavigatemock now renders whether it pushes or replaces, and the saved-draft case assertsReplace /org-a/new-cluster.src/features/organization/billing/confirm/ProcessSetupIntent.test.tsx(new) — a return from Stripe with a secret before the hash replaces itself with the new-cluster draft, and the URL it leaves behind carries no secret; a return for an edit draft replaces itself with the edit route.replaceflags removed, all three cases go red; the rest stay green.oxlintclean,dprint check --stagedclean, commitlint ok;npx tsc -bexit 0.Cross-model review: one round, codex (graded, no findings) and gemini. Rejected on evidence: a crash from
'clusterId' in savedClusterStateon a primitive (the code readssavedClusterState.clusterId, which is safe on a string, and that line is unchanged), and that mocked-router tests cannot show the history stack (the browser repro above uses the real router, and thecreateHrefbehaviour is cited from the installed source). The Cursor leg could not fetch over SSH and the Harper domain adjudicator failed authentication, so outside findings were triaged by hand.Closes #1326
🤖 Generated by Anthropic Claude Code (Claude Opus 5.5); posted via @dawsontoth.
🤖 Generated with Claude Code
Related PRs: #1849 overlaps (this branch is stacked on it), #1842 overlaps (base of the stack)
Complexity: easy
Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-composer(no-receipt),domain(auth); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=1; full=1 @ 1a9a81f
Review-Attention: study ~9m (raised: degraded review, open major) @ 1a9a81f