LearnNewsExamplesServices
Frontmatter
id18152
titleA lock toggle restages the dock shell, so every header in the app repaints
stateClosed
labels
bugaiperformancedashboard
assigneesneo-opus-ada
createdAtSep 3, 2026, 12:55 AM
updatedAtSep 3, 2026, 10:40 AM
githubUrlhttps://github.com/neomjs/neo/issues/18152
authorneo-opus-vega
commentsCount0
parentIssue18151
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 3, 2026, 10:40 AM

A lock toggle restages the dock shell, so every header in the app repaints

Closed v13.2.0/archive-v13-2-0-chunk-18 bugaiperformancedashboard
neo-opus-vega
neo-opus-vega commented on Sep 3, 2026, 12:55 AM

Context

Operator report on a downstream dock host (2026-09-03): clicking the lock action makes every tab-container header in the app flicker — "so I assume all of them update, for triggering an action that affects the content of its tab. serious no-go."

Reproduced and measured headless on the served app. The first sub of #18151: the seam where the engine's declining default is cheapest to fix and most visible.

The Problem

setItemLocked sets one boolean on one item. It changes no node, no split, no edge zone. It nevertheless takes the full staged transaction, because Workspace#getRefreshOptions(descriptor, source) returns {} by default — documented as "the default takes the full staged transaction every time" — and a consumer that does not override it never gets anything narrower.

Measured, one lock click, MutationObserver over the whole app subtree:

TOTAL mutations           : 32
  inside a tab header     :  7   (the locked pane's own header)
  OUTSIDE any header      : 25
  on the dock host        :  4
distinct non-header targets: neo-dashboard-dock-split (childList add/remove pairs),
                             neo-dashboard-dock-edge-row, neo-dashboard-dock-edge-zone,
                             neo-body, dock-flip-item-*, neo-dashboard-dock-query-host

The mechanism is re-parenting, not re-creation — which is why it is easy to miss. Three earlier arms of mine found nothing: header DOM nodes are retained (object permanence holds — 3 of 3 survived a marker stamped on the node), and the surviving headers show zero internal mutations because syncDock*Action writes only on a value change. The churn is one level up: the splits and edge rows have their children removed and re-added, so every retained header is re-parented, and a re-parented node repaints. An observer scoped to the headers cannot see its own cause.

{retainTopology} — "in-place item reconciliation on a proven-identical structural shell" — is exactly the shape this operation needs, and nothing asks for it.

The Architectural Reality

  • src/dashboard/dock/Workspace.mjs — getRefreshOptions(descriptor, source) { return {} }; the JSDoc names {geometryOnly} for a pure resizeSplit, {retainTopology} for item-only deltas, {preserveItemIds} for per-commit owner-held ids.
  • refreshDockWorkspace reads {geometryOnly=false, retainTopology=false} from those options; getReconcileOptions can force retainTopology (forcedRetainTopology ?? retainTopology).
  • src/dashboard/dock/model/Operations.mjs owns the whole vocabulary — addTab, applyDocument, setActiveItem, moveItem, splitNode, moveNode, resizeSplit, resizeEdgeZone, detachItem, closeItem, setItemLocked, setItemPinned, setItemAutoHidden — and is therefore the only place that can classify them honestly.
  • Workspace#handleDockLockAction commits setItemLocked and calls onDockZoneDocumentChange, which schedules the refresh. The consumer under test overrides neither refresh hook, which is the intended-to-be-safe path.

The Fix

Declare the change-class per operation as data beside the reducer that owns it, and let the engine derive the refresh shape from the descriptor it already receives. setItemLocked, setItemPinned and setItemAutoHidden are item-flag deltas; resizeSplit and resizeEdgeZone are geometry; the rest restructure. getRefreshOptions stays as the host override, now layered over a correct default rather than being the only source of truth.

The classification belongs in model/Operations.mjs, not in a Workspace switch: the reducer that implements an operation is what knows what it touched, and co-locating them is what keeps a new operation from silently defaulting to "restage everything" or, worse, to a wrong fast path.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
per-operation change-class src/dashboard/dock/model/Operations.mjs each operation declares topology / geometry / itemFlags beside its reducer an operation without a declaration is treated as topology (today's behaviour) JSDoc one arm per class; an undeclared operation stays full-transaction
Workspace#getRefreshOptions src/dashboard/dock/Workspace.mjs derives from the descriptor's change-class; unchanged signature a host override still wins outright JSDoc override-wins arm, mutation-proven
refreshDockWorkspace refreshOptions same unchanged contract (geometryOnly / retainTopology / preserveItemIds) unchanged JSDoc existing arms stay green

Decision Record impact

aligned-with ADR 0029 — the docking design record's projection and object-permanence rules are what this restores in practice; no authority changes.

Acceptance Criteria

  • AC-1 A setItemLocked commit on a workspace that overrides no refresh hook mutates nothing outside the affected pane's own header — asserted as a mutation count over the whole app subtree, not as a screenshot.
  • AC-2 Red-first: the same witness is red at the current head, reporting non-header mutations on the splits / edge rows. The witness must observe above the headers, since an observer scoped to the headers cannot see re-parenting.
  • AC-3 Every operation in model/Operations.mjs carries a change-class; an operation with none is treated as topology-changing, and a witness proves that fallback for a synthetic undeclared operation.
  • AC-4 setItemPinned / setItemAutoHidden rail round trips and resizeSplit / resizeEdgeZone keep their current behaviour, with the existing dock component suites green at exact head.
  • AC-5 A host override of getRefreshOptions still wins over the derived default, mutation-proven.
  • AC-6 Object permanence is unregressed: retained tab headers and panes keep component identity across a lock toggle, and a marker set on a header DOM node survives it.

Out of Scope

  • The recreate and vessel defaults — sibling seams under #18151.
  • Any change to what lock means (content inert, structural verbs fail closed): presentation and policy are untouched.
  • Consumer-side classification of operations, which is the layering defect #18151 names.

Avoided Traps

  • Scoping the witness to the headers. That is what made three of my own measurement arms report a clean result on a defect the operator could see: node identity survives, internal mutations are zero, and the cause sits one level up. Observe the app subtree.
  • A consumer-side getRefreshOptions switch. It fixes one app, reproduces the defect in every other, and rots as the vocabulary grows.
  • Reading flicker as a repaint-only cosmetic. The re-parenting is real DOM work on every commit; the visual flicker is the symptom, not the cost.
  • Assuming retainTopology is safe for every non-topology operation. It admits in-place reconciliation on a proven-identical shell; the declaration must come from the reducer, not from a guess about the operation's name.

Related

  • Sub of #18151 — the declining-default class this is the first instance of.
  • #18014 AC-1/AC-7/AC-9 (closed) — the default action vector and the single-lifecycle-owner invariant.
  • #18039 (closed) — the post-settle header sweep that walks the settled shell.
  • ADR 0029 — docking design record.

Live latest-open sweep: checked the latest 25 open issues at 2026-09-02T22:12Z plus the A2A claim window; no equivalent ticket and no competing claim.

Origin Session ID: a5db4dc8-310d-4702-9bfa-3780d32e932b

Retrieval Hint: "lock toggle restages dock shell every header repaints getRefreshOptions retainTopology operation change class re-parenting"

tobiu referenced in commit 2be5cb2 - "fix(dock): an operation declares what it can change, so a lock stops restaging the shell (#18154) on Sep 3, 2026, 10:40 AM
tobiu closed this issue on Sep 3, 2026, 10:40 AM
tobiu referenced in commit 874a392 - "fix(dock): restoreTab declares its change class, so dev goes green (#18175) (#18176) on Sep 3, 2026, 11:10 AM