Context
Found while fixing #18838 (grid picker editors). A floating component that is shown and destroyed in the same App-Worker tick still focuses itself when its mount lands, and the focus is lost to <body> when the destroy's removal follows a few milliseconds later.
The Problem
Reproducer on examples/grid/cellEditing with #18838's fix applied, at dev@3f859a4803: edit a date cell, open the picker by its trigger, pick a day, Escape (closes the picker), Enter. The DateField's own Enter handler shows its picker again (form.field.Picker#onKeyDownEnter), and in the same tick the grid's Enter ends the edit, destroying the editor and the picker. Main-thread trace (focusin / focusout capture listeners plus a body MutationObserver), 4 of 4 runs:
3ms keydown Enter on #neo-datefield-1__input
4ms added #neo-datefield-1__picker into BODY
5ms focusout #neo-datefield-1__input -> #neo-grid-view-1 ← revertFocus, correct
6ms focusin #neo-grid-view-1
6ms focusout #neo-grid-view-1 -> #neo-dateselector-1 ← the destroyed picker focuses itself
13ms removed #neo-datefield-1__picker from BODY (active now BODY)
With me.focusOnMount && !me.isDestroyed && me.focus(me.id, true) in component.Base#afterSetMounted, the same run keeps focus on #neo-grid-view-1; the picker still flashes into the DOM for ~9 ms and takes nothing.
The grid is only where it surfaced. Any floating component — menu, picker, tooltip, dialog — whose owner destroys it before its mount round trip returns has the same window.
The Architectural Reality
src/component/Base.mjs afterSetMounted (:762–783): a floating component that mounts aligns and, with focusOnMount, focuses itself. mounted is written when the main thread confirms the insertion, which can be after destroy() ran in the App Worker; nothing there asks whether the instance still lives.
core.Base#destroy sets isDestroyed (src/core/Base.mjs:548), the flag the rest of the engine reads for late async continuations.
- The mount delta and the removal delta travel separately, so the DOM node exists for a few milliseconds after the instance died — long enough for a focus call to land on it.
The Fix
component.Base#afterSetMounted skips the alignment and the mount focus for a destroyed instance. One unit arm on a floating component: show, destroy in the same tick, then deliver the mount — no focus call goes out; the control (no destroy) still focuses.
Acceptance Criteria
Out of Scope
Whether a picker field should open its picker on Enter while it is a grid editor (the ~9 ms flash remains; interaction-contract scope of #18762) · revertFocus() losing its source after a visit to a second floating level — the pattern #18847 fixes in menu.List and #18838 fixes in the grid plugin, each locally.
Avoided Traps
- Hiding the picker from the grid plugin before it destroys the editor: the mount delta is already in flight; the focus call comes from the mount confirmation, not from the show.
- Unmounting synchronously in
destroy(): the removal delta cannot overtake a mount delta already sent.
Related
#18838 · #18762 · #18847 (adjacent: focus falling to <body> when a floating level closes, different mechanism).
Decision Record impact: none.
Live latest-open sweep: latest 20 open issues at 2026-09-18T11:11Z plus all-state searches for floating focus mount destroyed and focusOnMount — no equivalent (#18847 read in full: different mechanism). A2A sweep at 10:53Z: no claim on this scope.
Origin Session ID: 323cb989-06a2-4b3f-8d70-912d67796c11
Retrieval Hint: "floating component afterSetMounted focusOnMount isDestroyed late mount confirmation focus falls to body"
Context
Found while fixing #18838 (grid picker editors). A floating component that is shown and destroyed in the same App-Worker tick still focuses itself when its mount lands, and the focus is lost to
<body>when the destroy's removal follows a few milliseconds later.The Problem
Reproducer on
examples/grid/cellEditingwith #18838's fix applied, atdev@3f859a4803: edit a date cell, open the picker by its trigger, pick a day, Escape (closes the picker), Enter. The DateField's own Enter handler shows its picker again (form.field.Picker#onKeyDownEnter), and in the same tick the grid's Enter ends the edit, destroying the editor and the picker. Main-thread trace (focusin/focusoutcapture listeners plus a bodyMutationObserver), 4 of 4 runs:With
me.focusOnMount && !me.isDestroyed && me.focus(me.id, true)incomponent.Base#afterSetMounted, the same run keeps focus on#neo-grid-view-1; the picker still flashes into the DOM for ~9 ms and takes nothing.The grid is only where it surfaced. Any floating component — menu, picker, tooltip, dialog — whose owner destroys it before its mount round trip returns has the same window.
The Architectural Reality
src/component/Base.mjsafterSetMounted(:762–783): a floating component that mounts aligns and, withfocusOnMount, focuses itself.mountedis written when the main thread confirms the insertion, which can be afterdestroy()ran in the App Worker; nothing there asks whether the instance still lives.core.Base#destroysetsisDestroyed(src/core/Base.mjs:548), the flag the rest of the engine reads for late async continuations.The Fix
component.Base#afterSetMountedskips the alignment and the mount focus for a destroyed instance. One unit arm on a floating component: show, destroy in the same tick, then deliver the mount — no focus call goes out; the control (no destroy) still focuses.Acceptance Criteria
alignTo.focusOnMount: true) and still does not withfocusOnMount: false.devfirst; the full unit and component tiers stay green.On the public cellEditing example, Enter after a picker visit leaves focus onMoved: that arm needs this guard AND #18838's fix ongrid.View(e2e arm).dev, and what Enter means in a picker editor is interaction-contract scope of #18762 — it lands with that leaf. The main-thread trace in the PR is this ticket's receipt for the flow.Out of Scope
Whether a picker field should open its picker on Enter while it is a grid editor (the ~9 ms flash remains; interaction-contract scope of #18762) ·
revertFocus()losing its source after a visit to a second floating level — the pattern #18847 fixes inmenu.Listand #18838 fixes in the grid plugin, each locally.Avoided Traps
destroy(): the removal delta cannot overtake a mount delta already sent.Related
#18838 · #18762 · #18847 (adjacent: focus falling to
<body>when a floating level closes, different mechanism).Decision Record impact: none.
Live latest-open sweep: latest 20 open issues at 2026-09-18T11:11Z plus all-state searches for
floating focus mount destroyedandfocusOnMount— no equivalent (#18847 read in full: different mechanism). A2A sweep at 10:53Z: no claim on this scope.Origin Session ID: 323cb989-06a2-4b3f-8d70-912d67796c11
Retrieval Hint: "floating component afterSetMounted focusOnMount isDestroyed late mount confirmation focus falls to body"