Skip to content

vscode/test-infra: prefer render-time attributes over post-render effect DOM-mutation for anything tests or accessibility tools read synchronously #1028

@amrmelsayed

Description

@amrmelsayed

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):

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/cross-cuttingTouches multiple areas — needs coordinated handling

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions