From 145e4161c5079e605735e95eb4e585b554e54003 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 10 Aug 2026 17:37:15 +0200 Subject: [PATCH] fix(sessions): re-arm preview focus reclaim when focus returns to a field Addresses review: hold the load window independently of the field, so leaving the composer and coming back re-arms instead of disarming for good; never re-point the target at the frame element itself; and treat Tab out of the field as the user steering focus, so tabbing into the preview stays. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/sessions/PreviewTabHost.svelte | 74 +++++++++++-------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 41911a8c00..706828ca1c 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -124,8 +124,16 @@ // 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 + // How long a load this host triggered may hand focus back. Independent of the + // field below, so leaving the composer and coming back re-arms within the window. + let reclaimUntil = 0 + // The field to hand focus back to; re-pointed as focus moves around this + // document, and undefined whenever it rests somewhere untypable. + let reclaimTarget: HTMLElement | undefined + // The user moved focus into the frame themselves — clicked or typed in there, or + // tabbed out of the field — so the frame holding focus is their doing, not the + // previewed page's. + let userMovedFocus = false const hookedDocs = new WeakSet() let hookTimer: ReturnType | undefined @@ -136,15 +144,14 @@ } function armFocusReclaim() { - const target = textEntry(document.activeElement) - frameInteracted = false - focusReclaim = target ? { target, until: Date.now() + FOCUS_RECLAIM_WINDOW_MS } : undefined - if (!focusReclaim) return + reclaimTarget = textEntry(document.activeElement) + reclaimUntil = Date.now() + FOCUS_RECLAIM_WINDOW_MS + userMovedFocus = false hookFrameDocument() clearInterval(hookTimer) hookTimer = setInterval(() => { hookFrameDocument() - if (!focusReclaim || Date.now() > focusReclaim.until) { + if (Date.now() > reclaimUntil) { clearInterval(hookTimer) hookTimer = undefined } @@ -160,7 +167,7 @@ const doc = frame?.contentDocument if (!doc || hookedDocs.has(doc)) return hookedDocs.add(doc) - const mark = () => (frameInteracted = true) + const mark = () => (userMovedFocus = true) doc.addEventListener('pointerdown', mark, true) doc.addEventListener('keydown', mark, true) } catch { @@ -200,26 +207,28 @@ armFocusReclaim() }) - // 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. + // Follow focus around this document: it decides which field a reclaim would + // hand focus back to, and whether the user moved out of it themselves. The + // armed field losing focus is also the only event this document gets out of a + // transfer into the frame — nothing fires once focus is inside it, and Firefox + // does not even blur the window for it. $effect(() => { function onFocusIn(e: FocusEvent) { - if (!focusReclaim) return - const target = textEntry(e.target) - focusReclaim = target ? { ...focusReclaim, target } : undefined + // Focus entering the frame is never the user picking another field out here + // (and per spec the frame element is in the new focus chain). + if (Date.now() > reclaimUntil || e.target === frame) return + reclaimTarget = textEntry(e.target) + if (reclaimTarget) userMovedFocus = false + } + function onKeyDown(e: KeyboardEvent) { + // Tab out of the field is the user steering focus, and the frame may well be + // the next stop — nothing to reclaim. + if (e.key === 'Tab' && e.target === reclaimTarget) userMovedFocus = true } - 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 || e.target !== reclaim.target) return - // Once the frame is hooked, `frameInteracted` below is the whole answer. + const target = reclaimTarget + if (!target || e.target !== target || Date.now() > reclaimUntil) return + // Once the frame is hooked, `userMovedFocus` 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() @@ -229,17 +238,20 @@ // 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 (focusReclaim !== reclaim || !frame || document.activeElement !== frame) return - if (frameInteracted || Date.now() > reclaim.until || !reclaim.target.isConnected) { - focusReclaim = undefined - return - } + if (reclaimTarget !== target || !frame || document.activeElement !== frame) return + if (userMovedFocus || Date.now() > reclaimUntil || !target.isConnected) return if (!looksAutomatic) return - reclaim.target.focus({ preventScroll: true }) + target.focus({ preventScroll: true }) }, 0) } + document.addEventListener('focusin', onFocusIn, true) + document.addEventListener('keydown', onKeyDown, true) document.addEventListener('focusout', onFocusOut, true) - return () => document.removeEventListener('focusout', onFocusOut, true) + return () => { + document.removeEventListener('focusin', onFocusIn, true) + document.removeEventListener('keydown', onKeyDown, true) + document.removeEventListener('focusout', onFocusOut, true) + } }) $effect(() => () => clearInterval(hookTimer))