Skip to content
Merged
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
34 changes: 32 additions & 2 deletions runner/apps/authoring/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,15 @@ function Authoring({
// Bumped whenever the whole workspace is replaced (example switch or fork) so
// the runtime remounts even when the framework is unchanged.
const [mountGen, setMountGen] = useState(0);
// Bumped by the error card's "Restart preview". Its own counter rather than `mountGen`:
// that one doubles as `EditorShell`'s `workspaceKey`, and a retry is not a new
// workspace — sharing it would close the user's open tabs to fix the preview.
const [retryGen, setRetryGen] = useState(0);
// Whether the current failure is one a remount could clear. False for the refusals the
// mount effect makes *before* building a runtime — an unsupported version, a starter
// below its core floor. Those depend on the version picker, not on the preview, and
// offering to restart them would promise something the button cannot deliver.
const [retryable, setRetryable] = useState(true);

const [iframeEl, setIframeEl] = useState<HTMLIFrameElement | null>(null);
// Where the running preview lives, as reported by mount(). Tier 2 gives the
Expand Down Expand Up @@ -963,6 +972,7 @@ function Authoring({
if (!v.ok) {
setStatus("error");
setErrorMessage(v.message);
setRetryable(false);
return;
}
// Per-starter floor: these starters were authored against a core API that
Expand All @@ -980,8 +990,10 @@ function Authoring({
setErrorMessage(
`Could not load this example for Handsontable ${version}. Try another version.`,
);
setRetryable(false);
return;
}
setRetryable(true);
setStatus("booting");
setBootLog("");
setSyncing(false);
Expand Down Expand Up @@ -1031,8 +1043,22 @@ function Authoring({
runtime.dispose();
if (runtimeRef.current === runtime) runtimeRef.current = null;
};
// mountGen forces a remount when files are replaced (example switch or fork/edit load).
}, [iframeEl, entry, version, mountGen, sourceLoaded, docsNotFound, docsRuntimeBlocked, versionPending, docsPath]);
// mountGen forces a remount when files are replaced (example switch or fork/edit load);
// retryGen when the user asks for one from the error card.
}, [iframeEl, entry, version, mountGen, retryGen, sourceLoaded, docsNotFound, docsRuntimeBlocked, versionPending, docsPath]);

/** "Restart preview" — mount a fresh runtime from the current (edited) sources.
*
* The way out of a failure the code has already outlived. Tier 1 recovers on its own
* now (the bundler's next clean compile re-emits ready), but Tier 2 cannot: a boot
* failure exits the container's dev server, and streaming the fixed file into a
* container with no dev server changes nothing. Only a new session re-runs it. */
const retryPreview = useCallback(() => {
setStatus("booting");
setErrorMessage(null);
setBootLog("");
setRetryGen((g) => g + 1);
}, []);

const onEdit = useCallback(
(path: string, contents: string) => {
Expand Down Expand Up @@ -1358,6 +1384,10 @@ function Authoring({
errorMessage={errorMessage}
bootLog={bootLog}
containerBoot={entry.engine === "container"}
// Withheld while a docs bucket/path is unresolved (the mount effect refuses to run
// at all in that state) and for pre-mount version refusals: in both cases the
// button would restart nothing.
onRetry={docsRuntimeBlocked || !retryable ? undefined : retryPreview}
syncing={syncing}
refreshing={refreshing}
version={version}
Expand Down
215 changes: 215 additions & 0 deletions runner/e2e/preview-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { test, expect } from "@playwright/test";

// The preview must be able to come back. A runtime error in edited code puts the
// pane into `error` (the "The preview could not start" card); fixing the code has
// to clear it again. It did not: `SandpackRuntime.emitReady()` fired at most once
// per mount, so the bundler's next clean `done` — the honest "your fix compiled
// and ran" signal — was swallowed, and the error card outlived the error. The only
// way out was an example switch or a version change, both of which remount.
//
// Live — needs the external Sandpack bundler; opt-in via E2E_LIVE=1, like the
// other render checks.

/** The visible editor. Hidden panes stay mounted (T12), so an unscoped
* `.cm-content` would match every open file and resolve in DOM order. */
const activeEditor = (page: import("@playwright/test").Page) =>
page.locator('[data-pane-active="true"] .cm-content');

const previewStatus = (page: import("@playwright/test").Page) =>
page.locator('[aria-label="Preview"]');

// Deterministic — no `E2E_LIVE=1`: the session POST is stubbed to a refusal, so the
// error card appears whether or not a real API worker (and container pool) happens to be
// reachable from wherever the suite runs. Without the stub this test passes only when
// nothing answers `/api` — which is true in CI and false on a developer's machine with
// the local worker up.
//
// Tier 2 has no self-healing equivalent of the Tier-1 fix below. A boot failure means
// the container's boot script exited, so its dev server is gone: the fixed file streams
// into a container with nothing left to serve it, and `reload()` (row-2 refresh) bails
// early because the iframe was never pointed. A remount is the only way back, and this
// button is the only thing that asks for one.
test("a failed preview offers a restart that remounts the runtime", async ({ page }) => {
const sessionPosts: string[] = [];
await page.route("**/api/session", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
sessionPosts.push(route.request().url());
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: "no container slots" }),
});
});

// `react-js` is a container starter; `vue`, despite the name, is Tier 1 (see
// catalog.json) and boots in-browser with no session at all.
await page.goto("/?example=react-js");
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", {
timeout: 60_000,
});
const restart = page.getByRole("button", { name: "Restart preview" });
await expect(restart).toBeVisible();

const before = sessionPosts.length;
expect(before, "the first mount attempted a session").toBeGreaterThan(0);
await restart.click();
// A fresh session POST is the observable proof the runtime remounted; asserting on the
// status attribute alone would pass on a button that did nothing, since the retry ends
// in the same error here (there is still no worker).
await expect(async () => {
expect(sessionPosts.length).toBeGreaterThan(before);
}).toPass({ timeout: 30_000 });
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", {
timeout: 60_000,
});
});

test("live: fixing a runtime error clears the preview error card", 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=react");
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,
});

// Break it the way a user does: a reference to something that doesn't exist.
// Compiles clean, throws when the module is evaluated.
const editor = activeEditor(page);
await editor.click();
await page.keyboard.press("ControlOrMeta+End");
await page.keyboard.type("\nconsole.log(alignHeadersTypo);");

await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", {
timeout: 60_000,
});
await expect(page.getByText("The preview could not start")).toBeVisible();

// Undo the edit — the fix. Select the broken line and remove it, newline and all.
await page.keyboard.press("Shift+Home");
await page.keyboard.press("Backspace");
await page.keyboard.press("Backspace");

await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", {
timeout: 60_000,
});
await expect(page.getByText("The preview could not start")).toHaveCount(0);
// Attribute alone would pass on a blanked pane; the grid has to be back.
await expect(page.frameLocator("iframe").first().locator(".handsontable td").first()).toBeVisible({
timeout: 60_000,
});
});

// The counterpart to the test above: an unsupported version is refused before any runtime
// is built, so a remount would re-run the same refusal. The card has no action, and the
// version picker (still live behind it) is where the fix actually is.
test("a version the runner refuses gets no restart button", async ({ page }) => {
await page.goto("/?example=react&v=99.99.99");
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", {
timeout: 30_000,
});
await expect(page.getByText("The preview could not start")).toBeVisible();
await expect(page.getByRole("button", { name: "Restart preview" })).toHaveCount(0);
});

// A compile the bundler sees as "no module changed" resets the preview document without
// re-evaluating anything: a blank frame, `done` with no error, nothing in the console.
// Two paths hit it. This is the one reachable by typing: break a line (the transpile
// throws, so nothing is pushed and the good render stays), then undo the break — the
// recomputed sandbox is byte-identical to what the bundler already has, and pushing it
// blanked a preview that was correct. Not pushing is the fix.
test("live: breaking and un-breaking a line leaves the grid alone", async ({ page }) => {
test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks");
test.setTimeout(180_000);

const grid = page.frameLocator("iframe").first().locator(".handsontable td").first();
await page.goto("/?example=react");
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", {
timeout: 120_000,
});
await expect(grid).toBeVisible({ timeout: 90_000 });

// Drop the comma after a `colHeaders` entry: a syntax error, so the transpile fails and
// the bundler is never told. The grid on screen is the last good render.
const commaAt = await page.evaluate(`(() => {
const view = document.querySelector('.cm-content').cmTile.view;
const at = view.state.doc.toString().indexOf("'Company name',") + "'Company name'".length;
view.dispatch({ changes: { from: at, to: at + 1, insert: "" } });
return at;
})()`);
await page.waitForTimeout(4000);
await expect(grid).toBeVisible();

// Put it back. The file is now byte-identical to the one that rendered.
await page.evaluate(
`document.querySelector('.cm-content').cmTile.view.dispatch({ changes: { from: ${commaAt}, insert: "," } })`,
);
await page.waitForTimeout(8000);
await expect(grid).toBeVisible();
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready");
});

// The other path to the same no-change compile: the row-2 refresh button pushes the
// current sources unchanged, which blanked the preview outright. `reload()` now stamps the
// entry *and* the example module so the bundler has a real diff to act on — stamping the
// HTML shell alone was measured to leave it blank, since a parcel sandbox boots from HTML
// but the module is what has to re-evaluate.
test("live: the refresh button re-runs the sandbox instead of blanking it", async ({ page }) => {
test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks");
test.setTimeout(180_000);

const preview = page.frameLocator("iframe").first();
await page.goto("/?example=react");
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", {
timeout: 120_000,
});
await expect(preview.locator(".handsontable td").first()).toBeVisible({ timeout: 90_000 });

await page.getByRole("button", { name: "Reload the preview" }).click();
await expect(preview.locator(".handsontable td").first()).toBeVisible({ timeout: 60_000 });
// Re-evaluating the entry must not stack a second grid on the page (the DEV-2129 class),
// and the plugin registry has to survive it — `getPlugin()` returning undefined after a
// refresh is exactly how that bug presented, with a grid still on screen.
await expect(preview.locator(".ht-root-wrapper")).toHaveCount(1);
await preview.locator(".handsontable td").first().click({ button: "right" });
await expect(preview.locator(".htContextMenu").first()).toBeVisible({ timeout: 15_000 });
});

// The reported Vue case (`VueCompilerError` with a template code frame). It reads like a
// second engine but is not: `vue` is a sandpack starter, so this is the same swallowed-
// `done` bug — through a *compile* error rather than a runtime one. Worth its own test
// because the two arrive differently: a compile failure never evaluates the module, so
// only the `done{compilatonError:true}` + `show-error` pair is seen.
test("live: fixing a Vue template error clears the preview error card", 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=vue");
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,
});

// A second root `<template>` — an SFC compile error, not a runtime one.
await activeEditor(page).click();
await page.keyboard.press("ControlOrMeta+End");
await page.keyboard.type("\n<template><div /></template>");
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", {
timeout: 60_000,
});

await page.keyboard.press("Shift+Home");
await page.keyboard.press("Backspace");
await page.keyboard.press("Backspace");
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", {
timeout: 60_000,
});
await expect(page.frameLocator("iframe").first().locator(".handsontable td").first()).toBeVisible({
timeout: 60_000,
});
});
3 changes: 3 additions & 0 deletions runner/packages/editor-shell/src/EditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface EditorShellProps {
bootLog?: string;
/** Tier 2: the boot overlay explains the tens-of-seconds wait and carries the log. */
containerBoot?: boolean;
/** Re-run the preview from the current sources; the error card's only action. */
onRetry?: () => void;
/** A container rebuild is in flight after an edit (shows "Applying changes…"). */
syncing?: boolean;
/** A row-2 refresh is in flight — blanks the pane behind a spinner (`72:26445`). */
Expand Down Expand Up @@ -479,6 +481,7 @@ export function EditorShell(props: EditorShellProps) {
errorMessage={props.errorMessage}
bootLog={props.bootLog}
containerBoot={props.containerBoot}
onRetry={props.onRetry}
syncing={props.syncing}
refreshing={props.refreshing}
/>
Expand Down
33 changes: 33 additions & 0 deletions runner/packages/editor-shell/src/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export interface PreviewPaneProps {
* pool is full — the window where the explanation matters most is the window where
* there is no log to infer it from. */
containerBoot?: boolean;
/** Re-run the preview from the current sources. Rendered as the error card's only
* action, so it is the way out of a failure the sources themselves have already
* fixed — a Tier-2 boot failure kills the container's dev server outright, and no
* amount of editing restarts it (`container.ts`, the `failed` branch).
*
* Optional: omitted when a remount cannot help, which is every failure the mount
* effect refuses to run at all (an unresolvable docs bucket or path). */
onRetry?: () => void;
/** A container rebuild is in flight after an edit. */
syncing?: boolean;
/** A row-2 refresh is in flight (`72:26445`). */
Expand Down Expand Up @@ -65,6 +73,7 @@ export function PreviewPane({
errorMessage,
bootLog,
containerBoot,
onRetry,
syncing,
refreshing,
}: PreviewPaneProps) {
Expand Down Expand Up @@ -109,6 +118,11 @@ export function PreviewPane({
<div style={errorCard}>
<p style={errorTitle}>The preview could not start</p>
<pre style={errorBody}>{errorMessage}</pre>
{onRetry && (
<button type="button" style={retryButton} onClick={onRetry}>
Restart preview
</button>
)}
</div>
</div>
)}
Expand Down Expand Up @@ -286,6 +300,25 @@ const errorTitle: CSSProperties = {
color: theme.color.danger,
};

/** Accent-filled, matching the dialogs' primary action (`EditInfoDialog`): the card
* has one action and it is the one the user wants. `alignSelf` keeps it to its own
* width instead of stretching across the card's column layout. */
const retryButton: CSSProperties = {
alignSelf: "flex-start",
display: "inline-flex",
alignItems: "center",
height: 32,
padding: `0 ${theme.space(3)}`,
border: `1px solid ${theme.color.accent}`,
borderRadius: theme.radius.md,
background: theme.color.accent,
color: theme.color.accentContrast,
fontFamily: theme.font.ui,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
};

const errorBody: CSSProperties = {
margin: 0,
maxHeight: 240,
Expand Down
Loading
Loading