Context
Found while measuring #18290, and it is not that ticket's cause — the 60-repeat control came back 6/60 against a 7/60 baseline, so this defect is real and separately owned. Filing it on its own rather than folding it into #18290 as partial progress.
The premise is not secondhand: a red-first arm reproduces it deterministically, without waiting on any race.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-04T17:53Z; no equivalent found. Targeted sweep over focusout / focus leave / manager/Focus returns #6128 (history windowId scoping — different concern) and closed prior art #6162 (maxFocusInOutGap 10ms → 30ms) and #8621 (FocusManager stability during atomic moves). Neither describes this. A2A in-flight claim sweep over the last ~60 minutes: active claims are #18297, #18281, #18296 and Institution #107 — no overlap. Own-assignment sweep: 9 open, none on this surface.
The Problem
Neo.manager.Focus#onFocusout does not deliver a focus-leave immediately. It defers by maxFocusInOutGap (50 ms) so that a focusout followed closely by a focusin can be collapsed into a focusMove instead. The deferred callback closes over its own opts, but decides whether to fire by reading manager-global state:
onFocusout(opts) {
me.lastFocusOutDate = new Date();
me.timeout(me.maxFocusInOutGap).then(() => {
if (me.lastFocusOutDate > me.lastFocusInDate) {
me.focusLeave(opts)
}
})
}lastFocusOutDate is a singleton field. Any later focusout overwrites it. So a newer focusout can push the global date past lastFocusInDate and thereby satisfy an older timer's condition on its behalf — and that older timer then delivers its own captured opts, raised against a mount that no longer exists.
Failing interleaving, all within one 50 ms window:
| t |
event |
effect |
| 0 ms |
focusout A |
lastFocusOutDate = t0; timer A armed for t50 |
| 10 ms |
focusin |
lastFocusInDate = t10; t10 - t0 < 50 → focusMove, which across identical paths correctly delivers nothing |
| 20 ms |
focusout B (unrelated) |
lastFocusOutDate = t20 |
| 50 ms |
timer A fires |
t20 > t10 is true → delivers A's stale opts |
The focusin at t10 should have disarmed timer A, and it does — until an unrelated focusout re-arms it by side effect.
Consumers receive a leave whose oldPath and relatedTarget describe a previous mount. A consumer that acts on !data.relatedTarget — the documented document-blur / Escape dismissal path — cannot distinguish it from a genuine dismissal, because from inside onFocusLeave the payload is indistinguishable.
The Architectural Reality
src/manager/Focus.mjs:166-176 — onFocusout, the deferred delivery described above. The opts/global-state split is the defect.
src/manager/Focus.mjs:148-157 — onFocusin sets lastFocusInDate and routes to focusMove or focusEnter on the same gap. The two methods share the pair of dates as their only coordination.
src/manager/Focus.mjs:185-210 — setComponentFocus re-resolves opts.componentPath through Neo.getComponent(id) at delivery time and calls the handler on every component in the path, so one stale delivery reaches an entire ancestor chain, not a single component.
src/component/Base.mjs:1353 — destroy() sets me.onFocusLeave = Neo.emptyFn with the comment "We do want to prevent delayed calls after a component instance got destroyed." The engine already recognises this hazard class and neutralises it for destroyed instances. An instance that merely unmounts and remounts keeps a live onFocusLeave, so that guard never applies — this ticket closes the same hazard one lifecycle rung down.
- This is app-worker code, not main-realm.
focusin/focusout listeners in the main realm observe nothing here.
Consumers reached by a stale delivery today include form/field/Base.mjs:356, form/field/ComboBox.mjs:551, form/field/Picker.mjs:295, form/field/Text.mjs:1377, form/field/Number.mjs:313, menu/List.mjs:527, calendar/view/EditEventContainer.mjs:263, dashboard/dock/interaction/RevealOverlay.mjs:550, and table/plugin/CellEditing.mjs:236.
The Fix
Gate the deferred callback on timer identity, not merely on recency: capture the Date this invocation wrote, and deliver only if it is still the one the manager holds.
onFocusout(opts) {
let me = this,
date = new Date();
me.lastFocusOutDate = date;
me.timeout(me.maxFocusInOutGap).then(() => {
if (me.lastFocusOutDate === date && date > me.lastFocusInDate) {
me.focusLeave(opts)
}
})
}One added binding and one added conjunct. date > me.lastFocusInDate preserves the existing null-lastFocusInDate behaviour exactly (both sides coerce identically). A superseded focusout stands down; the newest one still delivers its own opts through its own timer.
Deliberately not touched: menu/List.mjs#onFocusLeave's predicate. Its !data.relatedTarget branch is the documented document-blur/Escape dismissal path and is covered by an arm; weakening it trades a stale delivery for a menu that will not close. The ordering is not knowable at the consumer, which is why the fix belongs here.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback / Edge Case |
Docs |
Evidence |
Neo.manager.Focus#onFocusout (src/manager/Focus.mjs:166) |
this ticket |
only the most recent focusout may deliver its deferred leave |
a superseded focusout stands down silently; the newest still delivers |
JSDoc in place |
red-first unit arm, both directions |
Neo.manager.Focus#maxFocusInOutGap (:39, default 50) |
existing config |
unchanged; still the collapse window |
tuning it does not change delivery identity |
existing |
arm reads the config rather than hard-coding 50 |
onFocusLeave(data) consumer contract (src/component/Base.mjs:1731) |
existing |
unchanged signature; consumers simply stop receiving superseded leaves |
consumers relying on a stale leave to dismiss would change behaviour — none found |
existing |
full unit suite green |
Decision Record impact
none — this repairs delivery within an accepted primitive; no ADR authority is amended, challenged, or depended upon.
Acceptance Criteria
Out of Scope
- #18290. This is not its fix: measured 6/60 against a 7/60 baseline. That ticket stays open with its own cause unfound.
- Changing
maxFocusInOutGap, or the focusout→focusin→focusMove collapse policy itself. #6162 already moved that value once; this ticket does not revisit it.
setComponentFocus's re-resolution of ids through Neo.getComponent at delivery time. It is correct for a live instance under the same id, and with delivery identity fixed it has no observed defect. Noted, not addressed.
- Windowed scoping of
history — that is #6128.
Avoided Traps
- Treating this as a menu bug. The symptom was first seen through
menu.List, and the consumer-side guard is the obvious place to reach for. It was measured and eliminated: a mount-generation guard in the consumer scored 7/60 with and 7/60 without, because the leave never carried a generation to key on. The ordering is not the consumer's to know.
- Reading the same failure count as the same failure. This ticket exists precisely because a real, red-first-proven mechanism turned out not to move the symptom it was found under. A mechanism arm going green is evidence about the mechanism, never about a symptom.
- Comparing dates instead of identity.
lastFocusOutDate > lastFocusInDate reads like a freshness check and is one — for the manager, not for this timer. Any fix that keeps comparing only timestamps reproduces the defect the moment a third event lands in the window.
Related
- #18290 — where this was found; explicitly not resolved by it.
- #18215 — the flaking arm that surfaced #18290.
- #6162 (closed) — prior tuning of
maxFocusInOutGap; same method, different axis.
- #8621 (closed) — FocusManager stability during atomic moves; adjacent prior art.
- #6128 (open) —
manager.Focus history scoping; adjacent, not overlapping.
- #17825 (closed) — outside-pointer dismissal, one of the dismissal paths this ticket must not break.
Origin Session ID: 12595f5d-68f8-48bc-93d2-4a91c7bad164
Retrieval Hint: a deferred focusout timer gates on manager-global lastFocusOutDate, so a newer focusout re-validates an older timer and delivers stale opts to an entire ancestor path
Context
Found while measuring #18290, and it is not that ticket's cause — the 60-repeat control came back 6/60 against a 7/60 baseline, so this defect is real and separately owned. Filing it on its own rather than folding it into #18290 as partial progress.
The premise is not secondhand: a red-first arm reproduces it deterministically, without waiting on any race.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-04T17:53Z; no equivalent found. Targeted sweep over
focusout/focus leave/manager/Focusreturns #6128 (history windowId scoping — different concern) and closed prior art #6162 (maxFocusInOutGap10ms → 30ms) and #8621 (FocusManager stability during atomic moves). Neither describes this. A2A in-flight claim sweep over the last ~60 minutes: active claims are #18297, #18281, #18296 and Institution #107 — no overlap. Own-assignment sweep: 9 open, none on this surface.The Problem
Neo.manager.Focus#onFocusoutdoes not deliver a focus-leave immediately. It defers bymaxFocusInOutGap(50 ms) so that a focusout followed closely by a focusin can be collapsed into afocusMoveinstead. The deferred callback closes over its ownopts, but decides whether to fire by reading manager-global state:onFocusout(opts) { me.lastFocusOutDate = new Date(); me.timeout(me.maxFocusInOutGap).then(() => { if (me.lastFocusOutDate > me.lastFocusInDate) { me.focusLeave(opts) } }) }lastFocusOutDateis a singleton field. Any later focusout overwrites it. So a newer focusout can push the global date pastlastFocusInDateand thereby satisfy an older timer's condition on its behalf — and that older timer then delivers its own capturedopts, raised against a mount that no longer exists.Failing interleaving, all within one 50 ms window:
lastFocusOutDate = t0; timer A armed for t50lastFocusInDate = t10;t10 - t0 < 50→focusMove, which across identical paths correctly delivers nothinglastFocusOutDate = t20t20 > t10is true → delivers A's staleoptsThe focusin at t10 should have disarmed timer A, and it does — until an unrelated focusout re-arms it by side effect.
Consumers receive a leave whose
oldPathandrelatedTargetdescribe a previous mount. A consumer that acts on!data.relatedTarget— the documented document-blur / Escape dismissal path — cannot distinguish it from a genuine dismissal, because from insideonFocusLeavethe payload is indistinguishable.The Architectural Reality
src/manager/Focus.mjs:166-176—onFocusout, the deferred delivery described above. Theopts/global-state split is the defect.src/manager/Focus.mjs:148-157—onFocusinsetslastFocusInDateand routes tofocusMoveorfocusEnteron the same gap. The two methods share the pair of dates as their only coordination.src/manager/Focus.mjs:185-210—setComponentFocusre-resolvesopts.componentPaththroughNeo.getComponent(id)at delivery time and calls the handler on every component in the path, so one stale delivery reaches an entire ancestor chain, not a single component.src/component/Base.mjs:1353—destroy()setsme.onFocusLeave = Neo.emptyFnwith the comment "We do want to prevent delayed calls after a component instance got destroyed." The engine already recognises this hazard class and neutralises it for destroyed instances. An instance that merely unmounts and remounts keeps a liveonFocusLeave, so that guard never applies — this ticket closes the same hazard one lifecycle rung down.focusin/focusoutlisteners in the main realm observe nothing here.Consumers reached by a stale delivery today include
form/field/Base.mjs:356,form/field/ComboBox.mjs:551,form/field/Picker.mjs:295,form/field/Text.mjs:1377,form/field/Number.mjs:313,menu/List.mjs:527,calendar/view/EditEventContainer.mjs:263,dashboard/dock/interaction/RevealOverlay.mjs:550, andtable/plugin/CellEditing.mjs:236.The Fix
Gate the deferred callback on timer identity, not merely on recency: capture the
Datethis invocation wrote, and deliver only if it is still the one the manager holds.onFocusout(opts) { let me = this, date = new Date(); me.lastFocusOutDate = date; me.timeout(me.maxFocusInOutGap).then(() => { if (me.lastFocusOutDate === date && date > me.lastFocusInDate) { me.focusLeave(opts) } }) }One added binding and one added conjunct.
date > me.lastFocusInDatepreserves the existing null-lastFocusInDatebehaviour exactly (both sides coerce identically). A superseded focusout stands down; the newest one still delivers its ownoptsthrough its own timer.Deliberately not touched:
menu/List.mjs#onFocusLeave's predicate. Its!data.relatedTargetbranch is the documented document-blur/Escape dismissal path and is covered by an arm; weakening it trades a stale delivery for a menu that will not close. The ordering is not knowable at the consumer, which is why the fix belongs here.Contract Ledger Matrix
Neo.manager.Focus#onFocusout(src/manager/Focus.mjs:166)Neo.manager.Focus#maxFocusInOutGap(:39, default50)onFocusLeave(data)consumer contract (src/component/Base.mjs:1731)Decision Record impact
none— this repairs delivery within an accepted primitive; no ADR authority is amended, challenged, or depended upon.Acceptance Criteria
test/playwright/unit/manager/Focus.spec.mjsdelivers focusout → focusin → a second, unrelated focusout, and asserts the first focusout's components receive no leave. It must fail before the fix and pass after.focusLeavenever runs at all, which is a different bug.FocusManager.maxFocusInOutGaprather than hard-coding50, so a future change to that config cannot silently make the arm vacuous.npm run test-unitis green across the full suite — every consumer listed under Architectural Reality is covered by existing arms, and this is the blast-radius check.test/playwright/unit/menu/List.spec.mjs— "keeps document blur and Escape as immediate dismissal paths" (:173) and "treats the trigger and every mounted submenu level as inside, but dismisses outside" (:103) both still pass unmodified.test/playwright/component/menu/List.spec.mjs:119— "a leaf click takes every level down together" still passes unmodified.src/manager/Focus.mjsplus its spec.Out of Scope
maxFocusInOutGap, or the focusout→focusin→focusMovecollapse policy itself.#6162already moved that value once; this ticket does not revisit it.setComponentFocus's re-resolution of ids throughNeo.getComponentat delivery time. It is correct for a live instance under the same id, and with delivery identity fixed it has no observed defect. Noted, not addressed.history— that is #6128.Avoided Traps
menu.List, and the consumer-side guard is the obvious place to reach for. It was measured and eliminated: a mount-generation guard in the consumer scored 7/60 with and 7/60 without, because the leave never carried a generation to key on. The ordering is not the consumer's to know.lastFocusOutDate > lastFocusInDatereads like a freshness check and is one — for the manager, not for this timer. Any fix that keeps comparing only timestamps reproduces the defect the moment a third event lands in the window.Related
maxFocusInOutGap; same method, different axis.manager.Focushistory scoping; adjacent, not overlapping.Origin Session ID: 12595f5d-68f8-48bc-93d2-4a91c7bad164
Retrieval Hint:
a deferred focusout timer gates on manager-global lastFocusOutDate, so a newer focusout re-validates an older timer and delivers stale opts to an entire ancestor path