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
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"
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.containsFocusat reprojection time, and it looked stuck attrueafter 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:c3of record 3, scroll the pooled grid until the editor leaves the DOM, focus falls togrid.View), then click#grid-cell-editing-outside.true.false. 3 runs of 3..focus()) behaves the same.manager.Focusdefers a focusout's leave bymaxFocusInOutGap(50 ms), so that a focusout followed by a focusin becomes a move.containsFocustherefore 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()waitsmaxFocusInOutGapbeforefocusLeave(), and a focusin inside the gap becomesfocusMove().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.CellEditingreprojects a suspended session when its cell renders again (onBodyRender, #18898). Suspension leaves focus ongrid.View.main.DomEventsforwardsfocusinandfocusoutfor every element, Neo or not.The Fix
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
mountedlistener. The editor's own reprojected mount then takes focus back only whileview.containsFocusstill 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
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.Out of Scope
maxFocusInOutGap, or makingcontainsFocussynchronous. The deferral is what turns a focusout plus focusin into a move.Avoided Traps
containsFocusin 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'sfocusLeavedoes not avoid the gap either, because it fires with the flag.manager.Focus. Every island (pickers, menus, the editor) relies on a move not being a leave.Related
onBodyRender)containsFocusdefect-note are the only messages on this scope. The note does not reproduce as a stuck flag (measurement above).focusMovedelivers 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"