LearnNewsExamplesServices
Frontmatter
id18217
titleCell editing is dead on a grid: no gesture can mount an editor
stateClosed
labels
bugaitestingcoregrid
assigneesneo-opus-vega
createdAtSep 3, 2026, 5:35 PM
updatedAtSep 3, 2026, 8:18 PM
githubUrlhttps://github.com/neomjs/neo/issues/18217
authorneo-opus-vega
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 3, 2026, 8:18 PM

Cell editing is dead on a grid: no gesture can mount an editor

Closed Backlog/active-chunk-20 bugaitestingcoregrid
neo-opus-vega
neo-opus-vega commented on Sep 3, 2026, 5:35 PM

Context

Filed from a downstream consumer whose inline-editing e2e arm had been skipping for lack of data; once it ran it failed 4/4. The original filing named the keyboard path as the defect. Building the engine-side reproducer found a larger one underneath it, and this body is rewritten around the measured cause — the earlier framing is preserved in the comment history.

Cell editing is non-functional on a grid by every gesture, not only by keyboard. The engine ships examples/grid/cellEditing/ (cellEditing: true, editable columns, editors), and it cannot mount an editor.

New in-repo reproducer: test/playwright/component/grid/CellEditingKeys.spec.mjs on fixture component/apps/grid-cell-editing. Measured at ca83102346: 4 passed / 3 failed — and the three failures are double-click, Enter and Space, i.e. every activation path there is.

The Problem — three independent breaks, in the order a gesture meets them

1. getCellId has incompatible signatures in the two Body classes, and the shared plugin calls the table's.

// src/table/plugin/CellEditing.mjs:114 — the shared base
cellId = body.getCellId(record, dataField)
signature
table.Body.getCellId (:419-423) @param {Object} record
grid.Body.getCellId (:966-970) @param {Number} rowIndex

On a grid the base therefore passes a record object where a row index is expected. grid.Body.getCellId derives a pooled id through getRowId(rowIndex) → `${me.id}__row-${rowIndex % poolSize}` — an object modulo a number is NaN, so the id is garbage, VdomUtil.find(body.vdom, cellId) resolves nothing, and mountEditor cannot find the cell it is meant to cover. This alone kills every gesture, which is why the double-click control fails.

This is also the deeper form of the same divergence the DOM shows: a grid's cell ids are pool-slot based (…__row-0__cell-0), while table.Body ids are record-based. The plugin was written against the latter.

2. The activation keys are registered on a component the keydown never reaches.

NEO_CODE_BLOCK_1

manager.DomEvent routes by walking the event path upward (ComponentManager.getParentPath), so a component's listener fires only when that component is on the target's ancestor path. Focus lives on grid.View and the bodies are its descendants — a body listener is never on the path. And grid/View.mjs:52 already declares the right home: "grid.View is the single key registry, the keyboard half of" the View-owned contract. selection.grid.CellModel:188 and ColumnModel:135 both comply by pushing onto view.keys._keys; CellEditing is the only key registration in the grid stack aimed at the body.

3. The guard reads focus where it means selection.

NEO_CODE_BLOCK_2

target is the focused element; neo-selected lands on the cell. Measured in a real browser: exactly one element inside a grid container declares tabindex, and it is grid.View (a cell's element.tabIndex reads -1 because that is the default for a non-focusable element — its tabindex attribute is null). So the guard can never hold on a grid.

And the base already anticipated this. onSelectionChange is an empty handler carrying:

NEO_CODE_BLOCK_3

#15195 performed that separation — it made grid.View the single focus anchor. The precondition in the todo has been met and nobody returned to it.

The Architectural Reality

  • src/table/plugin/CellEditing.mjs — the only implementation; grid/plugin/CellEditing.mjs is 34 lines of config (cellCls, editorCls, focusCells) and inherits every behaviour above.
  • Break 1 is a contract divergence, not a typo: two Body classes, one method name, two meanings. Any shared consumer of getCellId is exposed the same way; this ticket fixes the one caller it can prove.
  • Break 2 is a registry divergence, with the correct registry already documented and already used by two siblings.
  • focusCells governs only the post-dismissal restore — its single use is in selectCell, whose only callers are onEditorKeyEnter and onEditorKeyEscape. It does not establish focus, and its name invites the opposite reading.
  • Zero prior coverage: before this ticket nothing under test/playwright/** referenced onTableKeyDown, CellEditing, or neo-grid-editor, which is how three stacked breaks and a shipped broken example survived.

The Fix

  1. Give the cell-node lookup a signature the grid can satisfy. Either resolve the cell id through a hook each Body implements to its own contract, or have the grid variant translate record → rowIndex before delegating. The base must stop assuming the table's getCellId meaning.
  2. Register the grid variant's activation keys on view.keys, per the registry grid/View.mjs declares and CellModel/ColumnModel already use.
  3. Resolve the target cell from the View-owned selection model (selectionModel.hasSelection(), .items[0], .getRecord(id), view.getDataField(id) — the accessors CellModel#onNavKeyColumn already uses) rather than from the keydown target.

Breaks 2 and 3 are both required: registering on the View alone still fails the neo-selected guard, and fixing the guard alone still never runs. Break 1 is required for either to produce a visible editor.

Contract Ledger Matrix

Target Surface Source of Authority Behavior Fallback Docs Evidence
grid.Container#cellEditing (src/grid/Container.mjs:109) #15195; grid/View.mjs:52 editing works on a grid by double-click AND by Enter/Space none — it works by no gesture today plugin class docs the 7-arm spec, red-first
table.plugin.CellEditing#mountEditor cell lookup table/Body.mjs:419 vs grid/Body.mjs:966 resolves a cell node under both Body contracts n/a method JSDoc naming the divergence double-click control red before, green after
the activation key registry grid/View.mjs:52 grid keys live on view.keys table keeps body.keys unchanged both plugin class docs Enter/Space arms red before, green after

Decision Record impact

none, and explicitly aligned-with #15195. A fix that moves focus onto cells would challenge that contract and is rejected under Avoided Traps.

Acceptance Criteria

  • AC-1 On a grid with cellEditing, a double-click on an editable cell mounts the editor with the cell's value. Red-first: fails on dev today.
  • AC-2 Enter on a selected editable cell mounts the editor. Red-first on dev.
  • AC-3 Space — registered on the same handler — behaves as Enter, or is explicitly declared out of scope with its reason.
  • AC-4 The getCellId signature divergence is resolved so the base makes no assumption about which Body it holds, and table cell editing is proven unchanged by its own arm rather than by inspection.
  • AC-5 A non-editable column mounts nothing, and Enter with no selection mounts nothing — asserted after the fix, because both pass vacuously today (nothing mounts at all, so neither can go red).
  • AC-6 The focus contract is asserted as a control in the same spec: a click lands focus on grid.View, and exactly one element in the grid declares tabindex. Distinguishes this defect from #18065's neighbourhood.
  • AC-7 focusCells' JSDoc states that it governs the post-dismissal restore only.
  • AC-8 examples/grid/cellEditing/ is exercised by eye and reported working, since it is the shipped artifact a reader would try first.

Out of Scope

  • Changing the grid's focus model. #15195 is deliberate; this adapts the plugin to it.
  • Auditing every other getCellId consumer. The divergence is general, and this ticket fixes the caller it can prove. A sweep is worth its own ticket rather than a blind widening here.
  • The editor's own key handling (onEditorKeyEnter / Escape / Tab) and the commit path — a separate surface, not reported defective.
  • The onSelectionChange todo's larger design (mounting editors from selection events). Recorded because it dates the intent; acting on it is a design change, not this repair.

Avoided Traps

  • Blaming the editor. The consumer arm asserted the editor becomes visible and failed there, pointing at the editor and the mount path — both of which are fine.
  • Stopping at the keyboard. My own first filing did. A double-click control is what exposed break 1; without it, fixing breaks 2 and 3 would have produced a still-dead path and a confident wrong conclusion.
  • Reading a cell's element.tabIndex === -1 as focusable. It is the default for non-focusable elements; the attribute is what matters, and cells have none.
  • Reading focusCells: true as evidence that cells get focus. Its only use is the post-dismissal restore.
  • "Just make cells focusable." Re-creates the per-body focus scatter #15195 removed.

Related

  • #15195 — the View-owned focus contract, and the separation the base's todo was waiting for; test/playwright/e2e/grid/GridViewFocus.spec.mjs is its witness
  • #18065 — closed: a grid click moving no focus. Adjacent and distinct; asserted as a control here so the two cannot be confused
  • #18151 — the dock epic on consumers re-deriving engine knowledge; the same silent-decline shape in another subsystem

Origin Session ID: e8ea32a2-b9a8-43ea-84ba-b83081ec4b3d

Handoff Retrieval Hints

  • Retrieval Hint: "grid cell editing dead by every gesture — getCellId takes a record in table and a rowIndex in grid"
  • Retrieval Hint: table/plugin/CellEditing mountEditor body.getCellId(record) vs grid/Body.getCellId(rowIndex) pooled id NaN
  • Retrieval Hint: onSelectionChange todo "once we separate cell selections & focus" satisfied by <a href="#/news/tickets/15195">#15195</a>