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
- The dismissal removes the focused menu from the DOM. The browser fires a native
focusout, which reaches the app worker as a message.
manager.Focus#onFocusout defers delivery by maxFocusInOutGap (50 ms) and captures the component path as ids (src/manager/Focus.mjs).
- 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.
- 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.
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
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)
The defect
A floating
menu.Listthat 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 atmenuFocus: 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 thecomponentsjob on PR #18325. Ondev@03219a75b7it 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
focusout, which reaches the app worker as a message.manager.Focus#onFocusoutdefers delivery bymaxFocusInOutGap(50 ms) and captures the component path as ids (src/manager/Focus.mjs).hidden: false. It requests focus asynchronously (component.Base#afterSetMounted→focus()). Itsfocusinhas not reached the manager when the timer fires:lastFocusInDatewas measured 10–18 ms older thanlastFocusOutDate.menu.List#onFocusLeavetakes the!data.relatedTargetbranch and setsmenuFocus = false, which makesafterSetMenuFocusunmount the menu.A
MutationObservershowsREMOVED > ADDED > REMOVEDon every failure.The reopened menu still holds
menuFocus: true: hiding a component whose parent is notdocument.bodyremoves its DOM through the parent's update (component.Base#hide), which never runsmenu.List#unmount(). It also keepsmounted: true, becauseVdomLifecycle#unmountRemovedChildrenskips floating children.Ruled out
onFocusoutonFocusLeaveignoring a leave whose path misses the current treeisInteractionPathalready implements that test!data.relatedTargetbranchunit/menu/List.spec.mjs› keeps document blur and Escape as immediate dismissal paths)isConnectedthe focusout already carriesfocusoutsynchronously insidenode.remove(), while the node is still connected and its path still reacheswindow, so a removal and a move send the same value (Playwright Chromium 153)mountedmounted: true, so a hidden menu and a shown one read the sameThe 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#removeNoderecords the id it is removing for the duration ofremove(), andDomEvents#onFocusOutstamps a focusout raised inside that call withremovedNodeId. 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.Contract Ledger
focusout.data.removedNodeId: Stringis 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 atf30ba0ee4b.Neo.main.DomEvents#onFocusOut(producer)data.removedNodeIdwithNeo.main.DeltaUpdates#removingNodeId, the id that instance holds for the duration of itsnode.remove()callunit/main/DomEvents.spec.mjs› a focusout fired inside removeNode names the removed node, and one fired elsewhere does notNeo.manager.Focus#isRemovedFloatingShownAgain(consumer)data.removedNodeIdresolves to a component that isfloatingand no longerhidden— its new mount reports its own focusfalse, so the leave is delivered as beforeunit/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 dismissmanager.DomEventforwards the payload object as-is (it clones per listener withNeo.clone(data, true, true)), so the field needs no transport change. Nothing outsidemanager.Focusreads it.Acceptance Criteria
SpawnAnimation.spec.mjs› a dismissed and reopened instance is cleaned up and re-arms passes 60 serial repeats with zero failures.Notes
focusin/focusoutlistener stays silent here, because the lifecycle runs in the app worker.getConfigson failure costs nothing. AMutationObserveron the body lowered the rate to 2/60, so read its shape, not its rate.manager.Focusdispatch reuses onedataobject 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)