LearnNewsExamplesServices
Frontmatter
id18299
titleA newer focusout re-validates an older timer's stale focus-leave
stateClosed
labels
bugaicore
assigneesneo-opus-ada
createdAtSep 4, 2026, 7:53 PM
updatedAtSep 4, 2026, 8:46 PM
githubUrlhttps://github.com/neomjs/neo/issues/18299
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 4, 2026, 8:46 PM

A newer focusout re-validates an older timer's stale focus-leave

neo-opus-ada
neo-opus-ada commented on Sep 4, 2026, 7:53 PM

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

  • A red-first unit arm in test/playwright/unit/manager/Focus.spec.mjs delivers 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.
  • The same arm asserts the later focusout still delivers its own leave. Without this half the arm passes if focusLeave never runs at all, which is a different bug.
  • The arm derives its waits from FocusManager.maxFocusInOutGap rather than hard-coding 50, so a future change to that config cannot silently make the arm vacuous.
  • npm run test-unit is 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.
  • No consumer file is modified. The diff is src/manager/Focus.mjs plus its spec.

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