@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:
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()):
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.
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.
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 itemsandgetVdomItemsRoot().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:
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.
Tag the derived buttons at creation (isTopologyRecoveryButton: true).
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 =.
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.
DeleteWorkspaceController.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.
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
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
Context
@tobiu pulled
dev, ranbuild all, openedapps/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:Independently reproduced on a second origin (
localhost:8080, different browser engine, virgin IndexedDB, fresh SharedWorker), where it threw the same error atinitVnodeonneo-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:
query_vdom({id: 'neo-toolbar-1'})vdom.cn= 5 children:neo-button-33,neo-button-34,neo-component-35,neo-button-35,neo-button-36find_instances({parentId: 'neo-toolbar-1'})neo-button-33(Save workspace),neo-button-34(Close workspace)get_component_treeneo-component-35absent from the tree entirelyThree of the five vdom children reference components that no longer exist.
VDom.getVdomthrows 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:201merges...me.createActionItemConfigs()intoitems. 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 plainNeo.component.Base, hence theneo-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:
slice(2)selects toolbar-owned items.authoredTopologyButtonCount = 2counts what the view wrote initems, but the array it indexes also contains what the toolbar materialised fromactions. The spacer and both action buttons are destroyed.component.Base#destroy(updateParentVdom=false, ...)defaults its first parameter tofalse, so nothing removes the child's node from the parent.bar.items.length = 2is a raw array truncation that bypassescontainer.Base#removeAt, the only path that splicesitemsandgetVdomItemsRoot().cntogether (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:
It is true of the two authored buttons and false of everything the toolbar adds after them.
The engine already owns this operation.
toolbar.Baseflags every item it owns (isToolbarAction,isToolbarActionSpacer,isToolbarActionContribution) and exposesgetActionItems(),getActionSpacer(),getAction(name),addActionContribution()/removeActionContribution(), andsyncActions()— the last documented as replacing the action group "while preserving every ordinary item", implemented overremoveAt. 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:129builds 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, sobar.addanditem.destroyare 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 relatesauthoredTopologyButtonCountto what the toolbar actually materialises. The constant is a claim about another file that nothing checks.The Fix
syncTopologyBarstops addressing items by position and addresses them by identity.isTopologyRecoveryButton: true).bar.remove(item, true, true)— which routes throughremoveAtand keepsitemsandgetVdomItemsRoot().cnin step — instead ofdestroy()+items.length =.bar.getActionSpacer(), so the recovery buttons sit with the other ordinary items rather than past aflex: 1spacer on the far side of Undo/Redo.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
Workspace.prototype.createTopologyBar()(or asserts equivalence to it), soactionsand 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.syncTopologyBar()runs with participants present,bar.getActionSpacer()is non-null andbar.getAction('undo')/bar.getAction('redo')both resolve to live, undestroyed instances.itemsandvdom.cnstay aligned. After a sync, and again after a resync that removes every participant,bar.items.length === bar.getVdomItemsRoot().cn.length, and everycomponentIdingetVdomItemsRoot().cnresolves throughComponentManager.get(). This is the invariant whose violation produced the reported error, so it is asserted directly rather than via the symptom.saveTopology/closeTopologyin place. Retained from #18545's arm, which was correct about the head.authoredTopologyButtonCountis gone.git grep -n authoredTopologyButtonCountreturns nothing. Positive control: the AC-2/AC-3 arms pass without it, so removal is demonstrated rather than asserted.Component not found for iderror 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:451mapsgetVdomover every child before filteringremoveDom. AremoveDom: trueplaceholder whose component is gone would throw on themapeven though the very next line would discard it. That is a real ordering question but it is not this defect (the orphan here is notremoveDom), and the throw is what made this findable. Not touched.WorkspaceSetpublishing a membership signal.syncTopologyBar's docblock already records that a participant registered after boot gains no buttons until reload. Unchanged here.Avoided Traps
authoredTopologyButtonCountto 5. Restores the invariant by coincidence and breaks the moment the view adds an item, the toolbar contributes an action, oractionschanges length. It also encodes toolbar-internal layout into a consumer constant.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
defect-notebroadcast 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"c74a53b6d0(introduction) — compareapps/workstation/view/WorkspaceController.mjsbefore/afterquery_vdom({id: 'neo-toolbar-1'})vsfind_instances({parentId: 'neo-toolbar-1'})— a length mismatch between the two is the whole defectOrigin Session ID: 0dd7d644-f279-4b53-a515-522346f5f28f