Skip to content

Fix remaining chunkErrorRecovery.spec.ts hydration/guard races under CI shard contention - #399

Merged
WilfordGrimley merged 1 commit into
masterfrom
fix-chunk-error-recovery-mount-race
Jul 23, 2026
Merged

Fix remaining chunkErrorRecovery.spec.ts hydration/guard races under CI shard contention#399
WilfordGrimley merged 1 commit into
masterfrom
fix-chunk-error-recovery-mount-race

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Symptom

After #397 merged, PR #395's CI (run 30043836352) still failed shard 1/4's "two ChunkLoadErrors in quick succession only trigger one reload attempt" test - expect.poll got 0 - across all 3 attempts including retries. Also reproduced locally on this box: chunkErrorRecovery.spec.ts fails intermittently at --workers=4 (and occasionally the unhandled-promise-rejection test too), passes reliably at --workers=1.

Root causes (two, both fixed)

(a) Guard-clear/dispatch gap: #397 called clearReloadGuard(page) and dispatchChunkError(page) as two separate page.evaluate() round-trips. That left a real (if narrow) window for the same class of dev-server on-demand-compilation chunk noise #397's own diagnosis identified (the self-referential "Editor" nav-link prefetch triggering a real recompile of pages/editor.js mid-test) to land in and consume the guard again.

(b) Hydration race: loadPageWithDefaultBackend()'s "Choose Art" click is a raw DOM click - Chromium dispatches it regardless of whether React has hydrated and useChunkErrorRecovery's useEffect has actually registered its listeners yet. Under CPU contention (confirmed: PR #395 un-skipped 58 ported parity tests into the same 4 CI shards, raising per-shard dev-server contention - shard composition changed, not playwright.config.ts's fullyParallel/workers: undefined, which predate this spec), hydration can still be pending when the test dispatches its synthetic error. A native DOM event dispatched before a listener exists is simply lost - polling longer can't recover it.

Fix

  • (a): folds the guard-clear and the synthetic dispatch into one page.evaluate() call per test (clearGuardAndDispatchChunkError(Error)AsRejection) - a single synchronous browser-side task nothing else on the page's event loop can interleave with.
  • (b): adds awaitHydrated(), which retries clicking "Choose Art" (the same established pattern test-utils.ts's own openAddCardsDropdown() already uses for the identical symptom) until ProjectEditor.tsx's "editor" tab content - specifically CardGrid.tsx's "Your project is empty at the moment." empty-state text - actually becomes visible. That can only happen once Tab.Container's onSelect handler has bound and fired, i.e. once the same hydration/effect-flush pass that mounts useChunkErrorRecovery's own listeners has completed. No extra network dispatch or navigation needed.

Two earlier attempts at (b) - a page.route()-intercepted, aborted warm-up reload, and a real uncontrolled warm-up reload followed by re-navigating - were each independently found via local repro to destabilise the page further under the same --workers=4 stress (a SecurityError: ... Access is denied for this document on the aborted-navigation path, and a Navigation ... is interrupted by another navigation on the real-reload path, since the self-referential "Editor" nav-link prefetch is a genuinely recurring background navigation under load, not a one-off). The DOM-visibility approach needed neither.

Why the assertions are not weakened

Both guarantees stay exactly as strict: the one-reload assertions (toBe(1)) are unchanged, and the guard-suppression test still clears the guard only once, before its first dispatch, so its second dispatch genuinely exercises real guard-suppression behaviour (not a cleared one).

Verification

  • npx prettier@2.7.1 --check on both changed files: pass
  • npx tsc --noEmit: pass
  • npx jest (full suite, 64 suites/568 tests): all pass
  • npx playwright test tests/chunkErrorRecovery.spec.ts --workers=4 --repeat-each=10 (cold .next cache): 40/40, twice
  • --workers=4 --repeat-each=15: 60/60
  • --workers=8 --repeat-each=10 (this box's full core count): 40/40
  • --shard=1/4 (the exact CI shard this spec lands in - confirmed via playwright test --list --shard=1/4), cold cache, default (4) workers, matching real shard composition/contention post-Port import/card-detail/export test clusters onto DisplayPage #395: clean, twice
  • --shard=1/4 --repeat-each=3 (246 tests, cold cache): 244 passed, 3 flaky-then-passed-on-retry - all three unrelated to this spec (DisplayFinishFooter.spec.ts, DisplayPage.spec.ts), confirmed pre-existing by reproducing standalone outside this change
  • Full unsharded suite (328 tests, cold cache): 327 passed, 1 unrelated flake (DisplayFinishFooter.spec.ts), confirmed pre-existing by re-running that spec alone (6/6 passed)

Two earlier local-only reproductions of dead-end fix attempts (the SecurityError and the interrupted-navigation error) are documented in docs/troubleshooting.md and in the spec file's own comments, so neither gets re-derived.

Docs

Edited docs/troubleshooting.md's existing chunkErrorRecovery.spec.ts entry in place (repo convention: edit, don't append a dated section) to cover both root causes and both dead ends, plus how to verify any future fix here (--shard=1/4, not the file in isolation).

Not merging - per this session's operating rules, PRs are reviewed and merged by the owner. This branch is a fresh worktree off current origin/master (the prior branch, #397, already merged).

@WilfordGrimley
WilfordGrimley merged commit 1d99d6c into master Jul 23, 2026
12 checks passed
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