Measured 2026-09-04 at dev@205bc52f8a while checking D#18150's "header object permanence" step before it became a ticket (receipt: https://github.com/neomjs/neo/discussions/18150#discussioncomment-18289767). Header chrome is already instance-stable across every commit class. What every commit does destroy is a set of components that never reach the DOM.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-04T13:28Z; no equivalent found (#18208 is the Workstation's override of the item-only refresh; #18236, closed not-planned, was the comparator premise). A2A claim sweep over the last 30 messages: no competing claim; lane claimed 13:27Z.
The Problem
Unit tier, a two-node document with three items, every component.Base construct and destroy counted with its creator stack:
commit
change class
components destroyed
of which stand-ins
setActiveItem (a tab click)
topology
8
3
setItemLocked
itemFlags
3
3
addTab
topology
9
4
closeItem
topology
10
3
setActiveItem on the already active item (a no-op)
topology
8
3
The stand-ins are one Neo.create({module: Component, header, hidden: true})per document item per commit, created hidden, never mounted, then destroyed. On the itemFlags path they are the whole cost. A consumer with twelve items pays twelve constructions and destructions per lock click, for a DOM delta of one class.
The Architectural Reality
Workspace#refreshDockWorkspace hands projectDockModel an item resolver that constructs a stand-in for every item (src/dashboard/dock/Workspace.mjs:3796–3802) through createProjectionPlaceholder (:1562–1568); the adapter calls it from LayoutAdapter.projectItem (src/dashboard/dock/projection/LayoutAdapter.mjs:915–930) via context.resolveComponentRef(componentRef, item, itemId), without the node id.
For a retained tab node the reconciler discards the whole projected tabs config behind a geometry placeholder (Reconciler.prepareTabChromeProjection, src/dashboard/dock/projection/Reconciler.mjs:271–312), so the stand-ins built for its items never enter a parent. The staged path then retires them (:407–420): "Pane placeholders inside a discarded config for a retained tab node never enter a parent. Retire them now; only genuinely new tab nodes need projected placeholders for pairing." The stable path retires them at :252–255. Both sweeps are the reconciler's contract towards any caller that hands it a full map — DockProjectionReconciler.spec.mjs:379–436 asserts it directly — so they stay; on the workspace path they become no-ops.
reconcileTabChrome treats a missing stand-in as a normal state: resolve(itemId) prefers the live pane (:720–726) and materialises a new one only when nothing is live (:764–814). Retained nodes reach that branch on every commit today, because their stand-ins were already destroyed.
Stand-ins ARE load-bearing for a new tab node: the staged container is built from the config, and the stand-in's index pairs the projected tab button with the live pane that replaces it (:774–789).
The Fix
Construct a stand-in only for an item whose tabs node is not retained — a caller-side change; the reconciler is untouched.
LayoutAdapter.projectTabsNode passes its nodeId into projectItem, which passes it as a fourth argument to context.resolveComponentRef.
refreshDockWorkspace collects the current shell's tab node ids (Reconciler.collectProjectedTabs(currentShell)) before projecting; its resolver returns null for an item in a retained node, so projectItem falls through to the item's persisted blueprint when present, otherwise to createPlaceholder(itemId, item) — a config either way, nothing constructed — which the reconciler discards with the tabs config as before.
Docblocks of createProjectionPlaceholder, projectItem and the resolveComponentRef option say when a stand-in exists and why.
Called as (componentRef, item, itemId, nodeId); nodeId is the tabs node the item projects into and is undefined on the rail-reveal path. A 1–3-argument consumer is unchanged: the extra argument is ignored. A null answer falls through to item.blueprint when present, otherwise to createPlaceholder(itemId, item) — neither constructs an instance.
Today's behaviour for every existing consumer; Workspace#resolveProjectedPane still answers the default resolver
LayoutAdapter.mjs option and parameter JSDoc; Workspace#createProjectionPlaceholder docblock
test/playwright/unit/dashboard/DockProjectionStandIns.spec.mjs: zero stand-ins for retained nodes across four commit classes; exactly one for the single item of a splitNode-minted node
Invoked only for an item inside a tabs node the current shell does not render; a retained node's items never reach it. Signature and return unchanged.
An override still shapes every stand-in that IS created
createProjectionPlaceholder docblock
same spec — the lock arm asserts zero invocations, the split arm exactly one
Acceptance Criteria
AC-1 — setItemLocked on a two-node fixture destroys zero components (today: 3), measured through a component.Base destroy counter in a unit arm.
AC-2 — setActiveItem, addTab and closeItem construct zero stand-ins for items whose tabs node is retained; createProjectionPlaceholder is called exactly once per item of a new tab node (a splitNode that mints one), so pairing in reconcileTabChrome is unchanged.
AC-3 — the header chrome census holds: tab container, header toolbar, tab buttons and action buttons keep their instance ids across all five commit classes above.
AC-4 — the reconciler is not modified; the source delta is the resolver predicate, the node id parameter and their docs, with no forwarding layer or second code path.
AC-5 — no regression against the exact base: DockWorkspace, DockLockAction, DockProjectionReconciler and DockLayoutAdapter unit suites stay green; every examples/dashboard/crossWindow and Workstation dock journey arm that is green on the base commit is green on the branch, and any arm red on the base is recorded with a durable owner rather than claimed green.
Out of Scope
The staged shell itself (edge-zone and split containers, the splitter, the two projection placeholders) built on every topology commit — that is retainTopology as the default admission request and waits on D#18224's rail reconcile decision (cut 2 on D#18150).
The reconciler's placeholder sweeps: they are its contract towards direct callers and stay.
Any change to toolbar.Base#syncActions or headerActions handling: header permanence is already true and measured.
The tear-out cluster.
Avoided Traps
Skipping stand-ins for items that merely have a live pane. A live pane moving into a new node still needs the stand-in for button pairing (:774–789); the retention of the NODE is the correct predicate, and the reconciler's own comment names it.
Deleting the reconciler's retire sweeps as "dead code". They are dead on the workspace path after this change, but DockProjectionReconciler.spec.mjs:432 asserts them for a caller that hands in a full map; deleting them changes a static utility's contract for one caller's convenience.
Fixing the comparator.#18236 tried to make isEqual treat functions by identity and was closed not-planned; the churn here is constructed instances, not change detection.
Decision Record impact
aligned-with ADR 0029 — same-instance projection is the ADR's rule; this removes constructions that never became instances of anything visible. No schema or wire change.
Context
Measured 2026-09-04 at
dev@205bc52f8awhile checking D#18150's "header object permanence" step before it became a ticket (receipt: https://github.com/neomjs/neo/discussions/18150#discussioncomment-18289767). Header chrome is already instance-stable across every commit class. What every commit does destroy is a set of components that never reach the DOM.Live latest-open sweep: checked the latest 20 open issues at 2026-09-04T13:28Z; no equivalent found (#18208 is the Workstation's override of the item-only refresh; #18236, closed not-planned, was the comparator premise). A2A claim sweep over the last 30 messages: no competing claim; lane claimed 13:27Z.
The Problem
Unit tier, a two-node document with three items, every
component.Baseconstruct and destroy counted with its creator stack:setActiveItem(a tab click)setItemLockedaddTabcloseItemsetActiveItemon the already active item (a no-op)The stand-ins are one
Neo.create({module: Component, header, hidden: true})per document item per commit, created hidden, never mounted, then destroyed. On theitemFlagspath they are the whole cost. A consumer with twelve items pays twelve constructions and destructions per lock click, for a DOM delta of one class.The Architectural Reality
Workspace#refreshDockWorkspacehandsprojectDockModelan item resolver that constructs a stand-in for every item (src/dashboard/dock/Workspace.mjs:3796–3802) throughcreateProjectionPlaceholder(:1562–1568); the adapter calls it fromLayoutAdapter.projectItem(src/dashboard/dock/projection/LayoutAdapter.mjs:915–930) viacontext.resolveComponentRef(componentRef, item, itemId), without the node id.Reconciler.prepareTabChromeProjection,src/dashboard/dock/projection/Reconciler.mjs:271–312), so the stand-ins built for its items never enter a parent. The staged path then retires them (:407–420): "Pane placeholders inside a discarded config for a retained tab node never enter a parent. Retire them now; only genuinely new tab nodes need projected placeholders for pairing." The stable path retires them at:252–255. Both sweeps are the reconciler's contract towards any caller that hands it a full map —DockProjectionReconciler.spec.mjs:379–436asserts it directly — so they stay; on the workspace path they become no-ops.reconcileTabChrometreats a missing stand-in as a normal state:resolve(itemId)prefers the live pane (:720–726) and materialises a new one only when nothing is live (:764–814). Retained nodes reach that branch on every commit today, because their stand-ins were already destroyed.:774–789).The Fix
Construct a stand-in only for an item whose tabs node is not retained — a caller-side change; the reconciler is untouched.
LayoutAdapter.projectTabsNodepasses itsnodeIdintoprojectItem, which passes it as a fourth argument tocontext.resolveComponentRef.refreshDockWorkspacecollects the current shell's tab node ids (Reconciler.collectProjectedTabs(currentShell)) before projecting; its resolver returnsnullfor an item in a retained node, soprojectItemfalls through to the item's persistedblueprintwhen present, otherwise tocreatePlaceholder(itemId, item)— a config either way, nothing constructed — which the reconciler discards with the tabs config as before.createProjectionPlaceholder,projectItemand theresolveComponentRefoption say when a stand-in exists and why.Contract Ledger Matrix
LayoutAdapter.project({resolveComponentRef})callbackLayoutAdapter.projectItem(src/dashboard/dock/projection/LayoutAdapter.mjs:915–930)(componentRef, item, itemId, nodeId);nodeIdis the tabs node the item projects into and isundefinedon the rail-reveal path. A 1–3-argument consumer is unchanged: the extra argument is ignored. Anullanswer falls through toitem.blueprintwhen present, otherwise tocreatePlaceholder(itemId, item)— neither constructs an instance.Workspace#resolveProjectedPanestill answers the default resolverLayoutAdapter.mjsoption and parameter JSDoc;Workspace#createProjectionPlaceholderdocblocktest/playwright/unit/dashboard/DockProjectionStandIns.spec.mjs: zero stand-ins for retained nodes across four commit classes; exactly one for the single item of asplitNode-minted nodeWorkspace#createProjectionPlaceholder(itemId, item, componentRef)Workspace#refreshDockWorkspaceitem resolver (src/dashboard/dock/Workspace.mjs:3796–3812)createProjectionPlaceholderdocblockAcceptance Criteria
setItemLockedon a two-node fixture destroys zero components (today: 3), measured through acomponent.Basedestroy counter in a unit arm.setActiveItem,addTabandcloseItemconstruct zero stand-ins for items whose tabs node is retained;createProjectionPlaceholderis called exactly once per item of a new tab node (asplitNodethat mints one), so pairing inreconcileTabChromeis unchanged.DockWorkspace,DockLockAction,DockProjectionReconcilerandDockLayoutAdapterunit suites stay green; everyexamples/dashboard/crossWindowand Workstation dock journey arm that is green on the base commit is green on the branch, and any arm red on the base is recorded with a durable owner rather than claimed green.Out of Scope
retainTopologyas the default admission request and waits on D#18224's rail reconcile decision (cut 2 on D#18150).toolbar.Base#syncActionsorheaderActionshandling: header permanence is already true and measured.Avoided Traps
:774–789); the retention of the NODE is the correct predicate, and the reconciler's own comment names it.DockProjectionReconciler.spec.mjs:432asserts them for a caller that hands in a full map; deleting them changes a static utility's contract for one caller's convenience.isEqualtreat functions by identity and was closed not-planned; the churn here is constructed instances, not change detection.Decision Record impact
aligned-with ADR 0029— same-instance projection is the ADR's rule; this removes constructions that never became instances of anything visible. No schema or wire change.Related
itemFlagsfast path this cut completesOrigin Session ID: 4384828f-2650-4d28-b650-fac55eebec59
Retrieval Hint:
dock projection stand-in per item per commit createProjectionPlaceholder retained tab node churn census