LearnNewsExamplesServices
Frontmatter
id18776
titleA unit spec replaces Neo.main.DomAccess and never restores it, so a later spec''s focus() throws
stateClosed
labels
bugaitesting
assigneesneo-opus-grace
createdAtSep 16, 2026, 12:19 PM
updatedAtSep 16, 2026, 3:52 PM
githubUrlhttps://github.com/neomjs/neo/issues/18776
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 16, 2026, 3:52 PM

A unit spec replaces Neo.main.DomAccess and never restores it, so a later spec's focus() throws

neo-opus-grace
neo-opus-grace commented on Sep 16, 2026, 12:19 PM

Context

Found while running the unit tier across several directories for #18764: selection/GalleryInternalId.spec.mjs failed in the combined run and passed alone, and the failing arm moved between runs. It is not a flake and not mine — it reproduces on dev@e197300297:

npm run test-unit -- test/playwright/unit/selection/GalleryInternalId.spec.mjs test/playwright/unit/main/DomEvents.spec.mjs --workers=1

One arm fails with TypeError: Cannot read properties of undefined (reading 'getElementById') at src/main/DomAccess.mjs:663, reached through Gallery.afterSetMounted → component/Base.mjs#focus → Neo.main.DomAccess.focus. Each spec passes on its own.

The Problem

test/playwright/setup.mjs:162 installs the remote stub every unit spec relies on:

Neo.main.DomAccess ??= { … }

test/playwright/unit/main/DomEvents.spec.mjs:27-31 then deletes it and imports the real main-thread singletons into the same worker:

delete Neo.main.DomAccess;

const {default: DomAccess}    = await import('../../../../src/main/DomAccess.mjs'),
      {default: DeltaUpdates} = await import('../../../../src/main/DeltaUpdates.mjs'),
      {default: DomEvents}    = await import('../../../../src/main/DomEvents.mjs');

Each import registers its singleton on the global namespace, so Neo.main.DomAccess, Neo.main.DeltaUpdates and Neo.main.DomEvents now hold real main-thread objects. afterAll (:43-49) restores globalThis.document, globalThis.window and the three patched methods — and nothing else. The namespace entries stay.

Two properties turn that into another spec's failure:

  1. A Playwright worker runs many spec files in one process, so every later spec in that worker inherits the replacement.
  2. ??= cannot heal it. A later setup() sees a truthy Neo.main.DomAccess and leaves the real singleton in place. Its focus then calls document.getElementById with the document this spec deleted.

The victim is whichever spec focuses a component next, which is why the failing arm moves.

The Architectural Reality

surface today
test/playwright/setup.mjs:162 Neo.main.DomAccess ??= — install-once, so a replacement is permanent for the worker
test/playwright/unit/main/DomEvents.spec.mjs:27 deletes the stub, imports the real singletons, restores neither
src/main/DomAccess.mjs:663 document.getElementById(nodeId) — the throw site, with document gone
src/component/Base.mjs#focus every component's focus path goes through Neo.main.DomAccess.focus
test/playwright/unit/component/CircleAsync.spec.mjs:26 delete Neo.main in afterEach — removes the shared stub wholesale, at run time
test/playwright/unit/component/NativeDragZone.spec.mjs:32-33 delete Neo.main.addon / delete Neo.main.DomEvents in afterEach

The last two are the same class: a spec disposing of shared namespace state by deleting it rather than restoring what it found.

The Fix

  • DomEvents.spec.mjs restores the namespace it replaced. Capture Neo.main.DomAccess (and the DeltaUpdates / DomEvents entries, if the imports create them) before the delete, and put the captured values back in afterAll, next to the document / window restore that is already there.
  • Sweep the population for the delete-as-cleanup pattern and give each the same treatment where a later spec can observe it.
  • The paired run above is the witness, and it must go red again when the restore is removed.

Acceptance Criteria

  • AC-1 — the paired run in Context passes, and reverting the restore makes it fail again with the same getElementById error.
  • AC-2 — after DomEvents.spec.mjs finishes, Neo.main.DomAccess, Neo.main.DeltaUpdates and Neo.main.DomEvents hold what they held before it ran.
  • AC-3 — test/playwright/unit/main/DomEvents.spec.mjs keeps its own coverage: its arms pass unchanged.
  • AC-4 — every unit spec that deletes a Neo.main* / Neo.worker.* entry is enumerated, and each either restores what it replaced or carries a line saying why deleting is correct there.
  • AC-5 — the full unit tier passes locally with the directory set from the report (grid, selection, list, table, plugin, menu, form, main/DomEvents, main/addon/GridDragScroll).

Out of Scope

The run-level unit-suite nondeterminism of #17796 (closed NOT_PLANNED): that was one arbitrary casualty per CI run with no spec-local cause. This one is a named spec, a named namespace and a deterministic pairing. · Any change to setup.mjs's ??= install: making it overwrite would paper over a spec that does not clean up.

Avoided Traps

  • Calling it a flake and re-running. The pairing is deterministic; only the victim varies.
  • Making the victim defensive. Gallery is not wrong to focus a component; a spec that leaves a broken singleton behind is.

Related

#17796 (closed NOT_PLANNED — the run-level pattern this is not) · #18764 (the lane that surfaced it) · #18772 (a separate CI-only test defect, menu pointer-rest)

The same class, from the other side, on the same day: test/playwright/unit/apps/colors/ViewportController.spec.mjs (PR #18774) installs its main-thread stand-ins only where nothing is registered and removes them in afterAll, because a plain object left on a Neo.main namespace makes the next spec file's Neo.setupClass of the real singleton throw a collision (src/Neo.mjs:881). That is this ticket's discipline, arrived at independently. A third instance is the retry direction: a spec calling Neo.setupClass in beforeAll cannot be retried at all, since the retry re-runs it and collides (observed on unit/data/TreeStoreValueBanding.spec.mjs:194, broadcast as a defect-note).

Live latest-open sweep: latest 20 open issues at 2026-09-16T10:18Z; no equivalent. A2A claim sweep: latest 30 inbox messages, all read-states, same window; the open test-defect claims are #18770 and #18772, both different surfaces. Memory Core rationale sweep: query_raw_memories on the symptom nouns; hits are older Neo.main stubbing sessions, no decision on restoring it. Closed-issue sweep: Neo.main.DomAccess / unit-leak searches surfaced #17796 only. Own-assignment sweep: 4 open, none on the test harness.

Origin Session ID: a758f0e1-0dc1-4e4e-a975-4824a9319487

Retrieval Hint: "unit spec deletes Neo.main.DomAccess and imports the real singleton; setup.mjs ??= cannot restore it, so a later spec's focus() hits a document-less DomAccess"

tobiu referenced in commit 1affec8 - "test(main): the DomEvents spec restores the Neo.main remotes it replaced (#18776) (#18777) on Sep 16, 2026, 3:52 PM
tobiu closed this issue on Sep 16, 2026, 3:52 PM