Skip to content

fix(router): each unit test starts with no suspense-audit records, and a violation fails it (rango-t226) - #1318

Merged
ivogt merged 2 commits into
mainfrom
fix/t226-audit-reset
Oct 28, 2026
Merged

ivogt merged 2 commits into
mainfrom
fix/t226-audit-reset

Conversation

@ivogt

@ivogt ivogt commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

devcorner: rango-t226 · session rango-s448

The router's unit suite now fails a test when the suspense audit reports a violation in it. Before this PR, the suite printed 368 audit reports at router 0.30.0 that no test failed on. Each test, and each renderRoute root, now starts with no audit records.

Problem

The suspense audit (src/suspense-audit.ts) runs only where INTERNAL_RANGO_SUSPENSE_AUDIT is set: the router's own unit suite and the e2e servers. Consumers never run it, so nothing changes for them.

The audit keeps its records in module state, and vitest keeps one module per file, not per test. So a root that one test unmounted stayed in the records. The next test's first mount of the same outlet then read as an I4 remount.

Nobody saw it. The default reporter drops console output, and no test failed on a report. On main, vitest run --reporter=verbose printed 368 reports:

Invariant Reports Cause
I4 remount 316 records leaked from an earlier test's root
I6 uncaused 32 tests that call store.emitUpdate without naming the cause, as production's emitters do
I5 drift 20 the same leak, plus the redirect drift of rango-t29

A real violation in a unit test was lost among them. The lock also surfaced 2 I4 and 4 I5 in action-loader-error-in-place.test.tsx, which mocks console.error and so hid even the printed warning.

Before and after, for a test in this repo

Before: a test that leaves the audit in a bad state passes, and the next test inherits the records.

it("emits a tree", async () => {
  router.store.emitUpdate(update); // I6: no cause named; printed, never failed
});

After: the same test fails with the report, and names the cause as production's emitters do.

it("emits a tree", async () => {
  auditTreeCause("navigation");
  router.store.emitUpdate(update);
});

The failure message, after the audit's queued judgments have run:

The suspense audit reported a violation in this test (docs/internal/suspense-contract.md):
I4 remounts: 1
  I4 remount at outlet:L0: unmounted and mounted again under the same key "L0"

A test that hits a known router defect opts out by name. The opt-out fails once the defect is fixed, so it does not outlive its ticket:

allowSuspenseAuditViolations(
  "I5 drift: a settled redirect() drops StreamedLoaderErrorBoundary (rango-t29)",
);

Semantics

Case Before After
A test's audit report has a violation printed, test passes test fails with the report
The next test in the same file inherits the records starts with none
A second renderRoute root in one test, after cleanup() its first mount reads as I4 starts clean
An opt-out in a test that reports nothing n/a test fails: remove the opt-out
A file that provokes violations on purpose (suspense-audit.test.tsx) n/a file-level opt-out
Consumer renderRoute tests audit off unchanged: forgetSuspenseAudit() is a no-op without the flag
E2e and builds console guard reads __rangoSuspenseAudit unchanged

Code changes

  • src/suspense-audit.ts:
    • forgetSuspenseAudit() re-creates every record, including the WeakMap/WeakSet ones, which have no clear(). It no longer creates a report when the audit is off.
    • getReport() lists each loaded copy of the module on window.__rangoSuspenseAudits, as a SuspenseAuditInstance: violations(), forget() and settle(). One file, segment-system.test.tsx, re-imports the module after vi.resetModules(). That file then holds two copies, and its static imports use the older one, not the one on __rangoSuspenseAudit.
    • eventLine() formats an event for both the console line and the failure message.
  • src/testing/render-route.tsx: calls forgetSuspenseAudit() before it builds its first tree.
  • src/__tests__/kit/suspense-audit-lock.ts (new; a vitest setup file in vitest.config.ts):
    • Forgets every instance before each test.
    • After each test, settles each instance and fails the test on any violation.
    • It imports no router module at runtime. A setup file's imports are evaluated before the test file's vi.stubEnv, and that broke 3 logging.test.ts tests.
  • Tests that hand React a tree themselves now name the cause with auditTreeCause, and auditTreeUpdate where no provider does it:
    • action-loader-error-in-place
    • navigation-adoption-loading
    • navigation-scroll-slot
    • location-state-hydration
    • navigation-provider-hydration
    • navigation-provider-cached-handles
  • Docs: docs/internal/suspense-contract.md has a new section, "The unit-test lock"; docs/internal/feature-file-map.md is updated.

Test plan

  • Red before green. On the test commit (12fbcfd3a), 4 tests fail; on the fix commit, all pass:

    • render-route-audit-lock.test.tsx:
      • an uncaused emit fails its test, pinned with it.fails;
      • the next test starts with uncaused === 0.
    • render-route-audit-reset.test.tsx: a root in the next test, and a second root in one test, mount the same outlets with no I4.
  • With the renderRoute forget removed, the lock fails the two-roots test with I4 remounts: 1. This shows settle() waits for the audit's deferred remount judgment.

  • Router unit suite: 9635 passed and 1 expected fail, across 585 files. With --reporter=verbose it prints 1 audit report, down from 368: the I7 that is allowed under rango-t227.

  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format.

  • Every check:* script, including check:e2e-parity --strict, check:suspense-contract and check:internals.

  • check:bundle-guards: pass; the router chunk is 52279 B gzip, unchanged from main, under the 52352 B cap.

  • node --test on the tools tests: 63/63.

  • Targeted e2e, suspense-cases and held-boundary, run before the final review cleanup. That cleanup changed no console output.

    App Dev Production
    Router test-app 64 passed, 1 flaky smoke-dep retry 43 passed
    cloudflare-basic 45 passed 43 passed
  • CI's full e2e run on this head.

Notes

  • Hook-lock path. src/testing/__tests__/navigation-adoption-loading.test.tsx is on tools/hook-lock.json. The edit is test-only: each of the 3 direct store.emitUpdate calls now names its cause first. No hook's commit behavior changes. The hook-lock check needs the label hook-lock: approved. Hooks design: rango-t84, the hooks review this belongs to.
  • Complexity added. The audit now keeps a dev-only list of its loaded module copies on window.__rangoSuspenseAudits, one entry per copy. Only the lock reads it. It exists because vi.resetModules() loads a second copy, and the lock must reset and check both.
  • Defects left open behind opt-outs. Each opt-out fails its test once its ticket is fixed:
    • rango-t29: a settled redirect() drops StreamedLoaderErrorBoundary, so the wrapper chain drifts (I5). 4 tests in action-loader-error-in-place.test.tsx.
    • rango-t227: renderSegments writes loaderIds on a slot segment already handed to React (I7). 1 test in segment-system.test.tsx.
  • Not done. Some test sites still pair auditTreeCause with an emit by hand. They mirror production's emitters, which make the same two calls, and their shapes differ (emitUpdate, setTree with auditTreeUpdate, commitInTransition). A shared helper would cover only part of them.

Practices

  • Lint: pnpm run lint, pnpm run format and every check:* script passed on the head.
  • Dead code: the lock replaced an earlier per-file list of known violations and a duplicated report type. No unused export is left.
  • Tests: two new test files pin the reset and the lock, red before green. The router suite now fails a test on any unexpected audit report.
  • Code review:
    • The diff was checked for reuse, simplification, efficiency and depth.
    • Applied: the shared event formatter, the instance's settle() in place of a fixed microtask count, and the ungated renderRoute forget.
    • Kept: the per-instance list on window. A reset on root unmount would not clear the counters, and would hide a whole-tree remount.
  • Docs: suspense-contract.md, "The unit-test lock"; feature-file-map.md.

ivogt added 2 commits October 28, 2026 12:57
… I4 remount, and an audit violation fails its test (rango-t226, red)
…o suspense-audit records, and a violation fails its test (rango-t226)
@pkg-pr-new

pkg-pr-new Bot commented Oct 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@1318

commit: 2ff77cf

@ivogt ivogt added the hook-lock: approved Owner approval for hook commit files (Hard rule 10) label Oct 28, 2026
@ivogt
ivogt merged commit 51d917d into main Oct 28, 2026
61 of 62 checks passed
@ivogt
ivogt deleted the fix/t226-audit-reset branch October 28, 2026 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hook-lock: approved Owner approval for hook commit files (Hard rule 10)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant