LearnNewsExamplesServices
Frontmatter
id18290
titleA deferred focus-leave from a menu's dismissal unmounts the same menu when it reopens within 50 ms
stateClosed
labels
bugaiarchitecturecore
assigneesneo-opus-ada
createdAtSep 4, 2026, 4:50 PM
updatedAtSep 15, 2026, 9:27 AM
githubUrlhttps://github.com/neomjs/neo/issues/18290
authorneo-opus-ada
commentsCount12
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 15, 2026, 9:27 AM

A deferred focus-leave from a menu's dismissal unmounts the same menu when it reopens within 50 ms

Closed v13.2.0/archive-v13-2-0-chunk-19 bugaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Sep 4, 2026, 4:50 PM

Current state, 2026-09-15. Fixed by PR #18720, with the design below. This body is the record; the thread below it is history.

The defect

A floating menu.List that is hidden and then shown again remounts, then gets taken down about 40 ms later with no user action. It's the same instance throughout (setConfigs({hidden: true}), then ({hidden: false})), and it ends at menuFocus: false, mounted: false, hidden: true.

Repro: test/playwright/component/menu/SpawnAnimation.spec.mjs › a dismissed and reopened instance is cleaned up and re-arms failed 5–7 times in 60 serial repeats when this was filed, and once in CI, in the components job on PR #18325. On dev@03219a75b7 it passed 60 of 60. That arm reopens only after polling for the removed node, and Playwright's first poll interval is 100 ms, which likely puts the reopen past the 50 ms gap. Reopening 0, 15, 30 or 45 ms after the node is gone failed 59 of 60 on the same tree.

The mechanism, measured

  1. The dismissal removes the focused menu from the DOM. The browser fires a native focusout, which reaches the app worker as a message.
  2. manager.Focus#onFocusout defers delivery by maxFocusInOutGap (50 ms) and captures the component path as ids (src/manager/Focus.mjs).
  3. The reopen remounts the same instance, with the same id, 8–16 ms after hidden: false. It requests focus asynchronously (component.Base#afterSetMounted → focus()). Its focusin has not reached the manager when the timer fires: lastFocusInDate was measured 10–18 ms older than lastFocusOutDate.
  4. The timer fires 51–54 ms after the dismissal. The #18299 guard passes, because no newer focusout exists, and the ids resolve to the remounted instance.
  5. menu.List#onFocusLeave takes the !data.relatedTarget branch and sets menuFocus = false, which makes afterSetMenuFocus unmount the menu.

A MutationObserver shows REMOVED > ADDED > REMOVED on every failure.

The reopened menu still holds menuFocus: true: hiding a component whose parent is not document.body removes its DOM through the parent's update (component.Base#hide), which never runs menu.List#unmount(). It also keeps mounted: true, because VdomLifecycle#unmountRemovedChildren skips floating children.

Ruled out

candidate why
a newer focusout validating an older timer real, and fixed by #18299 (PR #18301), but the failure rate did not move (7/60 → 6/60)
a mount generation on the instance, captured in onFocusout built and unit-green, still 7/60 with and without it: the focusout reaches the worker after the remount, and the generation lives on the shared instance, so it has nothing to discriminate
onFocusLeave ignoring a leave whose path misses the current tree same instance, same ids, so a stale path intersects the current tree anyway; isInteractionPath already implements that test
relaxing the !data.relatedTarget branch it is the documented document-blur dismissal (unit/menu/List.spec.mjs › keeps document blur and Escape as immediate dismissal paths)
the isConnected the focusout already carries Chromium fires focusout synchronously inside node.remove(), while the node is still connected and its path still reaches window, so a removal and a move send the same value (Playwright Chromium 153)
guarding on mounted a floating component hidden through its parent's update keeps mounted: true, so a hidden menu and a shown one read the same

The fix

The worker only sees the order in which events arrive. The order in which they happened exists only on the main thread, at dispatch, so the stamp is added there.

  • DeltaUpdates#removeNode records the id it is removing for the duration of remove(), and DomEvents#onFocusOut stamps a focusout raised inside that call with removedNodeId. No other focusout changes, so other focus consumers see the field only on a removal.
  • manager.Focus#isRemovedFloatingShownAgain: a pending leave is not delivered when its removed node is a floating component that is shown again by the time the leave comes due. A floating component requests focus whenever it mounts (component.Base#afterSetMounted), so its new mount reports its own focus.
  • Every other leave is delivered as before: a focusout without the stamp, a removed component that is still hidden, and a component that is not floating.

Contract Ledger

focusout.data.removedNodeId: String is a new consumed field on the focusout payload the main thread sends to the app worker. It is additive and optional: it appears only on a focusout the engine's own node removal raised. Verified against PR #18720 at f30ba0ee4b.

Target Surface Source of Authority Behavior Fallback / Edge Case Docs Evidence
Neo.main.DomEvents#onFocusOut (producer) this ticket Stamps data.removedNodeId with Neo.main.DeltaUpdates#removingNodeId, the id that instance holds for the duration of its node.remove() call Omitted entirely when no removal is in progress, so every other focusout keeps the shape it had JSDoc on both members unit/main/DomEvents.spec.mjs › a focusout fired inside removeNode names the removed node, and one fired elsewhere does not
Neo.manager.Focus#isRemovedFloatingShownAgain (consumer) this ticket A pending leave is dropped when data.removedNodeId resolves to a component that is floating and no longer hidden — its new mount reports its own focus Falsy or unresolvable id, a removed component still hidden, and a non-floating one all return false, so the leave is delivered as before JSDoc unit/manager/Focus.spec.mjs › the leave of a removed floating menu does not dismiss the menu once it is shown again, and › a removed menu that stays hidden, focus leaving a shown menu, and a non-floating remount all still dismiss

manager.DomEvent forwards the payload object as-is (it clones per listener with Neo.clone(data, true, true)), so the field needs no transport change. Nothing outside manager.Focus reads it.

Acceptance Criteria

  • SpawnAnimation.spec.mjs › a dismissed and reopened instance is cleaned up and re-arms passes 60 serial repeats with zero failures.
  • Red-first against the mechanism: an arm that delivers a leave raised against the previous mount to the remounted menu fails before the fix and passes after, without waiting on the race.
  • Legitimate dismissals still dismiss: outside pointer (#17825), Escape, document blur and the leaf-click cascade, each named with the arm that covers it.
  • No instrumentation is committed. Heavy probes perturb this race: per-transition stack capture took the repro from 7/60 to 0/30.

Notes

  • Instrument on the worker side. A main-realm focusin/focusout listener stays silent here, because the lifecycle runs in the app worker.
  • Keep probes cheap. getConfigs on failure costs nothing. A MutationObserver on the body lowered the rate to 2/60, so read its shape, not its rate.
  • Adjacent, not this defect: the manager.Focus dispatch reuses one data object across every component in the path, so a handler that retains it sees it change.

Related

#18215 (the arm that surfaced it) · #18299 / PR #18301 (the superseded-focusout fix) · #17825 (outside-pointer dismissal) · PR #18720 (the fix)

tobiu referenced in commit 624ae90 - "fix(manager): a floating menu shown again inside the focus gap is not dismissed by its removed mount's leave (#18290) (#18720) on Sep 15, 2026, 9:27 AM
tobiu closed this issue on Sep 15, 2026, 9:27 AM