Skip to content

fix(clusters): Back leaves the new-cluster page after a saved-draft or Stripe redirect - #1851

Draft
dawsontoth wants to merge 1 commit into
claude/1311-guide-region-swapfrom
claude/1326-new-cluster-back-button
Draft

dawsontoth wants to merge 1 commit into
claude/1311-guide-region-swapfrom
claude/1326-new-cluster-back-button

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

⊙ 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.tsx or clusters/routes.ts where 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 stage in a real browser (Playwright, local dev server, mocked API), going from the org picker to an org with a saved draft:

  • Back from the form landed on the clusters list, a page the user never saw, instead of the picker (history held one entry too many).
  • Back pressed while the form was still loading (plans response delayed 4s) landed on the list, which still had the draft and pushed the form again. The page stayed on #/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.

❓ Your call: is the requirement right as filed? Yes as far as it goes: redirects should replace. I kept the Stripe return page fix (below) in this PR because it ends on the same form with the same mistake; it is one flag and can be split out if you prefer.

💡 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.search at navigation time (@tanstack/history createHashHistory), which the existing replaceState has already cleared.

🔧 Changes

Noticed and not changed here: the list's redirect sends every draft to new-cluster, including a draft saved from an edit (it carries clusterId), so such a draft reopens as a create form. ProcessSetupIntent already 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 to stage as those merge. The change does not touch the lines PR #1780 edits in ClustersList.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.

Cross-model review: one round, codex (graded, no findings) and gemini. Rejected on evidence: a crash from 'clusterId' in savedClusterState on a primitive (the code reads savedClusterState.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 the createHref behaviour 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

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>

@gemini-code-assist gemini-code-assist Bot left a comment

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.

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.

Comment on lines +24 to +29
afterEach(() => {
cleanup();
state.navigate.mockReset();
state.stripe.retrieveSetupIntent.mockReset();
state.saved = null;
});

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.

medium

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.

Suggested change
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, '', '/');
});

Comment on lines +6 to +11
const state = vi.hoisted(() => ({
navigate: vi.fn(),
saved: null as unknown,
setupIntent: {} as { status: string; payment_method: string | null },
stripe: { retrieveSetupIntent: vi.fn() },
}));

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.

medium

The setupIntent property in the hoisted state is defined but never used in any of the tests. It can be safely removed to keep the test setup clean.

const state = vi.hoisted(() => ({
	navigate: vi.fn(),
	saved: null as unknown,
	stripe: { retrieveSetupIntent: vi.fn() },
}));

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