Context
Leaf of #18762. Found while probing @neo-fable's unowned defect-note of 13:22Z (cellEditing turned on at runtime on a mounted grid opens a session that never embodies). I did not reproduce her sighting — see What this ticket is not below. I did find a different, reproducible defect in the same method.
Measured at dev@0e3fb712ff, real Chromium, on examples/grid/container — a plain grid that ships with cellEditing: false — driven through Neo.worker.App.setConfigs.
The Problem
grid.Container#cellEditing is a reactive config, so an app may turn cell editing on and off at runtime. Turning it off does nothing, and turning it back on adds a second plugin.
| step |
cellEditing |
plugin …-1 |
plugin …-2 |
| boot |
false |
absent |
absent |
set true |
true |
registered, disabled: false |
absent |
set false |
false |
still registered, still disabled: false |
absent |
set true again |
true |
registered, disabled: false |
registered, disabled: false |
Two consequences, one cause:
cellEditing: false is a no-op. The config reads false while the plugin remains registered and enabled. An app that switches editing off has not switched it off.
- Every off→on cycle pushes another plugin instance. N cycles leave N+1 live plugins on one grid, each with its own
session field.
The second bears directly on this epic's terminal predicate. #18762 states "One editor instance per session; commit or cancel destroys it", and the guide states "One editor exists at a time. Activating another cell commits the current draft first." Two plugin instances each own an independent session, so the invariant is held by each plugin separately and by neither of them jointly.
The Architectural Reality
src/grid/Container.mjs:374-392:
afterSetCellEditing(value, oldValue) {
if (value) {
import('./plugin/CellEditing.mjs').then(module => {
let me = this,
{appName} = me,
plugins = me.plugins || [];
plugins.push({module: module.default, appName});
me.plugins = plugins;
me.repaintCells()
})
}
}The whole body is one if (value). There is no else, so nothing tears the plugin down; and there is no check for an already-present plugin, so the push is unconditional. oldValue is declared in the signature and never read — the parameter that would distinguish "first enable" from "re-enable" is right there, unused.
The plugin itself already has the vocabulary for both halves: disabled_ with an afterSetDisabled that cancels an open edit (src/grid/plugin/CellEditing.mjs:47-71), and a 'destroy' cancel reason documented at :82 for "the plugin went, its grid lives on". So the teardown semantics this method needs are defined and unused from here.
The Fix
Make afterSetCellEditing idempotent and give it its off branch, in src/grid/Container.mjs:
- On
true: if a grid-cell-editing plugin is already present, do not push another — re-enable the existing one.
- On
false: stop editing. The plugin's own disabled config already cancels an open edit and refuses new ones, so the minimal correct behaviour is reachable without teardown; destroying the plugin is the alternative and is what the 'destroy' cancel reason anticipates. The implementer picks one and states it in the AC.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
grid.Container#cellEditing_ (public reactive config, Container.mjs:118-121) |
its own JSDoc + #18762's one-editor predicate |
reactive both ways; idempotent on repeat |
today: false is inert, repeats accumulate plugins |
learn/guides/datahandling/Grids.md "Turning It On" documents only construction-time use |
the table above |
grid.plugin.CellEditing#disabled_ (CellEditing.mjs:47-50) |
afterSetDisabled → cancelEdit('disabled') at :65-71 |
the off branch's mechanism, if teardown is not chosen |
n/a |
— |
measured today: setting it cancels an open edit, discards the draft, returns focus to the View |
Decision Record impact
none — restores the declared behaviour of one reactive config.
Acceptance Criteria
- AC-1 After
cellEditing is set to false at runtime, a double-click on a previously editable cell starts no edit. Red on dev today.
- AC-2 Setting
cellEditing true → false → true leaves exactly one plugin-grid-cell-editing instance on the grid. Red on dev today (two instances).
- AC-3 Setting
cellEditing: true twice in a row, with no intervening false, also leaves exactly one instance.
- AC-4
After the off→on cycle, a double-click opens exactly one editor. — moved to #18966.
- AC-5
An edit open when cellEditing goes false is cancelled, not committed. — moved to #18966.
- AC-6 Mutation-proven: reverting the idempotence guard reddens AC-2/AC-3; reverting the off branch reddens AC-1.
AC-4 and AC-5 moved, and why (2026-09-19, by this ticket's author, before the PR opened): both describe a live edit session, which the single-thread unit harness cannot mount — creating one drives form.field.Text into registerPreventDefaultKeys, a Neo.main.DomEvents call with no main thread behind it. Measured: the arm's assertions passed and teardown then threw TypeError: Cannot read properties of undefined (reading 'registerPreventDefaultKeys'). They belong on the grid-cell-editing component fixture and now live in #18966, on the #18863 → #18927 pattern. AC-1 stayed here because the refusal is assertable without a session: startEdit returns false while the plugin is disabled.
Out of Scope
@neo-fable's runtime-enable sighting (below) — it may or may not share this cause, and I will not claim it does. The lazy-import timing itself, which the existing repaintCells() comment already addresses. Any change to plugin.Base's lifecycle generally.
What this ticket is NOT — the honest bound
@neo-fable's note describes a first runtime enable on apps/workstation's feed grid where the session opens but never embodies. I did not reproduce that, and this ticket is not it. Two live plugins is a plausible mechanism for a session that one instance opens and another never renders, but her sighting has no toggle in it, so the mechanisms are not the same until someone shows they are.
What blocked the faithful repro, so the next seat does not rediscover it: examples/grid/container's columns carry no editable, and with the Neural Link bridge down (BRIDGE_EXIT_1, neo-agent-brain#371) there is no main-thread path to set a column config — Neo.worker.App.getConfigs reads configs but cannot reach column instances by id, and asking the grid for plugins or columns hangs the call. A seat with a working Neural Link, or an example with editable columns and cellEditing: false at construction, closes that gap cheaply.
Avoided Traps
- Do not read AC-2 as the whole defect. An instance count is easy to satisfy and easy to regress without anyone noticing; AC-4 is what a user would feel.
- Do not assume
disabled: true on the plugin equals cellEditing: false on the container. They are different surfaces today, and the ticket's point is that the container's one is inert.
- Do not fold @neo-fable's sighting in to make this ticket bigger. One measured cause, one ticket.
Related
- #18762 — parent epic; its one-editor predicate is what AC-4 protects.
- @neo-fable's 13:22Z defect-note — adjacent, unreproduced here, still unowned.
neomjs/neo-agent-brain#371 — the Neural Link bridge failure that bounded this probe.
Handoff Retrieval Hints
Retrieval Hint: "grid Container cellEditing runtime toggle pushes a second plugin, false is inert" · source anchor src/grid/Container.mjs:374-392 · measured at dev@0e3fb712ff.
Context
Leaf of #18762. Found while probing @neo-fable's unowned defect-note of 13:22Z (
cellEditingturned on at runtime on a mounted grid opens a session that never embodies). I did not reproduce her sighting — see What this ticket is not below. I did find a different, reproducible defect in the same method.Measured at
dev@0e3fb712ff, real Chromium, onexamples/grid/container— a plain grid that ships withcellEditing: false— driven throughNeo.worker.App.setConfigs.The Problem
grid.Container#cellEditingis a reactive config, so an app may turn cell editing on and off at runtime. Turning it off does nothing, and turning it back on adds a second plugin.cellEditing…-1…-2falsetruetruedisabled: falsefalsefalsedisabled: falsetrueagaintruedisabled: falsedisabled: falseTwo consequences, one cause:
cellEditing: falseis a no-op. The config readsfalsewhile the plugin remains registered and enabled. An app that switches editing off has not switched it off.sessionfield.The second bears directly on this epic's terminal predicate. #18762 states "One editor instance per session; commit or cancel destroys it", and the guide states "One editor exists at a time. Activating another cell commits the current draft first." Two plugin instances each own an independent
session, so the invariant is held by each plugin separately and by neither of them jointly.The Architectural Reality
src/grid/Container.mjs:374-392:afterSetCellEditing(value, oldValue) { if (value) { import('./plugin/CellEditing.mjs').then(module => { let me = this, {appName} = me, plugins = me.plugins || []; plugins.push({module: module.default, appName}); me.plugins = plugins; // The plugin arrives after an import, so rows may have rendered without it me.repaintCells() }) } }The whole body is one
if (value). There is noelse, so nothing tears the plugin down; and there is no check for an already-present plugin, so thepushis unconditional.oldValueis declared in the signature and never read — the parameter that would distinguish "first enable" from "re-enable" is right there, unused.The plugin itself already has the vocabulary for both halves:
disabled_with anafterSetDisabledthat cancels an open edit (src/grid/plugin/CellEditing.mjs:47-71), and a'destroy'cancel reason documented at:82for "the plugin went, its grid lives on". So the teardown semantics this method needs are defined and unused from here.The Fix
Make
afterSetCellEditingidempotent and give it its off branch, insrc/grid/Container.mjs:true: if agrid-cell-editingplugin is already present, do not push another — re-enable the existing one.false: stop editing. The plugin's owndisabledconfig already cancels an open edit and refuses new ones, so the minimal correct behaviour is reachable without teardown; destroying the plugin is the alternative and is what the'destroy'cancel reason anticipates. The implementer picks one and states it in the AC.Contract Ledger Matrix
grid.Container#cellEditing_(public reactive config,Container.mjs:118-121)falseis inert, repeats accumulate pluginslearn/guides/datahandling/Grids.md"Turning It On" documents only construction-time usegrid.plugin.CellEditing#disabled_(CellEditing.mjs:47-50)afterSetDisabled→cancelEdit('disabled')at:65-71Decision Record impact
none— restores the declared behaviour of one reactive config.Acceptance Criteria
cellEditingis set tofalseat runtime, a double-click on a previously editable cell starts no edit. Red ondevtoday.cellEditingtrue→false→trueleaves exactly oneplugin-grid-cell-editinginstance on the grid. Red ondevtoday (two instances).cellEditing: truetwice in a row, with no interveningfalse, also leaves exactly one instance.After the off→on cycle, a double-click opens exactly one editor.— moved to #18966.An edit open when— moved to #18966.cellEditinggoesfalseis cancelled, not committed.AC-4 and AC-5 moved, and why (2026-09-19, by this ticket's author, before the PR opened): both describe a live edit session, which the single-thread unit harness cannot mount — creating one drives
form.field.TextintoregisterPreventDefaultKeys, aNeo.main.DomEventscall with no main thread behind it. Measured: the arm's assertions passed and teardown then threwTypeError: Cannot read properties of undefined (reading 'registerPreventDefaultKeys'). They belong on thegrid-cell-editingcomponent fixture and now live in #18966, on the #18863 → #18927 pattern. AC-1 stayed here because the refusal is assertable without a session:startEditreturnsfalsewhile the plugin is disabled.Out of Scope
@neo-fable's runtime-enable sighting (below) — it may or may not share this cause, and I will not claim it does. The lazy-import timing itself, which the existing
repaintCells()comment already addresses. Any change toplugin.Base's lifecycle generally.What this ticket is NOT — the honest bound
@neo-fable's note describes a first runtime enable on
apps/workstation's feed grid where the session opens but never embodies. I did not reproduce that, and this ticket is not it. Two live plugins is a plausible mechanism for a session that one instance opens and another never renders, but her sighting has no toggle in it, so the mechanisms are not the same until someone shows they are.What blocked the faithful repro, so the next seat does not rediscover it:
examples/grid/container's columns carry noeditable, and with the Neural Link bridge down (BRIDGE_EXIT_1,neo-agent-brain#371) there is no main-thread path to set a column config —Neo.worker.App.getConfigsreads configs but cannot reach column instances by id, and asking the grid forpluginsorcolumnshangs the call. A seat with a working Neural Link, or an example witheditablecolumns andcellEditing: falseat construction, closes that gap cheaply.Avoided Traps
disabled: trueon the plugin equalscellEditing: falseon the container. They are different surfaces today, and the ticket's point is that the container's one is inert.Related
neomjs/neo-agent-brain#371— the Neural Link bridge failure that bounded this probe.Handoff Retrieval Hints
Retrieval Hint: "grid Container cellEditing runtime toggle pushes a second plugin, false is inert"· source anchorsrc/grid/Container.mjs:374-392· measured atdev@0e3fb712ff.