From b2b53efae24f8971f21a5925598cc62a32616d3e Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:44:33 -0400 Subject: [PATCH 1/2] fix(runner): give focus back to the editor when a preview rebuild steals it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the code editor could lose focus to the preview mid-sentence: every Tier-1 keystroke re-evaluates the demo module inside the cross-origin Sandpack iframe, and a demo that focuses its grid on boot (selectCells(), listen(), any element.focus()) pulls browser focus out of CodeMirror — the rest of the keystrokes land in a grid cell. The grab cannot be prevented from the parent (the frame is cross-origin), but it is observable: a window blur while document.hasFocus() stays true means focus went into a subframe. When that happens within a keystroke of typing, EditorShell now hands focus straight back to the active editor pane. Deliberate clicks into the preview and app/tab switches are left alone. The restore is a contentDOM blur+focus rather than a bare focus(): Chromium builds disagree on where activeElement lands after a cross-origin grab, and on the builds where it stays stale on the editor a bare focus() is a no-op that reclaims nothing. The new spec fails without the fix (verified against the deployed runner) and passes with it. --- runner/e2e/preview-focus.spec.ts | 67 +++++++++++++++++++ .../packages/editor-shell/src/EditorShell.tsx | 65 +++++++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 runner/e2e/preview-focus.spec.ts diff --git a/runner/e2e/preview-focus.spec.ts b/runner/e2e/preview-focus.spec.ts new file mode 100644 index 000000000..995300def --- /dev/null +++ b/runner/e2e/preview-focus.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, type Page } from "@playwright/test"; +import { activeEditor, workspaceFiles } from "./helpers"; + +// Typing in the code editor must never lose focus to the preview. Every Tier-1 +// keystroke recompiles the sandbox and re-evaluates the demo module inside the +// cross-origin Sandpack iframe; a demo that focuses something as it boots — +// `hot.selectCells()`, `hot.listen()`, any `element.focus()` — then pulls browser +// focus out of CodeMirror mid-sentence, and the rest of the keystrokes land in a +// grid cell. The guard is `EditorShell`'s window-blur listener: a focus grab by a +// subframe within a keystroke of typing is theft, and focus goes straight back. +// +// Asserted through where the keystrokes *land*, never through `document.activeElement`: +// Chromium is not consistent about that value across a cross-origin grab (see the +// guard's comment) — on the builds where it goes stale, an activeElement assertion +// stays green while every keystroke is already routing into the frame. +// +// Live — the theft needs the real cross-origin bundler frame: a same-origin stub +// could never move browser focus the way the production preview does. Opt-in via +// E2E_LIVE=1, like the other render checks. + +const previewStatus = (page: Page) => page.locator('[aria-label="Preview"]'); + +test("live: typing through a preview rebuild keeps focus in the editor", async ({ page }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.setTimeout(180_000); + + await page.goto("/?example=javascript"); + await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", { + timeout: 120_000, + }); + await expect(page.frameLocator("iframe").first().locator(".handsontable td").first()).toBeVisible({ + timeout: 90_000, + }); + + // Make the demo grab focus the way the reported one did (`selectCells()` on + // boot): append a line that focuses an element on every module evaluation. A + // plain rather than a Handsontable API, so the trigger cannot drift + // with grid behavior across versions. + const editor = activeEditor(page); + await editor.click(); + await page.keyboard.press("ControlOrMeta+End"); + await page.keyboard.type( + "\nconst stealer = document.createElement('input'); document.body.append(stealer); stealer.focus();", + ); + + // Let that line's own rebuild (and its first grab) land before the part under + // test, so the assertions below measure the typing phase alone. + await page.waitForTimeout(4_000); + await editor.click(); + + // Type the way a user does — continuously, at a human cadence — so at least one + // re-evaluation (and its focus grab) lands mid-sentence. + const marker = "focus stays in the editor 0123456789"; + await page.keyboard.type(`\n// ${marker}`, { delay: 120 }); + + // Give the last keystroke's rebuild time to run the stealer once more, then keep + // typing WITHOUT re-clicking the editor. The tail proves the end state — focus was + // back with the editor after the final grab, not merely never lost before it. + await page.waitForTimeout(3_000); + const tail = "and stays there"; + await page.keyboard.type(` ${tail}`, { delay: 120 }); + + // The whole sentence, in the file, in one piece: no keystroke ever landed in the + // preview on the way. + const files = await workspaceFiles(page); + expect(files["/index.js"]).toContain(`// ${marker} ${tail}`); +}); diff --git a/runner/packages/editor-shell/src/EditorShell.tsx b/runner/packages/editor-shell/src/EditorShell.tsx index 3fa8546c7..18659259b 100644 --- a/runner/packages/editor-shell/src/EditorShell.tsx +++ b/runner/packages/editor-shell/src/EditorShell.tsx @@ -174,6 +174,16 @@ export interface EditorShellProps { const CURSOR_ORIGIN: CursorPosition = { line: 1, col: 1 }; +/** How close to the last keystroke a focus grab by the preview must land to read as + * theft rather than intent. What separates the two is the hand: a grab inside this + * window interrupts typing — a hand on the keyboard — while a deliberate click into + * the preview means the hand had already left it. Sized to the edit→re-evaluation + * latency it has to cover: a Tier-1 keystroke reaches the bundler in ~50ms and the + * re-run of the demo module follows within about a second (`sandpack.ts`, + * `pushUpdate`), so 2s covers it with margin without hoarding grabs that arrive + * long after the user stopped typing. */ +const PREVIEW_FOCUS_THEFT_WINDOW_MS = 2_000; + /** The first file to open, guarding the entry that does not exist. * * `props.entry` comes from the catalog, and it is not always a key of `files`: a @@ -215,6 +225,52 @@ export function EditorShell(props: EditorShellProps) { // activation, and re-measuring a pane that was hidden. const viewsRef = useRef(new Map()); + // ---- Preview focus theft -------------------------------------------------- + // Every Tier-1 keystroke recompiles the sandbox and re-evaluates the demo module + // inside the cross-origin preview iframe (`sandpack.ts`, `pushUpdate`). A demo that + // focuses its grid as it boots — `selectCells()`, `listen()`, any `element.focus()` + // — thereby pulls browser focus out of CodeMirror mid-sentence, and the rest of the + // keystrokes land in a grid cell. The frame is cross-origin, so the grab cannot be + // prevented from here; but it is observable as a window `blur` while + // `document.hasFocus()` stays true — focus left the top document without leaving the + // page, i.e. it went into a subframe. When that happens within a keystroke of + // typing, the grab interrupted the user's own editing, and focus goes straight back. + // + // What the handler must NOT key on is `document.activeElement`: Chromium is not + // consistent about it across a cross-origin grab. Sometimes it lands on the iframe + // element; on other builds (Chromium 149 headless, measured) it stays *stale* on the + // editor while every keystroke already routes into the frame. `hasFocus()` after a + // tick separates the two cases that matter instead — an app/tab switch also blurs + // the window, but there it goes false, and stealing focus back into a background + // window would be this bug re-created in reverse. + const lastKeystrokeRef = useRef(0); + const activeFileRef = useRef(active); + useEffect(() => { + activeFileRef.current = active; + }, [active]); + + useEffect(() => { + const onWindowBlur = () => { + if (Date.now() - lastKeystrokeRef.current > PREVIEW_FOCUS_THEFT_WINDOW_MS) return; + // Deferred a tick: while `blur` dispatches, the handoff is still in flight and + // `hasFocus()` still reports the pre-blur state whichever way it is going. + window.setTimeout(() => { + if (!document.hasFocus()) return; + const view = viewsRef.current.get(activeFileRef.current); + if (!view) return; + // Blur first. In the stale-activeElement manifestation the browser still + // *reports* the editor as the active element, so a bare `focus()` is a no-op + // that reclaims nothing (measured); clearing it makes the focus a real + // transition again. In the other manifestation the blur is itself the no-op. + // CodeMirror re-restores its own selection on focus, so neither costs state. + view.contentDOM.blur(); + view.contentDOM.focus(); + }, 0); + }; + window.addEventListener("blur", onWindowBlur); + return () => window.removeEventListener("blur", onWindowBlur); + }, []); + /** Open a file, or focus it if it is already open. What tree selection does. */ const openFile = useCallback((path: string) => { if (!path) return; @@ -503,7 +559,14 @@ export function EditorShell(props: EditorShellProps) { // Closes over `path`, never `active`. With one re-keyed editor the // two were interchangeable; with every tab mounted, `active` would // write each pane's edits into whichever file is showing. - onChange={(v) => props.onEdit(path, v)} + // + // The timestamp feeds the focus-theft guard above. Any doc change + // counts — paste and undo are editing just as typing is, and the + // rebuild each one triggers can steal focus just the same. + onChange={(v) => { + lastKeystrokeRef.current = Date.now(); + props.onEdit(path, v); + }} // Only the visible pane drives the status bar. A hidden one still // emits on focus changes, and would overwrite the readout. onCursorChange={path === active ? setCursor : undefined} From ca16ac9a536c7d5b8f827306c0efd19b4df66fab Mon Sep 17 00:00:00 2001 From: GreenFlux <24459976+GreenFlux@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:01:35 -0400 Subject: [PATCH 2/2] fix(runner): restore editor focus without scrolling the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focus-theft restore called contentDOM.focus() bare. CodeMirror's own view.focus() passes preventScroll: true because .cm-content is the whole document — a bare focus() can scroll a long file away from the caret the user is typing at. Pass the same option here. Refactor-only: no behavior change the focus spec can observe; the existing e2e/preview-focus.spec.ts covers the restore path and stays green. --- runner/packages/editor-shell/src/EditorShell.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/runner/packages/editor-shell/src/EditorShell.tsx b/runner/packages/editor-shell/src/EditorShell.tsx index 18659259b..e211c155f 100644 --- a/runner/packages/editor-shell/src/EditorShell.tsx +++ b/runner/packages/editor-shell/src/EditorShell.tsx @@ -263,8 +263,12 @@ export function EditorShell(props: EditorShellProps) { // that reclaims nothing (measured); clearing it makes the focus a real // transition again. In the other manifestation the blur is itself the no-op. // CodeMirror re-restores its own selection on focus, so neither costs state. + // + // `preventScroll` for the same reason CodeMirror's own `view.focus()` uses it: + // `.cm-content` is the whole document, so a bare `focus()` may scroll a long + // file away from the caret the user is typing at. view.contentDOM.blur(); - view.contentDOM.focus(); + view.contentDOM.focus({ preventScroll: true }); }, 0); }; window.addEventListener("blur", onWindowBlur);