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);