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
1 change: 0 additions & 1 deletion packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,6 @@ export function useDomEditSession({
domEditSelectionRef,
domEditGroupSelectionsRef,
refreshDomEditGroupSelectionsFromPreview,
previewIframeRef,
previewIframe,
captionEditMode,
refreshKey,
Expand Down
8 changes: 4 additions & 4 deletions packages/studio/src/hooks/useDomEditWiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export interface UseDomEditWiringParams {
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
previewIframe: HTMLIFrameElement | null;
captionEditMode: boolean;
refreshKey: number;
Expand Down Expand Up @@ -119,7 +118,6 @@ export function useDomEditWiring({
domEditSelectionRef,
domEditGroupSelectionsRef,
refreshDomEditGroupSelectionsFromPreview,
previewIframeRef,
previewIframe,
captionEditMode,
refreshKey,
Expand Down Expand Up @@ -201,7 +199,7 @@ export function useDomEditWiring({
projectId ?? null,
gsapSourceFile,
gsapCacheVersion,
previewIframeRef,
previewIframe,
);

const {
Expand All @@ -217,7 +215,9 @@ export function useDomEditWiring({
gsapCacheVersion,
// Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to
// the live element and surface in the inspector — not just by #id match.
previewIframeRef,
// The element itself, not the ref: it is the same one this ref points at,
// and the hook resolves it during render, where reading a ref is not allowed.
previewIframe,
);

// ── Telemetry & fallback ──
Expand Down
91 changes: 91 additions & 0 deletions packages/studio/src/hooks/useElementPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useElementPicker } from "./useElementPicker";

Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);

type Picker = ReturnType<typeof useElementPicker>;

/** One handle per render, so the assertions read a list instead of a mutated binding. */
const rendered: Picker[] = [];

function Probe({ options }: { options?: Parameters<typeof useElementPicker>[1] }) {
rendered.push(useElementPicker({ current: primary }, options));
return null;
}

function latest(): Picker {
const handle = rendered[rendered.length - 1];
if (!handle) throw new Error("hook did not render");
return handle;
}

let primary: HTMLIFrameElement;
let override: HTMLIFrameElement;
let root: ReturnType<typeof createRoot>;

beforeEach(() => {
rendered.length = 0;
primary = document.createElement("iframe");
override = document.createElement("iframe");
document.body.append(primary, override);
root = createRoot(document.createElement("div"));
});

afterEach(() => {
act(() => root.unmount());
document.body.replaceChildren();
});

describe("useElementPicker", () => {
it("points activeIframeRef at the preview iframe by default", () => {
act(() => root.render(<Probe />));

expect(latest().activeIframeRef.current).toBe(primary);
});

it("follows the zoomed frame once an override is set and the app re-renders", () => {
act(() => root.render(<Probe />));

act(() => {
latest().setActiveIframe(override);
root.render(<Probe />);
});

expect(latest().activeIframeRef.current).toBe(override);
});

it("patches source through the options given to the newest render", () => {
const first = vi.fn();
const second = vi.fn();
const files = { "index.html": `<div id="card">card</div>` };
primary.contentDocument!.body.innerHTML = `<div id="card">card</div>`;

act(() => root.render(<Probe options={{ workspaceFiles: files, onSyncFiles: first }} />));
act(() => {
window.dispatchEvent(
new MessageEvent("message", {
source: primary.contentWindow,
data: {
source: "hf-preview",
type: "element-picked",
elementInfo: { id: "card", tagName: "div", selector: "#card" },
},
}),
);
});
expect(latest().pickedElement?.id).toBe("card");

act(() => root.render(<Probe options={{ workspaceFiles: files, onSyncFiles: second }} />));
act(() => latest().setStyle("color", "red"));

// The newest render's callback, not the one the hook first mounted with:
// an inline-style edit has to reach the file map the app currently holds.
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledWith({
"index.html": expect.stringContaining("color: red"),
});
});
});
22 changes: 17 additions & 5 deletions packages/studio/src/hooks/useElementPicker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { useMountEffect } from "./useMountEffect";
import { resolveSourceFile, applyPatch } from "../utils/sourcePatcher";
import {
Expand Down Expand Up @@ -146,9 +146,16 @@ export function useElementPicker(
return () => window.removeEventListener("message", handleMessage);
});

// Ref for options to avoid stale closures in debounced callback
// Ref for options to avoid stale closures in debounced callback.
//
// Written after commit, not during render: a ref write in the hook body is a
// render side effect and the React Compiler declines the whole hook when it
// sees one. Every reader is a sync callback driven by a user edit, which
// cannot run before the render that produced `options` has committed.
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
optionsRef.current = options;
});

// Sync immediately (not debounced) — save on every change for reliability
const syncToSource = useCallback(
Expand Down Expand Up @@ -286,9 +293,14 @@ export function useElementPicker(
[pickedElement, getActiveIframe, syncToSource],
);

// Ref-like object that always points to the active iframe (override or primary)
// Ref-like object that always points to the active iframe (override or primary).
// Refreshed after commit rather than during render, both because a ref write in
// the hook body makes the React Compiler decline the hook and because the
// iframe element this reads only exists once React has attached it.
const activeIframeRef = useRef<HTMLIFrameElement | null>(null);
activeIframeRef.current = getActiveIframe();
useEffect(() => {
activeIframeRef.current = getActiveIframe();
});

return {
isPickMode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,14 @@ export function useExternalFileChangeCoordinator({
const lastEventIdentityRef = useRef<string | null>(null);
const blockedRef = useRef(blocked);
const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
blockedRef.current = blocked;

// After commit, not during render: a ref write in the hook body is a render
// side effect and the React Compiler declines the whole hook when it sees one.
// Every reader of `blockedRef` below is an async callback, which cannot run
// before the render that produced `blocked` has committed.
useEffect(() => {
blockedRef.current = blocked;
});

useEffect(() => {
mountedRef.current = true;
Expand Down
25 changes: 18 additions & 7 deletions packages/studio/src/hooks/useFileManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useRef } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import type { EditingFile } from "../utils/studioHelpers";
import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
Expand Down Expand Up @@ -46,10 +46,16 @@ export function useFileManager({
const [revealSourceOffset, setRevealSourceOffset] = useState<number | null>(null);

const editingPathRef = useRef(editingFile?.path);
editingPathRef.current = editingFile?.path;

const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;

// After commit, not during render: a ref write in the hook body is a render
// side effect and the React Compiler declines the whole hook when it sees one.
// Both refs are read only from callbacks, here and in the hooks they are handed
// to, and a callback cannot run before the render that set them has committed.
useEffect(() => {
editingPathRef.current = editingFile?.path;
projectIdRef.current = projectId;
});

const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
const fileVersionScope = useMemo(
Expand Down Expand Up @@ -258,14 +264,19 @@ export function useFileManager({

// ── Click-to-source ──

// Named, rather than `editingFile?.content` inline: an optional member as a
// dependency is a shape the React Compiler cannot match against the one it
// infers from the body, and the mismatch costs this hook its memoization.
const editingContent = editingFile?.content;

const openSourceForSelection = useCallback(
(sourceFile: string, target: PatchTarget) => {
const pid = projectIdRef.current;
if (!pid || !sourceFile) return;
revealAbortRef.current?.abort();
revealAbortRef.current = null;
if (editingPathRef.current === sourceFile && editingFile?.content != null) {
const match = findTagByTarget(editingFile.content, target);
if (editingPathRef.current === sourceFile && editingContent != null) {
const match = findTagByTarget(editingContent, target);
setRevealSourceOffset(match ? match.start : null);
return;
}
Expand All @@ -287,7 +298,7 @@ export function useFileManager({
})
.catch(() => {});
},
[editingFile?.content, fileVersions],
[editingContent, fileVersions],
);

// ── Upload ──
Expand Down
121 changes: 121 additions & 0 deletions packages/studio/src/hooks/useFrameCapture.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// @vitest-environment happy-dom
import { act, type MouseEvent } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useFrameCapture } from "./useFrameCapture";

Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);

/** One handle per render, so the assertions read a list instead of a mutated binding. */
const rendered: ReturnType<typeof useFrameCapture>[] = [];
const showToast = vi.fn();
const waitForPendingDomEditSaves = vi.fn(async () => {});

function Probe() {
rendered.push(
useFrameCapture({
projectId: "p1",
activeCompPath: "index.html",
showToast,
waitForPendingDomEditSaves,
}),
);
return null;
}

function latest() {
const handle = rendered[rendered.length - 1];
if (!handle) throw new Error("hook did not render");
return handle;
}

const clickEvent = { preventDefault: vi.fn() } as unknown as MouseEvent<HTMLAnchorElement>;

let unmount: () => void;

beforeEach(() => {
rendered.length = 0;
vi.clearAllMocks();
const root = createRoot(document.createElement("div"));
act(() => root.render(<Probe />));
unmount = () => act(() => root.unmount());
});

afterEach(() => {
unmount();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe("useFrameCapture", () => {
it("downloads the captured frame and leaves the button usable again", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, blob: async () => new Blob(["png"]) })),
);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:frame");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});

await act(async () => {
await latest().handleCaptureFrameClick(clickEvent);
});

expect(click).toHaveBeenCalledTimes(1);
expect(showToast).not.toHaveBeenCalled();
expect(latest().capturing).toBe(false);
});

it("reports the server's own message when the capture request fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 500,
json: async () => ({ error: "renderer crashed" }),
})),
);

await act(async () => {
await latest().handleCaptureFrameClick(clickEvent);
});

expect(showToast).toHaveBeenCalledWith("renderer crashed", "error");
// The latch is lowered on the failure path too, or one bad capture disables
// the button for the rest of the session.
expect(latest().capturing).toBe(false);
});

it("falls back to the status code when the failure body is not JSON", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: false,
status: 503,
json: async () => {
throw new Error("not json");
},
})),
);

await act(async () => {
await latest().handleCaptureFrameClick(clickEvent);
});

expect(showToast).toHaveBeenCalledWith("Capture failed (503)", "error");
});

it("reports a save-queue failure without ever issuing the request", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
waitForPendingDomEditSaves.mockRejectedValueOnce(new Error("Save queue timed out"));

await act(async () => {
await latest().handleCaptureFrameClick(clickEvent);
});

expect(fetchSpy).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith("Save queue timed out", "error");
expect(latest().capturing).toBe(false);
});
});
Loading
Loading