diff --git a/src/features/clusters/ClustersList.test.tsx b/src/features/clusters/ClustersList.test.tsx
index 6b20eaa08..f9fe02e3e 100644
--- a/src/features/clusters/ClustersList.test.tsx
+++ b/src/features/clusters/ClustersList.test.tsx
@@ -22,7 +22,7 @@ vi.mock(
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ organizationId: 'org-a' }),
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => {children},
- Navigate: ({ to }: { to: string }) =>
Navigate {to}
,
+ Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {replace ? 'Replace' : 'Push'} {to}
,
}));
vi.mock('@/components/SubNavMenu', () => ({ SubNavMenu: () => null }));
vi.mock(
@@ -109,10 +109,10 @@ describe('ClustersList', () => {
expect(screen.getByRole('link', { name: 'New Cluster' }).getAttribute('href')).toBe('/org-a/new-cluster');
});
- it('preserves a saved cluster creation redirect', () => {
+ it('redirects a saved cluster draft in place, so Back skips the page that redirected (#1326)', () => {
state.clusters = [cluster('Production', 'RUNNING', 'US East')];
state.saved = { name: 'Draft' };
render();
- expect(screen.getByText('Navigate /org-a/new-cluster')).toBeTruthy();
+ expect(screen.getByText('Replace /org-a/new-cluster')).toBeTruthy();
});
});
diff --git a/src/features/clusters/ClustersList.tsx b/src/features/clusters/ClustersList.tsx
index d54f9d117..6f55a7e03 100644
--- a/src/features/clusters/ClustersList.tsx
+++ b/src/features/clusters/ClustersList.tsx
@@ -58,7 +58,7 @@ export function ClustersList() {
return ;
}
if (savedClusterState) {
- return ;
+ return ;
}
return (
diff --git a/src/features/clusters/DESIGN.md b/src/features/clusters/DESIGN.md
index ecea1d316..16bb0b7b1 100644
--- a/src/features/clusters/DESIGN.md
+++ b/src/features/clusters/DESIGN.md
@@ -2,6 +2,11 @@
`lib/clusterListModel.ts` derives lifecycle categories, counts and filters from the same organization snapshot; its tests pin missing-data and self-hosted semantics. A running lifecycle is not a monitoring-health assertion. Regions combine resolved plan labels with available non-retired instance labels, deduplicating matches, because organization responses need not expand instances; the list resolves opaque ids through the existing organization-scoped region catalog once, without polling. Missing labels remain unreported without discarding other reported instance regions. Running classification uses the same activeClusterStatuses rule as card navigation; UPDATED remains attention until RUNNING is reported. The model still derives instance count and version when instance data is supplied, but the card no longer renders them: the organization response does not currently expand instances, so those two cells only ever showed a dash, and the Regions cell (real plan labels) went with the row. The list adds no per-card detail polling. Existing `ClusterProgress` retains its lifecycle polling behavior.
+A saved cluster draft makes the list redirect to the form, and that redirect replaces its history entry (#1326). The
+form clears the draft only once it mounts, so a pushed redirect left Back on the list, which pushed the form again for
+as long as the form was still loading. Billing's Stripe return page replaces itself on the way back to the form for the
+same reason: it strips the setup-intent secret from its own URL first, so going back to it would show a spinner forever.
+
SystemStatus notices have no cluster association and remain in the cloud-wide notification banner/center. Per-cluster monitoring incidents and scheduled maintenance need a scoped backend contract before the list can attribute them. The UI preserves ClusterCard's permission checks and lifecycle actions.
# Cluster configuration
diff --git a/src/features/organization/billing/confirm/ProcessSetupIntent.test.tsx b/src/features/organization/billing/confirm/ProcessSetupIntent.test.tsx
new file mode 100644
index 000000000..bcc16d7a0
--- /dev/null
+++ b/src/features/organization/billing/confirm/ProcessSetupIntent.test.tsx
@@ -0,0 +1,55 @@
+/** @vitest-environment jsdom */
+import { cleanup, render, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ProcessSetupIntent } from './ProcessSetupIntent';
+
+const state = vi.hoisted(() => ({
+ navigate: vi.fn(),
+ saved: null as unknown,
+ setupIntent: {} as { status: string; payment_method: string | null },
+ stripe: { retrieveSetupIntent: vi.fn() },
+}));
+vi.mock('@stripe/react-stripe-js', () => ({ useStripe: () => state.stripe }));
+vi.mock('@tanstack/react-router', () => ({
+ useNavigate: () => state.navigate,
+ useParams: () => ({ organizationId: 'org-a' }),
+ useSearch: () => ({}),
+}));
+vi.mock('@/hooks/useLocalStorage', () => ({ useLocalStorage: () => [state.saved] }));
+vi.mock('@/integrations/stripe/useProcessStripePaymentMethod', () => {
+ const process = (_paymentMethod: string, onProcessed: () => void) => onProcessed();
+ return { useProcessStripePaymentMethod: () => process };
+});
+
+afterEach(() => {
+ cleanup();
+ state.navigate.mockReset();
+ state.stripe.retrieveSetupIntent.mockReset();
+ state.saved = null;
+});
+
+function returnFromStripe(setupIntent: { status: string; payment_method: string | null }) {
+ window.history.pushState(null, '', '/?setup_intent_client_secret=seti_secret#/org-a/billing/confirm');
+ state.stripe.retrieveSetupIntent.mockResolvedValue({ setupIntent });
+ render();
+}
+
+describe('ProcessSetupIntent', () => {
+ it('replaces itself with the cluster draft, so Back from the form skips this page (#1326)', async () => {
+ state.saved = { clusterName: 'Draft' };
+ returnFromStripe({ status: 'succeeded', payment_method: 'pm_card' });
+
+ await waitFor(() => expect(state.navigate).toHaveBeenCalled());
+ expect(state.navigate).toHaveBeenCalledWith({ search: undefined, to: '../../new-cluster', replace: true });
+ expect(window.location.search).toBe('');
+ expect(window.location.hash).toBe('#/org-a/billing/confirm');
+ });
+
+ it('replaces itself on the way back to a cluster edit too', async () => {
+ state.saved = { clusterId: 'clu-a' };
+ returnFromStripe({ status: 'processing', payment_method: null });
+
+ await waitFor(() => expect(state.navigate).toHaveBeenCalled());
+ expect(state.navigate).toHaveBeenCalledWith({ search: undefined, to: '../../clu-a/edit', replace: true });
+ });
+});
diff --git a/src/features/organization/billing/confirm/ProcessSetupIntent.tsx b/src/features/organization/billing/confirm/ProcessSetupIntent.tsx
index c6e804062..3cd766cc4 100644
--- a/src/features/organization/billing/confirm/ProcessSetupIntent.tsx
+++ b/src/features/organization/billing/confirm/ProcessSetupIntent.tsx
@@ -32,7 +32,7 @@ export function ProcessSetupIntent() {
: '../../new-cluster'
: '../';
window.history.replaceState(null, '', currentUrlIncludingHash());
- void navigate({ search: undefined, to });
+ void navigate({ search: undefined, to, replace: true });
}, [navigate, savedClusterState]);
useEffect(() => {