fix(sessions): reclaim preview focus on focusout, past the page's own autofocus

Addresses review: Firefox never blurs the window for a transfer into a
same-origin frame, so key off focusout instead; drop a pending reclaim when
the user moves focus elsewhere in the parent; hook the frame's document as
soon as it exists (its app hydrates well before the load event) so a page
autofocusing its own field is not mistaken for a user's click; treat an
unreadable frame as not reclaimable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-08-10 17:12:55 +02:00
co-authored by Claude Opus 5
parent 70de39ed35
commit 1eeb134958
@@ -121,44 +121,72 @@
// opens or reloads previews). Hand it back, bounded to the load the session
// itself triggered: a click into the frame is the user's own move to make.
const FOCUS_RECLAIM_WINDOW_MS = 10_000
// The frame's document has to be hooked before its app hydrates — which is well
// before the frame's load event — so poll for it instead of waiting for load.
const FRAME_HOOK_INTERVAL_MS = 50
let focusReclaim: { target: HTMLElement; until: number } | undefined
let frameInteracted = false
const hookedDocs = new WeakSet<Document>()
let hookTimer: ReturnType<typeof setInterval> | undefined
function armFocusReclaim() {
const active = document.activeElement
const isTextEntry =
active instanceof HTMLTextAreaElement ||
active instanceof HTMLInputElement ||
(active instanceof HTMLElement && active.isContentEditable)
frameInteracted = false
focusReclaim = isTextEntry
? { target: active as HTMLElement, until: Date.now() + FOCUS_RECLAIM_WINDOW_MS }
: undefined
function textEntry(el: EventTarget | null): HTMLElement | undefined {
if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return el
if (el instanceof HTMLElement && el.isContentEditable) return el
return undefined
}
// A click or keypress inside the frame reaches no listener out here, so read it
// off the frame's own document (same-origin) — it fires ahead of the focus move
// it causes, which is what separates a user's click from the router's reset.
function trackFrameInteraction(target: HTMLIFrameElement) {
function armFocusReclaim() {
const target = textEntry(document.activeElement)
frameInteracted = false
focusReclaim = target ? { target, until: Date.now() + FOCUS_RECLAIM_WINDOW_MS } : undefined
if (!focusReclaim) return
hookFrameDocument()
clearInterval(hookTimer)
hookTimer = setInterval(() => {
hookFrameDocument()
if (!focusReclaim || Date.now() > focusReclaim.until) {
clearInterval(hookTimer)
hookTimer = undefined
}
}, FRAME_HOOK_INTERVAL_MS)
}
// Whether the user has acted inside the frame is the whole question, and a click
// or keypress in there reaches no listener out here — so read it off the frame's
// own document (same-origin). These fire ahead of the focus move they cause,
// which is what separates a user's click from focus the page took by itself.
function hookFrameDocument() {
try {
const doc = target.contentDocument
if (!doc) return
const doc = frame?.contentDocument
if (!doc || hookedDocs.has(doc)) return
hookedDocs.add(doc)
const mark = () => (frameInteracted = true)
doc.addEventListener('pointerdown', mark, true)
doc.addEventListener('keydown', mark, true)
} catch {
// Defensively cross-origin; the reclaim window expiring is the fallback.
// Defensively cross-origin; the fallback below decides instead.
}
}
// The router parks focus on the frame's body, so anything else focused in there
// was clicked. Second line of defence: the listeners above only exist from the
// load event on, and a slow frame hydrates (and resets focus) well before that.
function frameIsHooked(): boolean {
try {
const doc = frame?.contentDocument
return !!doc && hookedDocs.has(doc)
} catch {
return false
}
}
// Fallback for a document not hooked yet (one poll tick, or cross-origin): the
// router parks focus on the frame's body, so anything else focused in there was
// more likely clicked. Too coarse to rely on once the frame is hooked — a
// previewed page that autofocuses a field of its own (Runs does) would read as a
// user's click. An unreadable frame is not reclaimable, as in the catch.
function frameFocusIsRouterReset(): boolean {
try {
const doc = frame?.contentDocument
const inner = doc?.activeElement
return !inner || inner === doc?.body
if (!doc) return false
return !doc.activeElement || doc.activeElement === doc.body
} catch {
return false
}
@@ -172,28 +200,48 @@
armFocusReclaim()
})
// Focus entering the frame blurs the top window with the frame as
// document.activeElement — no event reaches the parent once it is inside.
// Focus the user moves elsewhere in this document supersedes the armed target,
// so a reclaim that lands late can never drag them back to the field they left.
$effect(() => {
function onWindowBlur() {
function onFocusIn(e: FocusEvent) {
if (!focusReclaim) return
const target = textEntry(e.target)
focusReclaim = target ? { ...focusReclaim, target } : undefined
}
document.addEventListener('focusin', onFocusIn, true)
return () => document.removeEventListener('focusin', onFocusIn, true)
})
// The armed field losing focus is the only event this document gets out of the
// transfer — nothing fires once focus is inside the frame, and Firefox does not
// even blur the window for it.
$effect(() => {
function onFocusOut(e: FocusEvent) {
const reclaim = focusReclaim
if (!reclaim || !frame || document.activeElement !== frame) return
if (frameInteracted || Date.now() > reclaim.until || !reclaim.target.isConnected) {
focusReclaim = undefined
return
}
if (!frameFocusIsRouterReset()) return
// Deferred: focusing from inside the blur handler moves activeElement but
// not the keyboard — the transfer into the frame is still in flight, and
// it lands last, leaving the caret drawn here and the keys going nowhere.
if (!reclaim || e.target !== reclaim.target) return
// Once the frame is hooked, `frameInteracted` below is the whole answer.
// Until then, read the frame's focus now, while it still reflects the move
// that just happened — a page autofocuses its own field a tick later.
const looksAutomatic = frameIsHooked() || frameFocusIsRouterReset()
// The verdict on this document waits a task, because the transfer into the
// frame is still in flight here — Chrome still reports the body as
// document.activeElement — and because focusing from inside the handler
// moves activeElement but not the keyboard: the in-flight transfer lands
// last, leaving the caret drawn here and the keystrokes going to the frame.
setTimeout(() => {
if (frameInteracted || !reclaim.target.isConnected) return
if (focusReclaim !== reclaim || !frame || document.activeElement !== frame) return
if (frameInteracted || Date.now() > reclaim.until || !reclaim.target.isConnected) {
focusReclaim = undefined
return
}
if (!looksAutomatic) return
reclaim.target.focus({ preventScroll: true })
}, 0)
}
window.addEventListener('blur', onWindowBlur)
return () => window.removeEventListener('blur', onWindowBlur)
document.addEventListener('focusout', onFocusOut, true)
return () => document.removeEventListener('focusout', onFocusOut, true)
})
$effect(() => () => clearInterval(hookTimer))
const visibility = $derived(
active ? 'z-10 opacity-100 pointer-events-auto' : 'z-0 opacity-0 pointer-events-none'
@@ -312,7 +360,7 @@
// Re-apply after load so a toggle that happened while the frame was
// loading (its layout read the pre-toggle preference) isn't lost.
applyPageIframeTheme(darkMode, f)
trackFrameInteraction(f)
hookFrameDocument()
onLoad(f)
}}
title="Session preview: {label}"