Repository navigation
test(cards): pin a second card click during a pending navigation - #1858
dawsontoth wants to merge 1 commit into
Conversation
#1301 reported repeated ids in the URL after clicking a sign-in link again while the first navigation was still loading. A card click calls router.navigate with the card's own target, and TanStack resolves a relative target against the pending location, so a second click on an organization card produced /org-a/org-a and a second Direct Sign In from a cluster card produced /org-a/clu-b/sign-in/clu-b/sign-in. The absolute targets on this branch fix both. Render the real cards under a real router whose next route never finishes loading and click twice. On stage all three cases fail with exactly those URLs. Closes #1301 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new test suite, cardLinksDuringPendingNavigation.test.tsx, to verify navigation behavior when organization or cluster cards are clicked while a previous navigation is still loading. The review feedback highlights several improvement opportunities: stabilizing the mock references for useLocalStorage and useTerminateClusterMutation to prevent unnecessary re-renders, instantiating QueryClient outside of the JSX render call to maintain a stable reference, and removing redundant act wrappers around fireEvent in the test helper functions.
| })); | ||
| vi.mock('@/config/useInstanceClient', () => ({ useInstanceClient: () => ({}) })); | ||
| vi.mock('@/features/auth/store/authStore', () => ({ authStore: { checkForFabricConnect: () => false } })); | ||
| vi.mock('@/hooks/useLocalStorage', () => ({ useLocalStorage: () => [null, vi.fn()] })); |
There was a problem hiding this comment.
The mock for useLocalStorage returns a new array containing a new mock function reference (vi.fn()) on every invocation. Since the setter function returned by useLocalStorage is used in a dependency array, this unstable reference will cause unnecessary recreations. To ensure a stable reference, define a stable mock function outside the factory. Additionally, to guard against unstable references, include a test assertion that re-renders the component without changing anything and verifies that the effect does not re-run.
const mockSetSavedClusterState = vi.fn();
vi.mock('@/hooks/useLocalStorage', () => ({ useLocalStorage: () => [null, mockSetSavedClusterState] }));
References
- When mocking a hook whose return value is used in a
useEffectdependency array, ensure the mock returns a stable object reference (e.g., by instantiating the object once in the mock factory and using getters for dynamic properties). To guard against unstable references, include a test assertion that re-renders the component without changing anything and verifies that the effect does not re-run.
| vi.mock( | ||
| '@/features/clusters/mutations/terminateCluster', | ||
| () => ({ useTerminateClusterMutation: () => ({ mutate: vi.fn(), isPending: false }) }), | ||
| ); |
There was a problem hiding this comment.
The mock for useTerminateClusterMutation returns a new object with a new mutate function reference on every call. Since terminateCluster is used in a dependency array, this unstable reference will cause unnecessary recreations. We should use a stable mock reference to prevent this. Additionally, to guard against unstable references, include a test assertion that re-renders the component without changing anything and verifies that the effect does not re-run.
| vi.mock( | |
| '@/features/clusters/mutations/terminateCluster', | |
| () => ({ useTerminateClusterMutation: () => ({ mutate: vi.fn(), isPending: false }) }), | |
| ); | |
| const mockTerminate = vi.fn(); | |
| vi.mock( | |
| '@/features/clusters/mutations/terminateCluster', | |
| () => ({ useTerminateClusterMutation: () => ({ mutate: mockTerminate, isPending: false }) }), | |
| ); |
References
- When mocking a hook whose return value is used in a
useEffectdependency array, ensure the mock returns a stable object reference (e.g., by instantiating the object once in the mock factory and using getters for dynamic properties). To guard against unstable references, include a test assertion that re-renders the component without changing anything and verifies that the effect does not re-run.
| render( | ||
| <QueryClientProvider client={new QueryClient()}> | ||
| <RouterProvider router={router as never} /> | ||
| </QueryClientProvider>, | ||
| ); |
There was a problem hiding this comment.
Instantiating new QueryClient() inline inside the render call of renderRouter creates a new QueryClient instance on every render of the wrapper. It is a best practice to instantiate the QueryClient outside of the JSX to ensure a stable reference and avoid wiping out the cache if the wrapper component re-renders.
| render( | |
| <QueryClientProvider client={new QueryClient()}> | |
| <RouterProvider router={router as never} /> | |
| </QueryClientProvider>, | |
| ); | |
| const queryClient = new QueryClient(); | |
| render( | |
| <QueryClientProvider client={queryClient}> | |
| <RouterProvider router={router as never} /> | |
| </QueryClientProvider>, | |
| ); |
| async function openOptions(name: string) { | ||
| const trigger = await screen.findByRole('button', { name }); | ||
| await act(async () => { | ||
| fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); | ||
| }); | ||
| } | ||
|
|
||
| async function click(element: HTMLElement) { | ||
| await act(async () => { | ||
| fireEvent.click(element); | ||
| }); | ||
| } |
There was a problem hiding this comment.
React Testing Library's fireEvent already wraps event dispatches in act internally. Wrapping them in act manually here is redundant and adds unnecessary boilerplate. We can simplify these helper functions by removing the redundant act wrappers.
async function openOptions(name: string) {
const trigger = await screen.findByRole('button', { name });
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false });
}
async function click(element: HTMLElement) {
fireEvent.click(element);
}
⊙ Problem
#1301 reported repeated ids in the URL after clicking a sign-in link again while the first navigation was still loading. I reproduced it on
stage, and the cause is the relative card links that #1798 makes absolute.A card click calls
router.navigatewith the card's own options. TanStack resolves a relative target against the pending location: router-corebuildLocationresolves againstdest.from ?? currentMatch().fullPathof_pendingLocation || latestLocation. The card is still on screen until the next route finishes loading, so a second click appends the target to the first one. A second click on an organization card lands on/org-a/org-a, and a second Direct Sign In from a cluster card lands on/org-a/clu-b/sign-in/clu-b/sign-in.💡 Solution
One new test file,
cardLinksDuringPendingNavigation.test.tsx. It renders the realOrgCardandClusterCardunder a real router whose next route never finishes loading and clicks twice: a second organization card click, the organization menu's hrefs from the pending location, and a second Direct Sign In. It does not debounce or disable a card while its navigation is pending: with absolute targets a second click goes to the same URL, so it does no harm.✅ Verification
End-to-end route: the real cards under a real TanStack router with a never-settling route load, which is the report's slow-network double click, minus a browser.
stage(dcaeb6c93, the sameOrgCard/ClusterCardas this stack's base): all three fail, withReceived: "/org-a/org-a"andReceived: "/org-a/clu-b/sign-in/clu-b/sign-in". On fix: org and cluster card menu links no longer resolve one segment too deep #1798's head (6ba1b9cc9) plus this commit: all three pass.npx vitest run: 399 files, 3695 passed, 11 skipped, exit 0 (the pre-commit hook's run).npx tsc -b,npx oxlintandnpx dprint checkall exit 0.Stack: #1798 (#1711, base
stage) → this PR. Closes #1301.🤖 Generated with Claude Code
Related PRs: #1798 overlaps (stack base; its absolute card targets are the fix this test pins), 1 others independent
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 @ dbd09d8
Review-Attention: skim ~2m (raised: degraded review) @ dbd09d8