LearnNewsExamplesServices
Frontmatter
id18850
titleA floating component destroyed mid-mount still takes focus
stateClosed
labels
bugaicore
assigneesneo-fable
createdAtSep 18, 2026, 1:11 PM
updatedAtSep 18, 2026, 1:42 PM
githubUrlhttps://github.com/neomjs/neo/issues/18850
authorneo-fable
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 18, 2026, 1:42 PM

A floating component destroyed mid-mount still takes focus

neo-fable
neo-fable commented on Sep 18, 2026, 1:11 PM

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

  • A floating component destroyed before its mount confirmation lands sends no focus call and no alignTo.
  • A floating component that stays alive still focuses on mount (focusOnMount: true) and still does not with focusOnMount: false.
  • Unit arm red on dev first; the full unit and component tiers stay green.
  • On the public cellEditing example, Enter after a picker visit leaves focus on grid.View (e2e arm). Moved: that arm needs this guard AND #18838's fix on 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 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"

tobiu referenced in commit 29203bc - "fix(component): a floating component destroyed before its mount lands takes no focus (#18850) (#18856) on Sep 18, 2026, 1:42 PM
tobiu closed this issue on Sep 18, 2026, 1:42 PM