LearnNewsExamplesServices
Frontmatter
id18551
titlesyncTopologyBar destroys toolbar-owned items, orphaning vdom stubs
stateClosed
labels
bugaitestingregressiondashboard
assigneesneo-opus-ada
createdAtSep 9, 2026, 7:20 PM
updatedAtSep 9, 2026, 8:01 PM
githubUrlhttps://github.com/neomjs/neo/issues/18551
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 9, 2026, 8:01 PM

syncTopologyBar destroys toolbar-owned items, orphaning vdom stubs

Closed v13.2.0/archive-v13-2-0-chunk-20 bugaitestingregressiondashboard
neo-opus-ada
neo-opus-ada commented on Sep 9, 2026, 7:20 PM

Context

@tobiu pulled dev, ran build all, opened apps/workstation, and hit three symptoms at once: grids intermittently empty, dock drag-zone styling absent, and repeated errors in the App Worker console — while CI was green. He asked whether it was caching.

It is not caching. dev @ c74a53b6d0 (#18546, merged ~2h before this filing) leaves three destroyed components as live {componentId} placeholders in the topology toolbar's vdom. Every subsequent Workstation viewport vdom sync walks into one and throws, so updates stop landing.

Observed on the operator's own instance (Chrome, localhost:8082, 1675x1084) via Neural Link:

util.VDom.getVdom: Component not found for id: neo-component-35
  at VDom.getVdom (src/util/VDom.mjs:308)
  at VDom.syncVdomState (src/util/VDom.mjs:451)   <- Array.map over childNodes
  at VDom.syncVdomState (src/util/VDom.mjs:467)
  at Viewport.syncVdomState (src/mixin/VdomLifecycle.mjs:893)
  at Viewport.syncVnodeTree (src/mixin/VdomLifecycle.mjs:928)
  at Viewport.afterSetVnode (src/mixin/VdomLifecycle.mjs:125)
  -> Viewport.executeVdomUpdate (src/mixin/VdomLifecycle.mjs:378)

Independently reproduced on a second origin (localhost:8080, different browser engine, virgin IndexedDB, fresh SharedWorker), where it threw the same error at initVnode on neo-component-34. Two origins, two browsers, empty vs. persisted storage, same defect — this falsifies caching, stale SharedWorker, and stale persisted topology as causes. Live-open sweep: latest 20 open issues checked at 2026-09-09T17:18:40Z, no equivalent found.

The Problem

Runtime state of the topology bar on the operator's instance:

probe result
query_vdom({id: 'neo-toolbar-1'}) vdom.cn = 5 children: neo-button-33, neo-button-34, neo-component-35, neo-button-35, neo-button-36
find_instances({parentId: 'neo-toolbar-1'}) 2 live instances — neo-button-33 (Save workspace), neo-button-34 (Close workspace)
get_component_tree neo-component-35 absent from the tree entirely

Three of the five vdom children reference components that no longer exist. VDom.getVdom throws on the first one it maps.

The user-visible consequences are all downstream of that one throw: the viewport's vdom sync aborts, so class and style deltas never reach the DOM (no dock styling), projected content never lands (empty grids), and Undo/Redo are gone from the topology bar because they were destroyed outright.

Why the observation is intermittent: the throw happens on a viewport-level sync. Whether a given interaction is visibly broken depends on whether it needed a viewport-rooted update, which is why it reads as "sometimes".

The Architectural Reality

The view declares two things on one config (apps/workstation/view/Workspace.mjs, createTopologyBar()):

{
    ntype  : 'toolbar',
    actions: [{action: 'undo', ...}, {action: 'redo', ...}],
    items  : [{ntype: 'button', handler: 'saveTopology',  text: 'Save workspace'},
              {ntype: 'button', handler: 'closeTopology', text: 'Close workspace'}],
    reference: 'topology-toolbar'
}

toolbar.Base:201 merges ...me.createActionItemConfigs() into items. That method (toolbar/Base.mjs:490) returns [this.createActionSpacerConfig(), ...configs], and the spacer (toolbar/Base.mjs:530) is {module: Component, cls: ['neo-toolbar-action-spacer'], flex: 1, isToolbarActionSpacer: true} — a plain Neo.component.Base, hence the neo-component-* id.

So the live item list is [Save, Close, spacer, Undo, Redo] — five, not two.

WorkspaceController.syncTopologyBar() (apps/workstation/view/WorkspaceController.mjs:148) assumes two:

bar.items.slice(me.constructor.authoredTopologyButtonCount).forEach(item => item.destroy());
bar.items.length = me.constructor.authoredTopologyButtonCount;
recovery.length && bar.add(recovery);

Two independent faults compound:

  1. slice(2) selects toolbar-owned items. authoredTopologyButtonCount = 2 counts what the view wrote in items, but the array it indexes also contains what the toolbar materialised from actions. The spacer and both action buttons are destroyed.
  2. Neither line maintains the vdom. component.Base#destroy(updateParentVdom=false, ...) defaults its first parameter to false, so nothing removes the child's node from the parent. bar.items.length = 2 is a raw array truncation that bypasses container.Base#removeAt, the only path that splices items and getVdomItemsRoot().cn together (container/Base.mjs:945-947). items.length = appears exactly twice in the repo; the other (list/Buffered.mjs:840) trims a recycling pool that owns no vdom.

The docblock above the declaration asserts a guarantee the code does not hold:

// Exactly WorkspaceController.authoredTopologyButtonCount items: the controller replaces everything after them and must not be able to destroy these.

It is true of the two authored buttons and false of everything the toolbar adds after them.

The engine already owns this operation. toolbar.Base flags every item it owns (isToolbarAction, isToolbarActionSpacer, isToolbarActionContribution) and exposes getActionItems(), getActionSpacer(), getAction(name), addActionContribution() / removeActionContribution(), and syncActions() — the last documented as replacing the action group "while preserving every ordinary item", implemented over removeAt. A consumer that needs to maintain its own subset of a toolbar's items has an identity-based vocabulary available and does not need index arithmetic.

Why CI is green

test/playwright/unit/apps/workstation/WorkspaceController.spec.mjs:129 builds its own toolbar:

const bar = Neo.create(Toolbar, {
    items    : [{ntype: 'button', handler: 'saveTopology',  text: 'Save workspace'},
                {ntype: 'button', handler: 'closeTopology', text: 'Close workspace'}],
    reference: 'topology-toolbar'
});

No actions. So no spacer, no action items, bar.items.length === 2, slice(2) is empty, and the destroy path never executes in any test. The spec's own comment claims the opposite — "A real toolbar, so bar.add and item.destroy are the engine's and not a double's" — and the toolbar class is real, but the configuration under test is not the one the view ships.

The view's real declaration is asserted in a different file (Workspace.spec.mjs), so no arm anywhere relates authoredTopologyButtonCount to what the toolbar actually materialises. The constant is a claim about another file that nothing checks.

The Fix

syncTopologyBar stops addressing items by position and addresses them by identity.

  1. Tag the derived buttons at creation (isTopologyRecoveryButton: true).
  2. Remove previously-derived buttons with bar.remove(item, true, true) — which routes through removeAt and keeps items and getVdomItemsRoot().cn in step — instead of destroy() + items.length =.
  3. Insert new ones ahead of the action spacer via bar.getActionSpacer(), so the recovery buttons sit with the other ordinary items rather than past a flex: 1 spacer on the far side of Undo/Redo.
  4. Delete WorkspaceController.authoredTopologyButtonCount, its docblock, and the view-side comment that references it. The count exists only to serve the positional arithmetic being removed; identity-based removal cannot drift against the view.

Files: apps/workstation/view/WorkspaceController.mjs, apps/workstation/view/Workspace.mjs (comment only), test/playwright/unit/apps/workstation/WorkspaceController.spec.mjs.

Acceptance Criteria

  • AC-1 — the fixture is the view's declaration, not an analogue. The controller spec builds its toolbar from Workspace.prototype.createTopologyBar() (or asserts equivalence to it), so actions and the spacer are present. This arm must be shown red before the fix and green after — a fixture that cannot reproduce the defect certifies nothing.
  • AC-2 — the toolbar's own items survive a sync. After syncTopologyBar() runs with participants present, bar.getActionSpacer() is non-null and bar.getAction('undo') / bar.getAction('redo') both resolve to live, undestroyed instances.
  • AC-3 — items and vdom.cn stay aligned. After a sync, and again after a resync that removes every participant, bar.items.length === bar.getVdomItemsRoot().cn.length, and every componentId in getVdomItemsRoot().cn resolves through ComponentManager.get(). This is the invariant whose violation produced the reported error, so it is asserted directly rather than via the symptom.
  • AC-4 — the authored head still survives. The existing guarantee holds: a resync that removes every participant leaves saveTopology / closeTopology in place. Retained from #18545's arm, which was correct about the head.
  • AC-5 — authoredTopologyButtonCount is gone. git grep -n authoredTopologyButtonCount returns nothing. Positive control: the AC-2/AC-3 arms pass without it, so removal is demonstrated rather than asserted.
  • AC-6 — the operator's symptom is gone at runtime. Workstation boots with no Component not found for id error in the App Worker console. Verified against a live instance, not only in the unit tier — the unit tier is what missed it.

Out of Scope

  • VDom.syncVdomState:451 maps getVdom over every child before filtering removeDom. A removeDom: true placeholder whose component is gone would throw on the map even though the very next line would discard it. That is a real ordering question but it is not this defect (the orphan here is not removeDom), and the throw is what made this findable. Not touched.
  • Reverting #18546. The view-owns-structure direction is right; only the controller's removal mechanics are wrong.
  • The portal live-preview drag-zone symptom @tobiu reported in the same message. CSS parity was checked and is complete (242 source SCSS files, 0 missing built CSS), so "missing CSS" is unlikely, but no positive finding exists yet. Separate ticket if it survives investigation.
  • WorkspaceSet publishing a membership signal. syncTopologyBar's docblock already records that a participant registered after boot gains no buttons until reload. Unchanged here.

Avoided Traps

  • Raising authoredTopologyButtonCount to 5. Restores the invariant by coincidence and breaks the moment the view adds an item, the toolbar contributes an action, or actions changes length. It also encodes toolbar-internal layout into a consumer constant.
  • Passing destroy(true) to update the parent vdom. Fixes the orphan stubs while still destroying the toolbar's spacer and actions — the symptom goes quiet and Undo/Redo stay gone. Treating the throw as the defect rather than as the detector.

Decision Record impact

none. No ADR governs toolbar item ownership; this restores an existing engine contract rather than changing one.

Related

  • #18545 / #18546 — introduced the shape being repaired
  • #18460 — the parent simplification this was row 1 of
  • defect-note broadcast 2026-09-09T17:19:05Z (MESSAGE:5f8d2225-1a0f-4795-ad19-56c9336c6d9a)

Handoff Retrieval Hints

  • query_raw_memories: "topology toolbar action spacer destroyed orphan componentId vdom sync"
  • Commit anchor: c74a53b6d0 (introduction) — compare apps/workstation/view/WorkspaceController.mjs before/after
  • Runtime probe that produced the finding: query_vdom({id: 'neo-toolbar-1'}) vs find_instances({parentId: 'neo-toolbar-1'}) — a length mismatch between the two is the whole defect

Origin Session ID: 0dd7d644-f279-4b53-a515-522346f5f28f