Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit bd558ff

Browse files
authored
feat(canvas): render authoritative cloud artifacts
Generated-By: PostHog Code Task-Id: 3d87598e-5366-4937-8f6e-30808221241e
1 parent 92e218d commit bd558ff

6 files changed

Lines changed: 190 additions & 10 deletions

File tree

‎packages/core/src/canvas/canvasBuildSchemas.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type CanvasBuildRecord,
55
hasActiveCanvasBuild,
66
latestFinishedCanvasBuild,
7+
publishedCanvasBuild,
78
} from "./canvasBuildSchemas";
89
import { DashboardsService } from "./dashboardsService";
910
import type { DesktopFsClient } from "./desktopFsClient";
@@ -17,6 +18,8 @@ function build(
1718
sourceVersionId: `sv-${id}`,
1819
buildStatus,
1920
diagnostics: [],
21+
artifactUrl:
22+
buildStatus === "ready" ? "https://usercontent.example/index.html" : null,
2023
pinned: false,
2124
createdAt: "2026-07-26T00:00:00Z",
2225
finishedAt:
@@ -61,6 +64,14 @@ describe("canvas build lifecycle", () => {
6164
expect(finished?.id).toBe("b1");
6265
});
6366

67+
it("selects only the ready build named by the published pointer", () => {
68+
const ready = build("b0", "ready");
69+
const value = lifecycle([build("b1", "failed"), ready]);
70+
value.publishedBuildId = "b0";
71+
72+
expect(publishedCanvasBuild(value)).toBe(ready);
73+
});
74+
6475
it("maps the builds endpoint's snake_case body to the client shape", async () => {
6576
const fetchMock = vi.fn(
6677
async () =>
@@ -80,6 +91,7 @@ describe("canvas build lifecycle", () => {
8091
message: "no lodash",
8192
},
8293
],
94+
artifact_url: null,
8395
pinned: false,
8496
created_at: "2026-07-26T00:00:00Z",
8597
finished_at: "2026-07-26T00:01:00Z",

‎packages/core/src/canvas/canvasBuildSchemas.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,25 @@ export const canvasBuildRecordSchema = z.object({
1212
sourceVersionId: z.string(),
1313
buildStatus: canvasBuildStatusSchema,
1414
diagnostics: z.array(canvasDiagnosticSchema),
15+
artifactUrl: z.string().url().nullable(),
1516
pinned: z.boolean(),
1617
createdAt: z.string(),
1718
finishedAt: z.string().nullable(),
1819
});
1920
export type CanvasBuildRecord = z.infer<typeof canvasBuildRecordSchema>;
2021

22+
export function publishedCanvasBuild(
23+
lifecycle: CanvasBuildLifecycle,
24+
): CanvasBuildRecord | null {
25+
return (
26+
lifecycle.builds.find(
27+
(build) =>
28+
build.id === lifecycle.publishedBuildId &&
29+
build.buildStatus === "ready",
30+
) ?? null
31+
);
32+
}
33+
2134
export const canvasBuildLifecycleSchema = z.object({
2235
/** The live (last successful, still-eligible) build, null until one completes. */
2336
publishedBuildId: z.string().nullable(),

‎packages/core/src/canvas/dashboardsService.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ export class DashboardsService {
357357
source_version_id: string;
358358
build_status: string;
359359
diagnostics?: unknown[];
360+
artifact_url: string | null;
360361
pinned: boolean;
361362
created_at: string;
362363
finished_at: string | null;
@@ -370,6 +371,7 @@ export class DashboardsService {
370371
sourceVersionId: build.source_version_id,
371372
buildStatus: build.build_status,
372373
diagnostics: build.diagnostics ?? [],
374+
artifactUrl: build.artifact_url,
373375
pinned: build.pinned,
374376
createdAt: build.created_at,
375377
finishedAt: build.finished_at,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { BuiltCanvas } from "./BuiltCanvas";
4+
5+
describe("BuiltCanvas", () => {
6+
it("loads an immutable artifact without granting origin or popup access", () => {
7+
render(
8+
<BuiltCanvas
9+
artifactUrl="https://usercontent.example/build/index.html"
10+
onDataRequest={vi.fn()}
11+
/>,
12+
);
13+
14+
expect(screen.getByTitle("Canvas")).toHaveAttribute(
15+
"src",
16+
"https://usercontent.example/build/index.html",
17+
);
18+
expect(screen.getByTitle("Canvas")).toHaveAttribute(
19+
"sandbox",
20+
"allow-scripts",
21+
);
22+
expect(screen.getByTitle("Canvas")).toHaveAttribute(
23+
"referrerpolicy",
24+
"no-referrer",
25+
);
26+
});
27+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
type CanvasNavIntent,
3+
type CanvasToHostMessage,
4+
canvasToHostMessageSchema,
5+
type HostToCanvasMessage,
6+
} from "@posthog/core/canvas/freeformSchemas";
7+
import { isSafePostHogUrl } from "@posthog/shared";
8+
import { logger } from "@posthog/ui/shell/logger";
9+
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
10+
import { useLayoutEffect, useRef } from "react";
11+
12+
const log = logger.scope("built-canvas");
13+
const EXTERNAL_OPEN_MIN_INTERVAL_MS = 1_000;
14+
15+
export interface BuiltCanvasProps {
16+
artifactUrl: string;
17+
onDataRequest: (method: string, payload: unknown) => Promise<unknown>;
18+
onError?: (message: string, stack?: string) => void;
19+
onRendered?: () => void;
20+
onNavigate?: (intent: CanvasNavIntent) => void;
21+
}
22+
23+
export function BuiltCanvas({
24+
artifactUrl,
25+
onDataRequest,
26+
onError,
27+
onRendered,
28+
onNavigate,
29+
}: BuiltCanvasProps) {
30+
const iframeRef = useRef<HTMLIFrameElement>(null);
31+
const lastExternalOpenRef = useRef(0);
32+
const latest = useRef({ onDataRequest, onError, onRendered, onNavigate });
33+
latest.current = { onDataRequest, onError, onRendered, onNavigate };
34+
35+
useLayoutEffect(() => {
36+
const post = (message: HostToCanvasMessage) =>
37+
iframeRef.current?.contentWindow?.postMessage(message, "*");
38+
39+
const route = async (message: CanvasToHostMessage) => {
40+
switch (message.type) {
41+
case "data-request":
42+
try {
43+
post({
44+
channel: "posthog-canvas",
45+
type: "data-response",
46+
id: message.id,
47+
ok: true,
48+
result: await latest.current.onDataRequest(
49+
message.method,
50+
message.payload,
51+
),
52+
});
53+
} catch (error) {
54+
post({
55+
channel: "posthog-canvas",
56+
type: "data-response",
57+
id: message.id,
58+
ok: false,
59+
error: error instanceof Error ? error.message : String(error),
60+
});
61+
}
62+
break;
63+
case "error":
64+
log.warn("Built canvas error", { message: message.message });
65+
latest.current.onError?.(message.message, message.stack);
66+
break;
67+
case "rendered":
68+
latest.current.onRendered?.();
69+
break;
70+
case "navigate":
71+
latest.current.onNavigate?.(message.nav);
72+
break;
73+
case "open-external":
74+
if (
75+
isSafePostHogUrl(message.url) &&
76+
document.activeElement === iframeRef.current &&
77+
Date.now() - lastExternalOpenRef.current >=
78+
EXTERNAL_OPEN_MIN_INTERVAL_MS
79+
) {
80+
lastExternalOpenRef.current = Date.now();
81+
openExternalUrl(message.url);
82+
}
83+
break;
84+
case "ready":
85+
break;
86+
}
87+
};
88+
89+
const onMessage = (event: MessageEvent) => {
90+
if (event.source !== iframeRef.current?.contentWindow) return;
91+
const parsed = canvasToHostMessageSchema.safeParse(event.data);
92+
if (parsed.success) void route(parsed.data);
93+
};
94+
95+
window.addEventListener("message", onMessage);
96+
return () => window.removeEventListener("message", onMessage);
97+
}, []);
98+
99+
return (
100+
<iframe
101+
ref={iframeRef}
102+
title="Canvas"
103+
sandbox="allow-scripts"
104+
src={artifactUrl}
105+
referrerPolicy="no-referrer"
106+
className="h-full w-full border-0 bg-background"
107+
/>
108+
);
109+
}

‎packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
SpinnerGapIcon,
88
WarningIcon,
99
} from "@phosphor-icons/react";
10+
import { publishedCanvasBuild } from "@posthog/core/canvas/canvasBuildSchemas";
1011
import type { CanvasAnalyticsConfig } from "@posthog/core/canvas/freeformSchemas";
1112
import { useHostTRPC } from "@posthog/host-router/react";
1213
import {
@@ -22,6 +23,7 @@ import {
2223
isCanvasGenerating,
2324
isCanvasGenerationRunning,
2425
} from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus";
26+
import { useCanvasBuilds } from "@posthog/ui/features/canvas/hooks/useCanvasBuilds";
2527
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
2628
import { useCanvasChatPanelStore } from "@posthog/ui/features/canvas/stores/canvasChatPanelStore";
2729
import {
@@ -44,6 +46,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
4446
import { Link } from "@tanstack/react-router";
4547
import { AnimatePresence, motion } from "framer-motion";
4648
import { useCallback, useMemo, useRef, useState } from "react";
49+
import { BuiltCanvas } from "./BuiltCanvas";
4750
import { CanvasBuildStatus } from "./CanvasBuildStatus";
4851
import { CanvasFramePlaceholder } from "./CanvasFramePlaceholder";
4952
import { CanvasGenerateHero } from "./CanvasGenerateHero";
@@ -237,7 +240,11 @@ export function FreeformCanvasView({
237240
// Deriving from the record rather than waiting on the seed also means a seed
238241
// that never runs can't strand the canvas on a spinner.
239242
const renderCode = code || dashboard?.code || "";
240-
const showCanvas = !!renderCode;
243+
const { lifecycle } = useCanvasBuilds(dashboardId);
244+
const artifactUrl = lifecycle
245+
? publishedCanvasBuild(lifecycle)?.artifactUrl
246+
: null;
247+
const showCanvas = !!artifactUrl || !!renderCode;
241248
// `isGenerating` keys off the effective task (the optimistic bridge right after
242249
// submit, then the polled record) and short-circuits on a terminal run — so a
243250
// failed/cancelled run can't strand the canvas body on the spinner.
@@ -377,15 +384,25 @@ export function FreeformCanvasView({
377384
// this placeholder just reserves the viewport box and owns scroll via
378385
// the host's overlay, so the canvas survives navigation without a reload.
379386
<Box className="h-full w-full">
380-
<CanvasFramePlaceholder
381-
dashboardId={dashboardId}
382-
code={renderCode}
383-
analytics={analytics}
384-
onDataRequest={onDataRequest}
385-
onError={onError}
386-
onRendered={onRendered}
387-
onNavigate={onNavigate}
388-
/>
387+
{artifactUrl ? (
388+
<BuiltCanvas
389+
artifactUrl={artifactUrl}
390+
onDataRequest={onDataRequest}
391+
onError={onError}
392+
onRendered={onRendered}
393+
onNavigate={onNavigate}
394+
/>
395+
) : (
396+
<CanvasFramePlaceholder
397+
dashboardId={dashboardId}
398+
code={renderCode}
399+
analytics={analytics}
400+
onDataRequest={onDataRequest}
401+
onError={onError}
402+
onRendered={onRendered}
403+
onNavigate={onNavigate}
404+
/>
405+
)}
389406
</Box>
390407
) : (
391408
<ScrollArea className="h-full">

0 commit comments

Comments
 (0)