Skip to content
Draft
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
35 changes: 28 additions & 7 deletions packages/ui/src/features/canvas/components/FreeformPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useInView } from "@posthog/ui/primitives/hooks/useInView";
import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary";
import { Box, Flex } from "@radix-ui/themes";
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useCallback } from "react";
import { type ReactNode, useCallback, useState } from "react";

// Render each canvas's live app at 1/SCALE of the card width, then shrink so it
// fits inside the preview frame as a thumbnail.
Expand Down Expand Up @@ -34,6 +34,17 @@ export function FreeformPreview({
className?: string;
}) {
const [ref, inView] = useInView<HTMLDivElement>(PREVIEW_VIEWPORT);
// A preview whose sandbox never painted (a CDN module that wouldn't load, a
// compile error) leaves an empty frame that reads as "this canvas is blank".
// Tracking the first render tells that apart from a canvas that rendered and
// only later reported an error, which should keep showing what it painted.
const [rendered, setRendered] = useState(false);
const [failed, setFailed] = useState(false);
const onRendered = useCallback(() => {
setRendered(true);
setFailed(false);
}, []);
const onError = useCallback(() => setFailed(true), []);

// Preview data handler: swallow captures so a thumbnail never emits analytics
// events, but let reads through (cached, shared with the full view) so the
Expand Down Expand Up @@ -69,17 +80,14 @@ export function FreeformPreview({
<ErrorBoundary
name="freeform-preview"
resetKey={code}
fallback={
<PreviewPlaceholder
icon={<WarningIcon size={18} />}
label="Preview unavailable"
/>
}
fallback={<PreviewUnavailable />}
>
<FreeformCanvas
code={code}
mode="edit"
onDataRequest={onDataRequest}
onRendered={onRendered}
onError={onError}
/>
</ErrorBoundary>
</Box>
Expand All @@ -94,10 +102,23 @@ export function FreeformPreview({
label="Nothing built yet"
/>
)}
{/* Overlays the SCALED frame from outside it, so the message reads at
full size rather than shrunk to thumbnail scale. */}
{failed && !rendered && <PreviewUnavailable />}
</Box>
);
}

// The sandbox failed before painting anything, so the frame is blank.
function PreviewUnavailable() {
return (
<PreviewPlaceholder
icon={<WarningIcon size={18} />}
label="Preview unavailable"
/>
);
}

function PreviewPlaceholder({
icon,
label,
Expand Down
41 changes: 41 additions & 0 deletions packages/ui/src/features/canvas/freeform/CanvasErrorState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CanvasErrorState } from "./CanvasErrorState";

describe("CanvasErrorState", () => {
it("explains the failure and offers a retry", async () => {
const onRetry = vi.fn();
render(
<CanvasErrorState
message="Couldn't load the canvas runtime."
onRetry={onRetry}
/>,
);

expect(
screen.getByText("Couldn't load the canvas runtime."),
).toBeInTheDocument();
screen.getByRole("button", { name: "Try again" }).click();
expect(onRetry).toHaveBeenCalledTimes(1);
});

it("only offers the agent in edit mode", () => {
const { rerender } = render(
<CanvasErrorState message="boom" onRetry={vi.fn()} />,
);
expect(
screen.queryByRole("button", { name: "Ask agent to fix" }),
).not.toBeInTheDocument();

rerender(
<CanvasErrorState
message="boom"
onRetry={vi.fn()}
onAskAgent={vi.fn()}
/>,
);
expect(
screen.getByRole("button", { name: "Ask agent to fix" }),
).toBeInTheDocument();
});
});
49 changes: 49 additions & 0 deletions packages/ui/src/features/canvas/freeform/CanvasErrorState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { WarningIcon } from "@phosphor-icons/react";
import {
Button,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@posthog/quill";

// Shown when a canvas has nothing on screen because it failed to load or render:
// a CDN/runtime fetch that never landed, a compile error, or a render throw the
// sandbox's boundary swallowed. Without it the viewport is simply blank — and in
// view mode (no toolbar) that blank carries no explanation and no way out.
// Recoverable: "Try again" rebuilds the frame, and in edit mode the agent can be
// pointed straight at the error.
export function CanvasErrorState({
message,
onRetry,
onAskAgent,
}: {
message: string;
onRetry: () => void;
/** Edit mode only — omitted in view mode, where there's no composer. */
onAskAgent?: () => void;
}) {
return (
<Empty className="h-full border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<WarningIcon size={24} />
</EmptyMedia>
<EmptyTitle>Couldn't load this canvas</EmptyTitle>
<EmptyDescription>{message}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="primary" size="default" onClick={onRetry}>
Try again
</Button>
{onAskAgent && (
<Button variant="outline" size="default" onClick={onAskAgent}>
Ask agent to fix
</Button>
)}
</EmptyContent>
</Empty>
);
}
65 changes: 63 additions & 2 deletions packages/ui/src/features/canvas/freeform/FreeformCanvas.test.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,39 @@
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
import { render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FreeformCanvas } from "./FreeformCanvas";
import { CANVAS_BOOT_TIMEOUT_ERROR, FreeformCanvas } from "./FreeformCanvas";

vi.mock("@posthog/ui/shell/openExternal", () => ({
openExternalUrl: vi.fn(),
}));

const renderCanvas = () => {
const renderCanvas = (
props?: Partial<ComponentProps<typeof FreeformCanvas>>,
) => {
render(
<FreeformCanvas
code="export default function Canvas() { return null }"
mode="edit"
onDataRequest={vi.fn()}
{...props}
/>,
);
return screen.getByTitle("Canvas") as HTMLIFrameElement;
};

const postFrameFromCanvas = (
iframe: HTMLIFrameElement,
frame: Record<string, unknown>,
) => {
window.dispatchEvent(
new MessageEvent("message", {
data: { channel: "posthog-canvas", ...frame },
source: iframe.contentWindow,
}),
);
};

const postFromCanvas = (iframe: HTMLIFrameElement, url: string) => {
window.dispatchEvent(
new MessageEvent("message", {
Expand All @@ -37,6 +53,51 @@ describe("FreeformCanvas", () => {
);
});

// A sandbox that never boots (blocked/hung CDN fetch, blocked srcDoc) posts
// nothing at all, so without a watchdog the host shows an indefinitely blank
// frame with nothing to act on.
describe("boot watchdog", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("reports an error when the sandbox never renders", () => {
const onError = vi.fn();
renderCanvas({ onError });

vi.advanceTimersByTime(30_001);

expect(onError).toHaveBeenCalledWith(CANVAS_BOOT_TIMEOUT_ERROR);
});

it.each([
{ name: "rendered", frame: { type: "rendered" } },
{ name: "error", frame: { type: "error", message: "boom" } },
])("stops waiting once the sandbox posts $name", ({ frame }) => {
const onError = vi.fn();
const iframe = renderCanvas({ onError });

postFrameFromCanvas(iframe, frame);
onError.mockClear();
vi.advanceTimersByTime(30_001);

expect(onError).not.toHaveBeenCalled();
});

it("does not wait on a canvas with no code to render", () => {
const onError = vi.fn();
renderCanvas({ code: "", onError });

vi.advanceTimersByTime(30_001);

expect(onError).not.toHaveBeenCalled();
});
});

describe("open-external", () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down
38 changes: 37 additions & 1 deletion packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ const log = logger.scope("freeform-canvas");
// Canvas code can post open-external without a gesture, so opens are limited.
const EXTERNAL_OPEN_MIN_INTERVAL_MS = 1_000;

// How long to wait for the sandbox to report `rendered` (or an error) before
// treating the boot as failed. The sandbox reports as soon as it commits its
// first render — it does NOT wait for the canvas's data — so this only has to
// cover fetching the CDN modules, hence generous but finite. Without it a boot
// that stalls without erroring (a hung CDN request, a blocked srcDoc) leaves the
// host showing an indefinitely blank frame with nothing to act on.
const BOOT_TIMEOUT_MS = 30_000;
export const CANVAS_BOOT_TIMEOUT_ERROR =
"The canvas didn't finish loading. Check your connection and try again.";

export interface FreeformCanvasProps {
/** The single-file React source to render. */
code: string;
Expand Down Expand Up @@ -105,6 +115,30 @@ export function FreeformCanvas({
theme,
};

// Boot watchdog: armed whenever the sandbox is (re)booted or handed new code,
// cleared by the first `rendered` or `error` frame. On expiry it synthesises an
// error so the host can offer a recoverable state instead of a blank frame.
const bootTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearBootTimer = useCallback(() => {
if (bootTimerRef.current === null) return;
clearTimeout(bootTimerRef.current);
bootTimerRef.current = null;
}, []);
const armBootTimer = useCallback(() => {
clearBootTimer();
bootTimerRef.current = setTimeout(() => {
bootTimerRef.current = null;
latest.current.onError?.(CANVAS_BOOT_TIMEOUT_ERROR);
}, BOOT_TIMEOUT_MS);
}, [clearBootTimer]);

// biome-ignore lint/correctness/useExhaustiveDependencies: srcDoc identity tracks a reload, which reboots the sandbox.
useEffect(() => {
if (!code) return;
armBootTimer();
return clearBootTimer;
}, [code, srcDoc, armBootTimer, clearBootTimer]);

const postInit = useCallback(() => {
const p = latest.current;
iframeRef.current?.contentWindow?.postMessage(
Expand Down Expand Up @@ -169,10 +203,12 @@ export function FreeformCanvas({
break;
}
case "error":
clearBootTimer();
log.warn("Freeform canvas error", { message: msg.message });
latest.current.onError?.(msg.message, msg.stack);
break;
case "rendered":
clearBootTimer();
latest.current.onRendered?.();
break;
case "navigate":
Expand Down Expand Up @@ -215,7 +251,7 @@ export function FreeformCanvas({

window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [postInit]);
}, [postInit, clearBootTimer]);

// Re-send init when the code / mode / analytics change, if the iframe is ready.
// NB: reference code/mode/analytics DIRECTLY here (not via postInit, which
Expand Down
Loading
Loading