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:
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:
A Playwright worker runs many spec files in one process, so every later spec in that worker inherits the replacement.
??= 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
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.
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
Context
Found while running the unit tier across several directories for #18764:
selection/GalleryInternalId.spec.mjsfailed in the combined run and passed alone, and the failing arm moved between runs. It is not a flake and not mine — it reproduces ondev@e197300297:npm run test-unit -- test/playwright/unit/selection/GalleryInternalId.spec.mjs test/playwright/unit/main/DomEvents.spec.mjs --workers=1One arm fails with
TypeError: Cannot read properties of undefined (reading 'getElementById')atsrc/main/DomAccess.mjs:663, reached throughGallery.afterSetMounted→component/Base.mjs#focus→Neo.main.DomAccess.focus. Each spec passes on its own.The Problem
test/playwright/setup.mjs:162installs the remote stub every unit spec relies on:Neo.main.DomAccess ??= { … }test/playwright/unit/main/DomEvents.spec.mjs:27-31then 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.DeltaUpdatesandNeo.main.DomEventsnow hold real main-thread objects.afterAll(:43-49) restoresglobalThis.document,globalThis.windowand the three patched methods — and nothing else. The namespace entries stay.Two properties turn that into another spec's failure:
??=cannot heal it. A latersetup()sees a truthyNeo.main.DomAccessand leaves the real singleton in place. Itsfocusthen callsdocument.getElementByIdwith thedocumentthis spec deleted.The victim is whichever spec focuses a component next, which is why the failing arm moves.
The Architectural Reality
test/playwright/setup.mjs:162Neo.main.DomAccess ??=— install-once, so a replacement is permanent for the workertest/playwright/unit/main/DomEvents.spec.mjs:27src/main/DomAccess.mjs:663document.getElementById(nodeId)— the throw site, withdocumentgonesrc/component/Base.mjs#focusNeo.main.DomAccess.focustest/playwright/unit/component/CircleAsync.spec.mjs:26delete Neo.maininafterEach— removes the shared stub wholesale, at run timetest/playwright/unit/component/NativeDragZone.spec.mjs:32-33delete Neo.main.addon/delete Neo.main.DomEventsinafterEachThe 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.mjsrestores the namespace it replaced. CaptureNeo.main.DomAccess(and theDeltaUpdates/DomEventsentries, if the imports create them) before the delete, and put the captured values back inafterAll, next to thedocument/windowrestore that is already there.Acceptance Criteria
getElementByIderror.DomEvents.spec.mjsfinishes,Neo.main.DomAccess,Neo.main.DeltaUpdatesandNeo.main.DomEventshold what they held before it ran.test/playwright/unit/main/DomEvents.spec.mjskeeps its own coverage: its arms pass unchanged.Neo.main*/Neo.worker.*entry is enumerated, and each either restores what it replaced or carries a line saying why deleting is correct there.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
Galleryis 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 inafterAll, because a plain object left on aNeo.mainnamespace makes the next spec file'sNeo.setupClassof 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 callingNeo.setupClassinbeforeAllcannot be retried at all, since the retry re-runs it and collides (observed onunit/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_memorieson the symptom nouns; hits are olderNeo.mainstubbing 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"