LearnNewsExamplesServices
Frontmatter
id18927
titleReprojection focus and click-away order in grid editing
stateClosed
labels
bugaigrid
assigneesneo-opus-grace
createdAtSep 18, 2026, 6:47 PM
updatedAtSep 19, 2026, 1:37 PM
githubUrlhttps://github.com/neomjs/neo/issues/18927
authorneo-opus-grace
commentsCount0
parentIssue18762
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 19, 2026, 1:37 PM

Reprojection focus and click-away order in grid editing

neo-opus-grace
neo-opus-grace commented on Sep 18, 2026, 6:47 PM

Context

This is a leaf of #18762. It was split from #18863 on 2026-09-18, when PR #18912 delivered that ticket's AC-3 (the edited cell stays selected after an Enter commit) and AC-4 (an invalid draft keeps the editor). #18912 resolves the narrowed #18863. This ticket carries the two ACs it left open, word for word.

@neo-opus-vega built AC-1's first half, where the editor takes focus back when focus was in it at suspension, and it works. She reverted it because the second half had no sound signal, and shipping half an AC was not an option.

The Problem

AC-1's second half. A reprojected editor must not take focus back when a newer gesture moved focus away while the edit was suspended. The attempt read view.containsFocus at reprojection time, and it looked stuck at true after focus had gone to a plain element outside the app.

It is not stuck. Measured on test/playwright/component/apps/grid-cell-editing, real Chromium:

  • Setup: suspend an edit (edit c3 of record 3, scroll the pooled grid until the editor leaves the DOM, focus falls to grid.View), then click #grid-cell-editing-outside.
  • Read immediately after the click: true.
  • Read 150 ms later, and on every read after: false. 3 runs of 3.
  • The plain case (a cell click, then the outside button, by click or .focus()) behaves the same.

manager.Focus defers a focusout's leave by maxFocusInOutGap (50 ms), so that a focusout followed by a focusin becomes a move. containsFocus therefore reports the state from before the gesture for that gap plus a worker round trip. A read in the same tick as the gesture cannot tell a click-away from nothing happening.

AC-2. A click on another cell while editing must commit, then select, in that order. That order has no event-level witness yet.

The Architectural Reality

  • src/manager/Focus.mjs: onFocusout() waits maxFocusInOutGap before focusLeave(), and a focusin inside the gap becomes focusMove(). focusMove() with no common component leaves the whole old path, so focus going to a non-Neo element does clear the path, one gap later.
  • grid.plugin.CellEditing reprojects a suspended session when its cell renders again (onBodyRender, #18898). Suspension leaves focus on grid.View.
  • main.DomEvents forwards focusin and focusout for every element, Neo or not.

The Fix

Corrected 2026-09-18 (author), while implementing: the first version of this section prescribed recording the View's focusLeave from suspension on, as "event-driven, so the 50 ms gap cannot fool it". That was wrong. manager.Focus#setComponentFocus sets containsFocus and fires focusLeave in the same call, so an event lags the gap exactly as the flag does. It is also more fragile: a suspension whose revertFocus lands after the gap reads as the View being left.

The suspension blur marks the session: the editor's leave while it is not embodied (a scroll), or the leave a lock change owes, registers a one-shot mounted listener. The editor's own reprojected mount then takes focus back only while view.containsFocus still holds. Read there, it is correct for every reprojection that lands after the gap, which a scroll or a lock change following a gesture always does.

For AC-2, witness the order the plugin already has: its commit and the cell selection, recorded by event.

Acceptance Criteria

  • AC-1: a reprojected editor takes focus back when focus was in it at suspension, and does not when a newer gesture moved focus. The first arm fails first on dev. The second arm moves focus both inside the app and to a non-Neo element, and restores after the 50 ms gap has passed, so a same-tick read cannot pass it.
  • AC-2: a click on another cell while editing commits, then selects, in that order, witnessed at the event level.
  • AC-3: each arm is killed by a named mutation recorded in the PR.

Out of Scope

  • The ACs #18912 delivers.
  • Changing maxFocusInOutGap, or making containsFocus synchronous. The deferral is what turns a focusout plus focusin into a move.

Avoided Traps

  • ⛔ Reading containsFocus in the tick of the gesture. It is correct one gap later, and a same-tick read is how this AC was first missed. Recording the View's focusLeave does not avoid the gap either, because it fires with the flag.
  • ⛔ Fixing the lag in manager.Focus. Every island (pickers, menus, the editor) relies on a move not being a leave.

Related

  • #18863 (split from; its PR is #18912) · #18762 (epic) · #18898 (onBodyRender)
  • Live latest-open sweep: checked the 20 latest open issues at 2026-09-18T16:46Z, and all-state searches for reprojection focus; nothing equivalent.
  • A2A sweep: @neo-opus-vega's fork and her containsFocus defect-note are the only messages on this scope. The note does not reproduce as a stuck flag (measurement above).
  • Memory Core sweep: @neo-fable's 09-18 evaluation recorded that reprojection leaves focus on the View and that focusMove delivers the leave before the enter. No decision against this shape.

unowned-rationale: split for scope, not claimed. @neo-opus-vega built AC-1's first half and has first claim. Otherwise it is claimable, and an Opus seat is the cheap one.

Origin Session ID: 0a997d38-ccdb-4352-ac21-566606a6ab2d Retrieval Hint: "reprojected grid editor focus return suspended containsFocus focusout gap"