Summary
A recurring failure class surfaced while landing PR #1027 (@cluesmith/codev-artifact-canvas, spec 945): post-render DOM-mutation effects race against synchronous DOM reads. When a component renders raw HTML (e.g. via dangerouslySetInnerHTML) and then decorates that DOM in a useEffect (setting tabindex, classes, focus, ARIA, etc.), anything that reads those decorations synchronously right after the element appears can observe the pre-effect state. Tests are the most visible victim, but the same theoretical race exists against assistive tech and focus management on a fast paint.
This tracker captures the principle; individual cases are evaluated as they come up (see Out of scope).
Originating evidence (two CI-only failures on PR #1027)
Both passed locally every time and failed only in CI (slower effect-flush scheduling exposed the window):
- Stale-
activeLine / overlay race (Phase 4 e2e). A hover set activeLine; an unconditional reset effect keyed on content could fire after the hover and clobber it, so the + overlay never mounted and the test's findByRole timed out. Fixed by validating the anchor against the reloaded DOM instead of blindly resetting.
tabindex race (Phase 3 keyboard test). A <p>'s default .tabIndex is -1; the decoration effect set it to 0 after render, and the test read p.tabIndex synchronously the instant the element appeared (expected -1 to be +0). Fixed by stamping tabindex="0" at render time in the markdown renderer (in the HTML, not via an effect).
Evidence chain that pinned #2 as a timing race (not a data bug): markdown-it is deterministic and version-pinned (yields data-line="0"), the renderer's own test passed in the same failing CI run, and the workflow flipped pass to fail across docs-only commits with no code change.
The pattern
- Trigger: render output the component does not own as React elements (sanitized HTML), decorated by a follow-up effect.
- Race: a synchronous reader (a test, a screen reader, programmatic focus) observes the element after mount but before the effect runs.
- Why CI: identical code, only the scheduler timing differs; a slower runner widens the window.
General fix shape
- Value known at render time (focusability, static ARIA, stable attributes): stamp it into the rendered HTML so it is present the instant the node mounts. No effect, no window. (This is what the
tabindex fix did.)
- Value not known until later (e.g. marker classes that depend on asynchronously-loaded data): keep the effect, but readers must wait for the decorated state (tests use
waitFor; runtime consumers should not assume synchronous availability).
Not a Vitest/jsdom-specific gap
This is not a test-harness quirk. Production code carries the same theoretical race: assistive technology or focus logic that reads the DOM right after a fast paint can see the pre-effect state. The test failures are the canary, not the whole problem.
Suggested approach
BUGFIX with a small sweep: the plan is a short "what is the audit shape" (how to identify post-render decoration that something reads synchronously), then a survey pass through existing post-render decoration patterns (vscode webviews, artifact-canvas, future packages) to flag candidates. Convert the clear render-time-knowable ones; document the rest.
Out of scope
Rewriting every effect. This tracker captures the principle and the audit; individual cases are evaluated on their merits as they are found.
Origin
Surfaced during PR #1027 (spec 945). The two fixes above already landed on that branch; this tracker generalizes the lesson so future packages do not rediscover it.
Summary
A recurring failure class surfaced while landing PR #1027 (
@cluesmith/codev-artifact-canvas, spec 945): post-render DOM-mutation effects race against synchronous DOM reads. When a component renders raw HTML (e.g. viadangerouslySetInnerHTML) and then decorates that DOM in auseEffect(settingtabindex, classes, focus, ARIA, etc.), anything that reads those decorations synchronously right after the element appears can observe the pre-effect state. Tests are the most visible victim, but the same theoretical race exists against assistive tech and focus management on a fast paint.This tracker captures the principle; individual cases are evaluated as they come up (see Out of scope).
Originating evidence (two CI-only failures on PR #1027)
Both passed locally every time and failed only in CI (slower effect-flush scheduling exposed the window):
activeLine/ overlay race (Phase 4 e2e). A hover setactiveLine; an unconditional reset effect keyed on content could fire after the hover and clobber it, so the+overlay never mounted and the test'sfindByRoletimed out. Fixed by validating the anchor against the reloaded DOM instead of blindly resetting.tabindexrace (Phase 3 keyboard test). A<p>'s default.tabIndexis-1; the decoration effect set it to0after render, and the test readp.tabIndexsynchronously the instant the element appeared (expected -1 to be +0). Fixed by stampingtabindex="0"at render time in the markdown renderer (in the HTML, not via an effect).Evidence chain that pinned #2 as a timing race (not a data bug): markdown-it is deterministic and version-pinned (yields
data-line="0"), the renderer's own test passed in the same failing CI run, and the workflow flipped pass to fail across docs-only commits with no code change.The pattern
General fix shape
tabindexfix did.)waitFor; runtime consumers should not assume synchronous availability).Not a Vitest/jsdom-specific gap
This is not a test-harness quirk. Production code carries the same theoretical race: assistive technology or focus logic that reads the DOM right after a fast paint can see the pre-effect state. The test failures are the canary, not the whole problem.
Suggested approach
BUGFIX with a small sweep: the plan is a short "what is the audit shape" (how to identify post-render decoration that something reads synchronously), then a survey pass through existing post-render decoration patterns (vscode webviews, artifact-canvas, future packages) to flag candidates. Convert the clear render-time-knowable ones; document the rest.
Out of scope
Rewriting every effect. This tracker captures the principle and the audit; individual cases are evaluated on their merits as they are found.
Origin
Surfaced during PR #1027 (spec 945). The two fixes above already landed on that branch; this tracker generalizes the lesson so future packages do not rediscover it.