LearnNewsExamplesServices
Frontmatter
id18763
titleColors: a destroyed grid nulls its reference and updateGrid throws
stateClosed
labels
bugaidashboard
assigneesneo-opus-ada
createdAtSep 16, 2026, 10:30 AM
updatedAtSep 16, 2026, 12:36 PM
githubUrlhttps://github.com/neomjs/neo/issues/18763
authorneo-opus-vega
commentsCount3
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 16, 2026, 12:36 PM

Colors: a destroyed grid nulls its reference and updateGrid throws

neo-opus-vega
neo-opus-vega commented on Sep 16, 2026, 10:30 AM

Context

test/playwright/e2e/colors/tearOutMatrix.spec.mjs reds intermittently on every branch, including dev, and it has been doing so for at least a day. The failure is not in the spec's own assertions — those pass and print their receipts. It is the workerErrors auto-fixture (test/playwright/fixtures.mjs:138) catching a real App Worker exception:

App Worker: TypeError: Cannot destructure property 'store' of 'grid' as it is null.
    at ViewportController.updateGrid (apps/colors/view/ViewportController.mjs:218:14)
    at ViewportController.updateWidgets (apps/colors/view/ViewportController.mjs:257:20)

Enumerated occurrences (gh run list --workflow=test-e2e.yml), all with the identical stack and the identical 1 failed / 58 passed shape:

run branch when
35002189086 dev 2026-09-15 17:35Z
34991148249 agent/18741-teardown-liveness 2026-09-15 15:50Z
34992052709 agent/18741-teardown-liveness 2026-09-15 15:59Z
34993223708 agent/18741-teardown-liveness 2026-09-15 16:09Z
35069143332 dependabot/npm_and_yarn/all-deps-d54dbf6e78 2026-09-16 07:33Z

A same-SHA rerun of the last one went green, which establishes intermittency and rules out the dependency bump it was blocking (PR #18756, since merged). Roughly one e2e-engine run in three over that window.

Intermittent is not spurious. The fixture is behaving correctly; the application is intermittently wrong. The standing cost is that unrelated PRs go red at ~30%, which spends a rerun per author and trains the team to re-run reds without reading them.

Two ✘ clusters in the same log are not part of this: WorkerErrorGate.spec.mjs rows 18 / 20 / 22 are that spec's own negative controls ("a worker error the test does not name fails it"). They are supposed to red, and the summary line reads 1 failed.

The Problem

updateWidgets awaits an RPC round-trip and then resumes:

async updateWidgets() {
    ...
    response = await Colors.backend.ColorService.read({...});

    if (!me.isDestroyed) {                 // guards the CONTROLLER
        me.updateGrid(data.tableData);     // dereferences the GRID
        me.updateCharts(data.summaryData)
    }
}

updateGrid(records) {
    let grid    = this.getReference('grid'),
        {store} = grid;                    // :218 — throws when grid is null

The liveness predicate is one layer off from the thing being dereferenced. me.isDestroyed answers "does the controller still exist?"; the very next line asks the tree for a component the controller does not own. Between the await and the resume, that component can go away while the controller does not — so the guard passes and the dereference fails.

This is the same class as #18731 / #18741 / #18752: deferred work checking liveness where it was scheduled rather than where it resumes. Those were engine-side; this is the app-side instance, and it is narrower — the guard exists, it just names the wrong object.

The Architectural Reality

The reference cache is not the culprit, and that is load-bearing for the diagnosis. Every mutation site of controller.Component#references, enumerated across the repo:

operation site reachable from
write src/controller/Component.mjs:125 getReference() itself — the only writer
delete src/controller/Component.mjs:177 removeReference() (:170), called from src/component/Base.mjs:1366 inside destroy(), plus its own parent-walk at :182

getReference treats the cache as a hint rather than an authority: a miss falls through to me.component.down({reference: name}) and repopulates, which makes destroy-then-recreate self-healing on the next call.

So a stale cache would return the wrong component — which is exactly the failure mode the getReference TODO at src/controller/Component.mjs:113 describes (container.remove() then container.add() with the same key). This ticket is the opposite shape. null proves two things at once:

  1. the cache had already been correctly purged — and destroy() is the only path that purges it; and
  2. down({reference: 'grid'}) found nothing in the controller's subtree at that instant.

apps/colors/view/Viewport.mjs:59 declares {module: GridContainer, reference: 'grid'}, so me.reference is truthy and destroy() does reach the purge. A null here is therefore mechanical evidence that the grid instance was destroyed — not merely re-parented, not merely unmounted.

That is the part worth isolating, because it sits against object permanence: Neo's multi-window model keeps the component tree in the shared App Worker and treats windows as render targets, so a widget moving between windows should be reused, not destroyed and rebuilt. Something on the tear-out / popup path (src/dashboard/Container.mjs#openWidgetInPopup, :231 and :395) is a candidate, but that is a hypothesis and is not claimed — the failing arm (tearOutMatrix.spec.mjs:104, row 2) documents its own gesture as "small in-container moves only, never crossing the boundary", which does not obviously produce a destroy. The mechanism needs an instrumented run, not another read.

Adjacent and deliberately not folded in: destroy() sets me.controller = null at src/component/Base.mjs:1364 before calling me.getController()?.removeReference(me) at :1366, and getController() memoizes into me[closestController]. For a component that owns a controller the purge therefore walks a different chain than it would one line earlier. Harmless for the colors grid, which owns none — recorded so the next reader does not have to re-derive it.

The Fix

Two layers, and the first does not wait on the second.

  1. apps/colors/view/ViewportController.mjs — the resume guard must cover what it dereferences. updateGrid should not destructure a reference it has not checked, and updateWidgets's post-await branch should test the surfaces it is about to touch rather than only the controller. updateCharts is on the same resume path and needs the same audit.
  2. The destroy itself — isolate what destroys the grid during that arm, then decide whether the destroy is correct (and the app must tolerate it) or an object-permanence violation (and the destroy is the bug). Only the first branch is an app fix; the second is engine-side and would get its own leaf.

Deliberately not prescribed here: changing getReference, removeReference, or the cache. The enumeration above shows the cache behaving correctly in this scenario, and the :113 TODO addresses a different failure mode.

Acceptance Criteria

  • AC-1 — the destroy source is identified and named with a file:line, by an instrumented run rather than by reading. Record whether the destroy is correct behaviour or an object-permanence violation; if the latter, file the engine-side leaf and link it here.
  • AC-2 — updateWidgets' resume path guards every surface it dereferences, updateCharts included. No getReference(...) result is destructured or dereferenced without a null check on the same resume.
  • AC-3 — a red-first arm reproduces the throw against the pre-fix source and passes after, asserting the absence of the worker error rather than only a green render. Placement per the unit-test / whitebox-e2e skill, whichever the seam allows.
  • AC-4 — tearOutMatrix.spec.mjs runs clean across 10 consecutive e2e-engine invocations, or the residual rate is measured and recorded here. A single green run does not discharge this: the pre-fix rate is ~1 in 3, so one pass is ~67% likely to prove nothing.

Out of Scope

  • getReference / removeReference / the reference cache — see The Architectural Reality; the cache is not implicated in this failure mode.
  • The src/component/Base.mjs:1364/:1366 ordering note — recorded above, not fixed here, no consumer affected.
  • WorkerErrorGate.spec.mjs's three intentional red arms.
  • The dependency bump in PR #18756 — falsified as a cause, merged.
  • Whether any other app has the same resume-guard shape. Worth a sweep, not this leaf.

Avoided Traps

  • Re-running until green. The same-SHA rerun was used to classify the failure, not to bypass it. Treating this as a flake to be re-run is how a real ~30% defect survives indefinitely while costing every author a cycle.
  • Blaming the fixture. workerErrors is doing exactly what it exists to do. Suppressing or narrowing it would convert a visible defect into an invisible one.
  • Fixing the cache. The obvious read of a null from a cached getter is "cache invalidation bug", and the enumeration refutes it. Patching getReference would leave the destroy in place and hide the evidence that one happened.
  • Prescribing the mechanism from source alone. The tear-out path is the plausible candidate and the failing arm's own comment argues against it. AC-1 requires a measurement rather than accepting either reading.

Related

  • #18731 · #18741 · #18752 — the engine-side instances of the same resume-liveness class.
  • #17871 — Workstation full tour can lose the pooled grid after midpoint. Different app, different surface, unverified relation; linked because "a grid disappears mid-interaction" is the same symptom family and one investigation may inform the other.
  • #18756 — the dependabot PR this failure blocked; falsified as a cause and merged.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-16T08:28:45Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages (all read-states): no competing [lane-claim] / [lane-intent] on this surface. Memory Core rationale sweep on the symptom nouns: no prior decision. Own-assignment sweep: no same-surface hit. Structure-map gate: N/A — no .mjs created or relocated, and nothing under ai/, Agent OS, MCP, Memory Core, orchestration or .agents/skills is touched.

unowned-rationale: filed from a review-adjacent investigation, not a claimed lane — the operator has a different topic in flight and I am not starting this. AC-1 is the entry point and is pickable by anyone; it needs an instrumented run, not context from this session.

Decision Record impact: none.

Origin Session ID: 5bf0b816-b919-40fc-9c18-fee751fd1635

Retrieval Hint: "colors ViewportController updateGrid null grid reference destroyed resume liveness" · query_raw_memories on "resume guard names the controller not the dereferenced surface"

tobiu referenced in commit fb2afd1 - "fix(colors): the resume reaches the widgets a drag moved out of the tree (#18763) on Sep 16, 2026, 12:17 PM
tobiu referenced in commit 55dd75b - "fix(colors): the resume reaches the widgets a drag moved out of the tree (#18763) (#18774) on Sep 16, 2026, 12:36 PM
tobiu closed this issue on Sep 16, 2026, 12:36 PM
tobiu referenced in commit 7710767 - "fix(manager): both traversals agree with parent about who a component's parent is (#18781) on Sep 16, 2026, 1:43 PM
tobiu referenced in commit 6a1f10b - "fix(manager): both traversals agree with parent about who a component's parent is (#18781) (#18784) on Sep 16, 2026, 3:31 PM