Use when writing a Vitest test for a react-router useBlocker unsaved-changes guard in this repo — specifically an intentional-navigation handler (Edit / Submit / Cancel) that uses flushSync(() => setIsNavigating(true)) before navigate() to bypass the blocker. A test that reads the blocker predicate after act() passes even when flushSync is removed, so it does not guard the fix. Trigger on any test touching isNavigating, useBlocker, flushSync, or a *.hooks.js navigation-blocker handler.
75
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Several *.hooks.js files in this repo (RequestPreAwardApproval, ApprovePreAwardApproval, ReviewBudgetTeamRequisition, award-approval siblings) register a react-router useBlocker unsaved-changes guard:
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
!isNavigating && hasChanged && currentLocation.pathname !== nextLocation.pathname
);An intentional navigation (Edit / Submit / Cancel) must slip past this guard. The pattern is always:
flushSync(() => { setIsNavigating(true); }); // <-- load-bearing
navigate(`/somewhere`);Core principle: flushSync is the load-bearing line, and the obvious unit test does not guard it. react-router evaluates the blocker predicate synchronously at the instant navigate() fires on a forward push. A plain setIsNavigating(true) batches the state update, so the predicate that runs during navigate() is still closed over isNavigating === false and blocks — reintroducing the bug. flushSync forces the commit (and blocker re-registration) before navigate(). A test must therefore assert the predicate's value at navigate-time, not after act() settles.
The natural test reads the captured predicate after the handler runs:
act(() => { result.current.handleEdit(); });
expect(capturedCb(nav)).toBe(false); // ❌ passes with OR without flushSyncThis passes even against a mutant where flushSync(...) is replaced by a plain setIsNavigating(true). Two reasons, both verified by mutation testing in this repo:
useBlocker is mocked. The mock just captures the predicate; react-router's real synchronous re-check during a forward push — the exact thing flushSync protects — never runs.act() masks the timing. act() flushes all pending state before returning, so by the time you call capturedCb(nav) the batched setIsNavigating(true) has already committed. The stale-vs-fresh distinction is gone.Result: dropping flushSync reintroduces the production bug with a fully green suite. The regression test for the bug does not protect the line the fix is made of.
Reproduce react-router's synchronous behavior by evaluating the currently-registered predicate inside the navigate mock — the moment navigate() is called:
it("does not block at the instant navigate() fires (guards flushSync)", async () => {
let capturedCb;
// mockImplementation re-captures the predicate on EVERY render, so capturedCb
// always points at the latest closure over isNavigating.
mockUseBlocker.mockImplementation((cb) => {
capturedCb = cb;
return { state: "unblocked", proceed: mockProceed, reset: mockReset };
});
const { result } = setup(buildAgreement());
await waitFor(() => expect(result.current).toBeDefined());
// Dirty the form so the guard WOULD fire on navigation.
act(() => { result.current.setNotes("some note"); });
const nav = {
currentLocation: { pathname: "/agreements/1/pre-award-approval" },
nextLocation: { pathname: "/agreements/review/1/edit" }
};
await waitFor(() => expect(capturedCb(nav)).toBe(true)); // sanity: blocks before edit
// Capture the predicate's value at the exact moment navigate() runs — this is
// what react-router does synchronously on a real forward push.
let blockedAtNavigateTime;
navigateMock.mockImplementation(() => {
blockedAtNavigateTime = capturedCb(nav);
});
act(() => { result.current.handleEdit(); });
// With flushSync -> false (bypass committed before navigate). Without -> true (bug).
expect(blockedAtNavigateTime).toBe(false);
expect(navigateMock).toHaveBeenCalledWith(
"/agreements/review/1/edit?returnTo=%2Fagreements%2F1%2Fpre-award-approval"
);
});Requires a module-level stable navigateMock (not useNavigate: () => vi.fn(), which returns a throwaway) so mockImplementation and URL assertions work. beforeEach must vi.clearAllMocks().
A bypass test that you have not mutation-tested is presumed to be the trap above. Confirm it fails against the mutant:
flushSync(() => { setIsNavigating(true); }); with a plain setIsNavigating(true);.expected true to be false at blockedAtNavigateTime).git checkout the file).If the test still passes against the mutant, it is the after-act() trap — rewrite it to evaluate inside the navigate mock.
| Assertion site | Catches flushSync removal? |
|---|---|
capturedCb(nav) after act() | ❌ No — act() already flushed the batched setState |
capturedCb(nav) inside navigateMock.mockImplementation | ✅ Yes — reads the predicate at navigate-time |
navigate called with encoded URL | ❌ No (guards the URL, not the timing) |
bypass-flag exists at all (remove setIsNavigating → fail) | Partial — guards the flag, not flushSync |
useBlocker/navigate are mocked. It models — does not exercise — a real react-router transition.MemoryRouter/createMemoryRouter, a real blocker, and real routes. There is no such test in this repo — every blocker test mocks useBlocker (see frontend/src/hooks/useNavigationBlocker.test.js, frontend/src/hooks/useUnsavedChangesBlocker.test.js). Don't introduce that machinery for one line unless asked; the navigate-time unit test is the idiomatic choice here.act() assertion (e.g. matching a sibling test's style), add a one-line comment stating it guards the bypass-flag contract and URL but not the flushSync timing — so the next reader isn't misled. Don't write a comment claiming it verifies flushSync when it doesn't.capturedCb(nav) === false after act() and calling it done. That is the trap — mutation-test it and watch it pass against the mutant.useNavigate: () => vi.fn(). Returns a fresh mock each call; you can't attach mockImplementation or assert the URL. Hoist a module-level navigateMock.act() test overstates coverage. State what the test actually guards.On issue 6061 (Edit button wrongly showing the cancel modal), the fix added handleEdit with flushSync(() => setIsNavigating(true)). Adversarial review mutation-tested the new hook test: replacing flushSync with a plain setIsNavigating(true) left all 14 hook tests green — the regression test for the bug did not protect the fix's load-bearing line. The navigate-time technique above fails against that mutant, closing the gap. See dead-code-after-removal-sweep for the sibling "verify with a mutation" discipline applied to deletions.
4ff5a51
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.