Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions runner/e2e/preview-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <input> 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}`);
});
69 changes: 68 additions & 1 deletion runner/packages/editor-shell/src/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -215,6 +225,56 @@ export function EditorShell(props: EditorShellProps) {
// activation, and re-measuring a pane that was hidden.
const viewsRef = useRef(new Map<string, EditorView>());

// ---- 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.
//
// `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({ preventScroll: true });
}, 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;
Expand Down Expand Up @@ -503,7 +563,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}
Expand Down
Loading