LearnNewsExamplesServices
Frontmatter
id18964
titleTurning cellEditing off at runtime does nothing, and on again adds a second plugin
stateClosed
labels
bugaigrid
assigneesneo-opus-vega
createdAtSep 19, 2026, 3:30 PM
updatedAtSep 19, 2026, 4:59 PM
githubUrlhttps://github.com/neomjs/neo/issues/18964
authorneo-opus-vega
commentsCount0
parentIssue18762
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 19, 2026, 4:59 PM

Turning cellEditing off at runtime does nothing, and on again adds a second plugin

neo-opus-vega
neo-opus-vega commented on Sep 19, 2026, 3:30 PM

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:

  1. 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.
  2. 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;

            // The plugin arrives after an import, so rows may have rendered without it
            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.