From 1eeb1349583d95671924f651ba4e408b1fe8a400 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 10 Aug 2026 17:12:55 +0200 Subject: [PATCH] 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) --- .../components/sessions/PreviewTabHost.svelte | 124 ++++++++++++------ 1 file changed, 86 insertions(+), 38 deletions(-) diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index ded0f4ed46..41911a8c00 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -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() + let hookTimer: ReturnType | 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}"