Skip to content
Closed
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
25 changes: 25 additions & 0 deletions packages/ui/src/features/canvas/components/WebsiteLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ArrowClockwiseIcon,
DotsThreeIcon,
GitForkIcon,
LinkIcon,
Expand All @@ -18,6 +19,8 @@ import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/Channe
import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu";
import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost";
import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore";
import { CANVAS_QUERY_KEY } from "@posthog/ui/features/canvas/freeform/freeformDataBridge";
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
import {
Expand All @@ -39,6 +42,7 @@ import { toast } from "@posthog/ui/primitives/toast";
import { track } from "@posthog/ui/shell/analytics";
import { useHeaderStore } from "@posthog/ui/shell/headerStore";
import { Box, Flex } from "@radix-ui/themes";
import { useQueryClient } from "@tanstack/react-query";
import {
Outlet,
useNavigate,
Expand Down Expand Up @@ -106,6 +110,23 @@ function FreeformEditControls({
const revert = useFreeformChatStore((s) => s.revert);
const goToLatest = useFreeformChatStore((s) => s.goToLatest);

const queryClient = useQueryClient();
const remountFrame = useCanvasFrameStore((s) => s.remount);
// Fully remount the mounted canvas iframe: drop the host-side read cache so
// queries re-run, then recreate the iframe element (not just reload its
// document) so a refresh also recovers from a wedged frame.
const onRefresh = () => {
track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
action_type: "refresh",
surface: "canvas",
channel_id: channelId,
dashboard_id: dashboardId,
kind: "freeform",
});
void queryClient.invalidateQueries({ queryKey: [CANVAS_QUERY_KEY] });
remountFrame(dashboardId);
};

const hasCode = code.length > 0;
// Viewing the head version (or there's no history yet) → autosave is live.
// Otherwise the user has undone to an older version and is browsing.
Expand Down Expand Up @@ -212,6 +233,10 @@ function FreeformEditControls({
}
/>
<DropdownMenuContent align="end" side="bottom" sideOffset={4}>
<DropdownMenuItem onClick={onRefresh}>
<ArrowClockwiseIcon size={14} />
Refresh
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
void copyCanvasLink(channelId, dashboardId, "canvas")
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/features/canvas/freeform/CanvasFrameHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FreeformCanvas } from "./FreeformCanvas";
export function CanvasFrameHost() {
const slots = useCanvasFrameStore((s) => s.slots);
const activeDashboardId = useCanvasFrameStore((s) => s.activeDashboardId);
const frameKeys = useCanvasFrameStore((s) => s.frameKeys);

return (
// Fixed, viewport-filling, click-through layer. Children are positioned in
Expand Down Expand Up @@ -43,15 +44,16 @@ export function CanvasFrameHost() {
// Keyed by the physical frame's identity (its slot index), NOT dashboardId:
// reassigning a slot to a new canvas must reuse the same iframe (init
// code-swap), not remount it — remounting re-parents the iframe = reload.
const frameKey = `slot-${slotId}`;
// The remount generation (bumped only by an explicit user Refresh) is
// folded in so that action — and only that action — recreates the iframe.
const frameKey = `slot-${slotId}-${frameKeys[slotId] ?? 0}`;
return (
<div key={frameKey} style={style}>
<ErrorBoundary name="freeform-canvas" resetKey={slot.dashboardId}>
<FreeformCanvas
code={slot.inputs.code}
mode="edit"
analytics={slot.inputs.analytics}
refreshKey={slot.inputs.refreshKey}
onDataRequest={slot.inputs.onDataRequest}
onError={slot.inputs.onError}
onRendered={slot.inputs.onRendered}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CanvasFramePlaceholder } from "./CanvasFramePlaceholder";
import { useCanvasFrameStore } from "./canvasFrameStore";

// The warm-frame host only shows a canvas once its slot has a measured rect
// (`active = ... && !!slot.rect`). The placeholder must populate that rect from
// its own synchronous measure on mount, WITHOUT relying on a later
// ResizeObserver/scroll re-measure — on a settled layout no such re-measure
// fires, so a canvas opened while the app has been running a while (e.g. via a
// deep link) would otherwise stay hidden until a hard refresh. The jsdom
// ResizeObserver stub never fires, so this test reproduces that settled-layout
// condition: if the rect isn't captured synchronously, it never is.

const RECT = { top: 10, left: 20, width: 800, height: 600 };

function resetStore() {
useCanvasFrameStore.setState({
slots: [],
activeDashboardId: null,
maxWarmFrames: 2,
});
}

describe("CanvasFramePlaceholder", () => {
beforeEach(() => {
resetStore();
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
...RECT,
right: RECT.left + RECT.width,
bottom: RECT.top + RECT.height,
x: RECT.left,
y: RECT.top,
toJSON: () => "",
} as DOMRect);
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
resetStore();
});

it("captures the slot rect on mount without a follow-up re-measure", () => {
render(
<CanvasFramePlaceholder
dashboardId="dash-1"
code="export default () => null"
onDataRequest={async () => null}
/>,
);

const slot = useCanvasFrameStore
.getState()
.slots.find((s) => s?.dashboardId === "dash-1");
expect(slot).toBeTruthy();
// Pre-fix this is null (the slot is registered in a passive effect that runs
// after the layout-effect measure, so the first measure is dropped).
expect(slot?.rect).toEqual(RECT);
expect(useCanvasFrameStore.getState().activeDashboardId).toBe("dash-1");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import type {
CanvasAnalyticsConfig,
CanvasNavIntent,
} from "@posthog/core/canvas/freeformSchemas";
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { useCanvasRefreshNonce } from "../stores/canvasRefreshStore";
import { useLayoutEffect, useMemo, useRef } from "react";
import { useCanvasFrameStore } from "./canvasFrameStore";

// Stands in for the canvas inside the route tree. It renders nothing visible —
Expand All @@ -29,7 +28,6 @@ export function CanvasFramePlaceholder({
onNavigate?: (intent: CanvasNavIntent) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const refreshKey = useCanvasRefreshNonce(`dashboard:${dashboardId}`);

const register = useCanvasFrameStore((s) => s.register);
const setRect = useCanvasFrameStore((s) => s.setRect);
Expand All @@ -40,24 +38,21 @@ export function CanvasFramePlaceholder({
() => ({
code,
analytics,
refreshKey,
onDataRequest,
onError,
onRendered,
onNavigate,
}),
[
code,
analytics,
refreshKey,
onDataRequest,
onError,
onRendered,
onNavigate,
],
[code, analytics, onDataRequest, onError, onRendered, onNavigate],
);

useEffect(() => {
// Layout effect (not passive) and declared first, so the slot exists before the
// rect-measure effect below runs its initial synchronous measure. Otherwise the
// slot is created too late (setRect no-ops with no slot) and the frame only
// becomes visible once a later scroll/resize re-measures — which never happens
// on a settled layout, so a canvas opened while the app has been running a while
// (e.g. via a deep link) stays hidden until a hard refresh.
useLayoutEffect(() => {
register(dashboardId, inputs);
}, [dashboardId, inputs, register]);

Expand Down
17 changes: 4 additions & 13 deletions packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,6 @@ export interface FreeformCanvasProps {
* never crosses into the iframe.
*/
analytics?: CanvasAnalyticsConfig;
/**
* Bump to force a full iframe reload (a fresh boot re-runs the app's data
* `useEffect`s = a real refresh). Folded into the srcDoc so changing it
* reloads the frame via the existing reload path.
*/
refreshKey?: number;
}

// Renders a freeform-React canvas inside a null-origin sandboxed iframe and
Expand All @@ -64,7 +58,6 @@ export function FreeformCanvas({
onRendered,
onNavigate,
analytics,
refreshKey = 0,
}: FreeformCanvasProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// The canvas mirrors the host's light/dark theme. Passed via `init` (not the
Expand All @@ -79,12 +72,10 @@ export function FreeformCanvas({
// for posthog-js), not on code: code is injected via `init`, so changing it
// never reloads the iframe — it re-renders in place.
const analyticsHost = analytics?.apiHost;
const srcDoc = useMemo(() => {
const doc = buildSandboxDocument(mode, analyticsHost);
// Append the refresh nonce as a comment so a bump changes the srcDoc string,
// which reloads the iframe (re-announces "ready" → re-init → fresh run).
return refreshKey > 0 ? `${doc}\n<!-- refresh:${refreshKey} -->` : doc;
}, [mode, analyticsHost, refreshKey]);
const srcDoc = useMemo(
() => buildSandboxDocument(mode, analyticsHost),
[mode, analyticsHost],
);

// Latest props, read by the once-bound listener + the (stable) postInit.
const latest = useRef({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
} from "./canvasFrameStore";

function inputs(code: string): CanvasFrameInputs {
return { code, refreshKey: 0, onDataRequest: vi.fn() };
return { code, onDataRequest: vi.fn() };
}

function reset() {
useCanvasFrameStore.setState({
slots: [],
activeDashboardId: null,
maxWarmFrames: 2,
frameKeys: {},
});
}

Expand Down Expand Up @@ -88,6 +89,38 @@ describe("canvasFrameStore", () => {
expect(useCanvasFrameStore.getState().slots).toBe(after);
});

it("remount bumps only the frame generation of the canvas's slot", () => {
const { register, remount } = useCanvasFrameStore.getState();
register("a", inputs("A"));
register("b", inputs("B"));

remount("a");
expect(useCanvasFrameStore.getState().frameKeys[0]).toBe(1);
expect(useCanvasFrameStore.getState().frameKeys[1] ?? 0).toBe(0);
});

it("remount is a no-op for a canvas with no slot", () => {
const { remount } = useCanvasFrameStore.getState();
const before = useCanvasFrameStore.getState().frameKeys;
remount("missing");
expect(useCanvasFrameStore.getState().frameKeys).toBe(before);
});

it("keeps a slot's remount generation when it is reassigned (warm reuse)", () => {
const { register, activate, remount } = useCanvasFrameStore.getState();
register("a", inputs("A"));
activate("a");
remount("a"); // slot 0 generation -> 1
register("b", inputs("B"));
activate("b");
register("c", inputs("C")); // evicts LRU "a", "c" takes slot 0

expect(slotIndexOf("c")).toBe(0);
// Reassigning slot 0 to a different canvas must NOT change its key, so the
// warm iframe is reused (code-swap) rather than remounted on navigation.
expect(useCanvasFrameStore.getState().frameKeys[0]).toBe(1);
});

it("deactivate clears the active id only when it matches", () => {
const { register, activate, deactivate } = useCanvasFrameStore.getState();
register("a", inputs("A"));
Expand Down
21 changes: 20 additions & 1 deletion packages/ui/src/features/canvas/freeform/canvasFrameStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export interface CanvasFrameRect {
export interface CanvasFrameInputs {
code: string;
analytics?: CanvasAnalyticsConfig;
refreshKey: number;
onDataRequest: (method: string, payload: unknown) => Promise<unknown>;
onError?: (message: string, stack?: string) => void;
onRendered?: () => void;
Expand All @@ -49,11 +48,18 @@ interface CanvasFrameStore {
slots: (CanvasFrameSlot | null)[];
activeDashboardId: string | null;
maxWarmFrames: number;
// Per-slot remount generation. Folded into the frame's React key by the host,
// so bumping it tears down and recreates that slot's iframe element (a true
// remount, unlike a code-swap). Keyed by SLOT INDEX, not canvas, and only ever
// bumped by an explicit user refresh — so reassigning a slot to another canvas
// (navigation) leaves it untouched and still reuses the warm iframe.
frameKeys: Record<number, number>;

register: (dashboardId: string, inputs: CanvasFrameInputs) => void;
setRect: (dashboardId: string, rect: CanvasFrameRect) => void;
activate: (dashboardId: string) => void;
deactivate: (dashboardId: string) => void;
remount: (dashboardId: string) => void;
setMaxWarmFrames: (n: number) => void;
}

Expand Down Expand Up @@ -118,6 +124,7 @@ export const useCanvasFrameStore = create<CanvasFrameStore>()((set) => ({
slots: [],
activeDashboardId: null,
maxWarmFrames: DEFAULT_MAX_WARM_FRAMES,
frameKeys: {},

register: (dashboardId, inputs) =>
set((s) => ({
Expand Down Expand Up @@ -168,6 +175,18 @@ export const useCanvasFrameStore = create<CanvasFrameStore>()((set) => ({
s.activeDashboardId === dashboardId ? { activeDashboardId: null } : s,
),

// Force a full remount of the canvas's mounted iframe (recreate the element +
// its sandboxed document), for a manual "Refresh" that must recover from a
// wedged frame, not just reload the document. No-op if the canvas has no slot.
remount: (dashboardId) =>
set((s) => {
const idx = findSlot(s.slots, dashboardId);
if (idx < 0) return s;
return {
frameKeys: { ...s.frameKeys, [idx]: (s.frameKeys[idx] ?? 0) + 1 },
};
}),

setMaxWarmFrames: (n) =>
set((s) => ({
maxWarmFrames: Math.max(1, n),
Expand Down
27 changes: 0 additions & 27 deletions packages/ui/src/features/canvas/stores/canvasRefreshStore.ts

This file was deleted.

Loading