diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 0a2d43bfc3..90e8bed655 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -427,7 +427,6 @@ export function useDomEditSession({ domEditSelectionRef, domEditGroupSelectionsRef, refreshDomEditGroupSelectionsFromPreview, - previewIframeRef, previewIframe, captionEditMode, refreshKey, diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts index 7dc5261085..7a7e64c840 100644 --- a/packages/studio/src/hooks/useDomEditWiring.ts +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -25,7 +25,6 @@ export interface UseDomEditWiringParams { domEditSelectionRef: React.MutableRefObject; domEditGroupSelectionsRef: React.MutableRefObject; refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise; - previewIframeRef: React.RefObject; previewIframe: HTMLIFrameElement | null; captionEditMode: boolean; refreshKey: number; @@ -119,7 +118,6 @@ export function useDomEditWiring({ domEditSelectionRef, domEditGroupSelectionsRef, refreshDomEditGroupSelectionsFromPreview, - previewIframeRef, previewIframe, captionEditMode, refreshKey, @@ -201,7 +199,7 @@ export function useDomEditWiring({ projectId ?? null, gsapSourceFile, gsapCacheVersion, - previewIframeRef, + previewIframe, ); const { @@ -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 ── diff --git a/packages/studio/src/hooks/useElementPicker.test.tsx b/packages/studio/src/hooks/useElementPicker.test.tsx new file mode 100644 index 0000000000..9ffcbe9182 --- /dev/null +++ b/packages/studio/src/hooks/useElementPicker.test.tsx @@ -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; + +/** One handle per render, so the assertions read a list instead of a mutated binding. */ +const rendered: Picker[] = []; + +function Probe({ options }: { options?: Parameters[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; + +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()); + + expect(latest().activeIframeRef.current).toBe(primary); + }); + + it("follows the zoomed frame once an override is set and the app re-renders", () => { + act(() => root.render()); + + act(() => { + latest().setActiveIframe(override); + root.render(); + }); + + 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": `
card
` }; + primary.contentDocument!.body.innerHTML = `
card
`; + + act(() => root.render()); + 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()); + 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"), + }); + }); +}); diff --git a/packages/studio/src/hooks/useElementPicker.ts b/packages/studio/src/hooks/useElementPicker.ts index b04848377d..10377970ce 100644 --- a/packages/studio/src/hooks/useElementPicker.ts +++ b/packages/studio/src/hooks/useElementPicker.ts @@ -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 { @@ -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( @@ -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(null); - activeIframeRef.current = getActiveIframe(); + useEffect(() => { + activeIframeRef.current = getActiveIframe(); + }); return { isPickMode, diff --git a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts index 11748c7436..7570c92357 100644 --- a/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts +++ b/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts @@ -135,7 +135,14 @@ export function useExternalFileChangeCoordinator({ const lastEventIdentityRef = useRef(null); const blockedRef = useRef(blocked); const snapshotWriteTailRef = useRef>(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; diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index f2eb0201d5..0146fac6d7 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -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"; @@ -46,10 +46,16 @@ export function useFileManager({ const [revealSourceOffset, setRevealSourceOffset] = useState(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([]); const fileVersionScope = useMemo( @@ -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; } @@ -287,7 +298,7 @@ export function useFileManager({ }) .catch(() => {}); }, - [editingFile?.content, fileVersions], + [editingContent, fileVersions], ); // ── Upload ── diff --git a/packages/studio/src/hooks/useFrameCapture.test.tsx b/packages/studio/src/hooks/useFrameCapture.test.tsx new file mode 100644 index 0000000000..90724a908f --- /dev/null +++ b/packages/studio/src/hooks/useFrameCapture.test.tsx @@ -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[] = []; +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; + +let unmount: () => void; + +beforeEach(() => { + rendered.length = 0; + vi.clearAllMocks(); + const root = createRoot(document.createElement("div")); + act(() => root.render()); + 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); + }); +}); diff --git a/packages/studio/src/hooks/useFrameCapture.ts b/packages/studio/src/hooks/useFrameCapture.ts index 962298e1bd..6c97e188a5 100644 --- a/packages/studio/src/hooks/useFrameCapture.ts +++ b/packages/studio/src/hooks/useFrameCapture.ts @@ -10,6 +10,75 @@ interface UseFrameCaptureParams { waitForPendingDomEditSaves: () => Promise; } +interface FrameCaptureRequest { + projectId: string; + activeCompPath: string | null; + time: number; + waitForPendingDomEditSaves: () => Promise; +} + +/** + * Fetch the rendered frame and hand it to the browser as a download. Resolves to + * the message to show the user, or `undefined` when the capture landed. + * + * Module scope, and a returned message instead of a throw, because the React + * Compiler can lower neither a `throw` inside a `try`/`catch` nor a `finally`, + * and declines the entire hook when it finds one. The hook is left with the two + * state writes that bracket the request. + */ +async function downloadCapturedFrame(request: FrameCaptureRequest): Promise { + const { projectId, activeCompPath, time, waitForPendingDomEditSaves } = request; + try { + await Promise.race([ + waitForPendingDomEditSaves(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Save queue timed out")), 5000), + ), + ]); + } catch (err) { + return err instanceof Error ? err.message : "Capture failed"; + } + // The 30s clock starts here, after the save drain, so the two budgets stay + // separate and the abort can only ever cancel the request itself. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + try { + const href = buildFrameCaptureUrl({ + projectId, + compositionPath: activeCompPath, + currentTime: time, + }); + const response = await fetch(href, { cache: "no-store", signal: controller.signal }); + clearTimeout(timeout); + if (!response.ok) return await captureErrorMessage(response); + const blobUrl = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = buildFrameCaptureFilename(activeCompPath, time); + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + return undefined; + } catch (err) { + clearTimeout(timeout); + if (err instanceof DOMException && err.name === "AbortError") { + return "Capture timed out — the server took too long to respond"; + } + return err instanceof Error ? err.message : "Capture failed"; + } +} + +async function captureErrorMessage(response: Response): Promise { + try { + const json = await response.json(); + if (json?.error) return String(json.error); + } catch { + /* non-JSON response — use default message */ + } + return `Capture failed (${response.status})`; +} + export function useFrameCapture({ projectId, activeCompPath, @@ -38,58 +107,17 @@ export function useFrameCapture({ if (capturingRef.current) return; capturingRef.current = true; setCapturing(true); - try { - const time = usePlayerStore.getState().currentTime; - setCaptureFrameTime(time); - await Promise.race([ - waitForPendingDomEditSaves(), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Save queue timed out")), 5000), - ), - ]); - const href = buildFrameCaptureUrl({ - projectId, - compositionPath: activeCompPath, - currentTime: time, - }); - const filename = buildFrameCaptureFilename(activeCompPath, time); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30000); - try { - const response = await fetch(href, { cache: "no-store", signal: controller.signal }); - clearTimeout(timeout); - if (!response.ok) { - let msg = `Capture failed (${response.status})`; - try { - const json = await response.json(); - if (json?.error) msg = json.error; - } catch { - /* non-JSON response — use default message */ - } - throw new Error(msg); - } - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = blobUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); - } catch (fetchErr) { - clearTimeout(timeout); - if (fetchErr instanceof DOMException && fetchErr.name === "AbortError") { - throw new Error("Capture timed out — the server took too long to respond"); - } - throw fetchErr; - } - } catch (err) { - showToast(err instanceof Error ? err.message : "Capture failed", "error"); - } finally { - capturingRef.current = false; - setCapturing(false); - } + const time = usePlayerStore.getState().currentTime; + setCaptureFrameTime(time); + const failure = await downloadCapturedFrame({ + projectId, + activeCompPath, + time, + waitForPendingDomEditSaves, + }); + if (failure) showToast(failure, "error"); + capturingRef.current = false; + setCapturing(false); }, [activeCompPath, projectId, showToast, waitForPendingDomEditSaves], ); diff --git a/packages/studio/src/hooks/useGestureCommit.test.tsx b/packages/studio/src/hooks/useGestureCommit.test.tsx index e67df39695..503c338b2b 100644 --- a/packages/studio/src/hooks/useGestureCommit.test.tsx +++ b/packages/studio/src/hooks/useGestureCommit.test.tsx @@ -126,5 +126,50 @@ describe("useGestureCommit", () => { expect(options[0]).not.toHaveProperty("softReload"); expect(options[1]).toEqual(expect.objectContaining({ coalesceMs: Infinity, softReload: true })); expect(options[1]).not.toHaveProperty("skipReload"); + expect(gestureRecording.clearSamples).toHaveBeenCalledTimes(1); + }); + + it("still releases the recording when the commit rejects", async () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const element = document.createElement("div"); + element.id = "card"; + const showToast = vi.fn(); + const sessionRef = { + current: { + domEditSelection: makeSelection(element), + selectedGsapAnimations: [], + commitMutation: vi.fn(async () => { + throw new Error("write failed"); + }), + }, + }; + const captured: { hook: ReturnType | null } = { hook: null }; + function Probe() { + captured.hook = useGestureCommit({ + domEditSessionRef: sessionRef, + previewIframeRef: { current: iframe }, + showToast, + isGestureRecordingRef: { current: false }, + }); + return null; + } + const root = mountReactHarness(); + cleanup = () => act(() => root.unmount()); + if (!captured.hook) throw new Error("hook did not initialize"); + + act(() => captured.hook?.handleToggleRecording()); + act(() => captured.hook?.handleToggleRecording()); + await act(async () => { + await vi.waitFor(() => expect(gestureRecording.clearSamples).toHaveBeenCalledTimes(1)); + }); + + expect(showToast).toHaveBeenCalledWith( + expect.stringContaining("Gesture commit failed"), + "error", + ); + // A second toggle has to be able to start a new recording: the in-flight + // latch is released in the same clause that clears the samples. + expect(captured.hook?.gestureState).toBe("idle"); }); }); diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index bc87caac39..42392f4e1e 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -3,7 +3,7 @@ * Extracted from App.tsx to keep file sizes under the 600-line limit. */ import { useState, useCallback, useRef, useEffect } from "react"; -import { useGestureRecording } from "./useGestureRecording"; +import { useGestureRecording, type GestureSample } from "./useGestureRecording"; import { simplifyGestureSamples } from "../utils/rdpSimplify"; import { fitEasesFromVelocity } from "../utils/velocityEaseFitter"; import { smoothGestureKeyframes } from "../utils/gestureSmoother"; @@ -82,6 +82,232 @@ function reloadOnlyLast(index: number, count: number): Partial; + liveSession: GestureSessionRef; + selection: DomEditSelection | null; + recStart: number; + showToast: (message: string, tone?: "error" | "info") => void; +} + +/** + * The commit itself, in module scope rather than in the hook that triggers it. + * + * The React Compiler cannot reorder across a `finally`, so a `try`/`finally` + * anywhere inside a hook (including a callback the hook creates) makes it + * decline the whole hook and silently drop every memo in it. Out here the same + * control flow is just a function. `after` IS the `finally` clause: the early + * returns below rely on it running, so it stays inside this function rather + * than after the call. + */ +// fallow-ignore-next-line complexity +async function commitRecordedGesture(run: GestureCommitRun, after: () => void): Promise { + const { liveSession, selection: sel, frozenSamples, coalesceOptions, recStart, showToast } = run; + try { + if (!sel) { + if (frozenSamples.length > 2) { + showToast("Selection lost during recording", "error"); + } + return; + } + const duration = + frozenSamples.length > 0 ? (frozenSamples[frozenSamples.length - 1]?.time ?? 0) : 0; + + if (frozenSamples.length <= 2) { + showToast("No gesture detected — move the pointer while recording", "error"); + return; + } + if (duration <= 0) { + showToast("Recording too short — try again", "error"); + return; + } + + // Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10) + // need a much tighter tolerance than positional properties (x/y in px). + // fallow-ignore-next-line complexity + const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => { + if (key === "opacity") return 0.01; + if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01; + return 5; + }); + const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); + + // Ensure a 0% keyframe exists with the element's start-of-recording position + if (!simplified.has(0) && frozenSamples.length > 0) { + simplified.set(0, frozenSamples[0]!.properties); + if (!sortedPcts.includes(0)) sortedPcts.unshift(0); + } + + // Two different jobs, two different selectors. `selector` is the string an + // ALREADY-AUTHORED tween is matched against (and retargeted with, so a + // tween aimed at a whole group stays aimed at it). `writeSelector` is what + // a NEW tween is authored with: the bare class the id-less case yields here + // would record the gesture onto every sibling sharing it. + const selector = sel.id ? idSelector(sel.id) : sel.selector; + if (!selector) { + showToast("Cannot save — element has no selector", "error"); + return; + } + // A recorded gesture becomes a NEW tween, so its target must address one + // element; the selection's own selector would record the motion onto + // every sibling sharing its class (see writeTargetSelector). + const writeSelector = writeTargetSelector(sel); + if (!writeSelector) { + showToast("Cannot save: element has no unique selector", "error"); + return; + } + if (liveSession.commitMutation) { + const rawKeyframes = sortedPcts.map((pct) => ({ + percentage: pct, + properties: simplified.get(pct) as Record, + })); + const smoothed = smoothGestureKeyframes(rawKeyframes, 3); + const keyframes = fitEasesFromVelocity(smoothed, frozenSamples, duration); + const hasPositionProps = keyframes.some((kf) => + Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), + ); + const allAnims = liveSession.selectedGsapAnimations ?? []; + const existingPositionTween = hasPositionProps + ? allAnims.find( + (a) => + a.propertyGroup === "position" && + tweenTargetsElement(a.targetSelector, selector, sel.element), + ) + : undefined; + if (existingPositionTween) { + if (isInstantHold(existingPositionTween)) { + // An instant hold is not a tween to merge into — replace it with the + // recorded motion (which already starts from the held position). + await liveSession.commitMutation( + { + type: "replace-with-keyframes", + animationId: existingPositionTween.id, + targetSelector: selector, + position: roundTo3(recStart), + duration: roundTo3(duration), + keyframes, + }, + { label: "Gesture recording (replace set)", softReload: true }, + ); + } else { + const tweenStart = existingPositionTween.resolvedStart ?? 0; + const tweenDur = existingPositionTween.duration ?? duration; + const tweenEnd = tweenStart + tweenDur; + const recEnd = recStart + duration; + + // Only merge if the recording overlaps the existing tween's time range. + // No overlap → fall through to add-with-keyframes (creates a separate tween). + const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05; + + if (overlaps) { + const existingKfs = existingPositionTween.keyframes?.keyframes ?? []; + const rangeStartPct = + tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0; + const rangeEndPct = + tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100; + + const preserved = existingKfs + .filter( + (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5, + ) + .map((kf) => ({ + percentage: kf.percentage, + properties: kf.properties, + ...(kf.ease ? { ease: kf.ease } : {}), + })); + + const mapped = keyframes.map((kf) => ({ + percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct), + properties: kf.properties, + ...(kf.ease ? { ease: kf.ease } : {}), + })); + + const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage); + + await liveSession.commitMutation( + { + type: "replace-with-keyframes", + animationId: existingPositionTween.id, + targetSelector: selector, + position: + typeof existingPositionTween.position === "number" + ? existingPositionTween.position + : tweenStart, + duration: tweenDur, + keyframes: merged, + }, + { label: "Gesture recording (merge)", softReload: true }, + ); + } else { + // Emit one tween per property group so a mixed-prop gesture (e.g. + // x/y + opacity) doesn't collapse into an untagged legacy mixed + // tween that the position-only drag intercept can't edit. + const keyframeGroups = partitionKeyframesByGroup(keyframes); + for (const [index, groupKfs] of keyframeGroups.entries()) { + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: writeSelector, + position: roundTo3(recStart), + duration: roundTo3(duration), + keyframes: groupKfs, + // Linear fallback: the velocity fitter assigns a per-keyframe + // ease to non-constant segments and intentionally leaves + // constant-speed segments undefined → they must stay linear, + // not inherit a sigmoid. + easeEach: "none", + }, + { + label: "Gesture recording (new range)", + ...coalesceOptions, + ...reloadOnlyLast(index, keyframeGroups.length), + }, + ); + } + } + } + } else { + // No existing tween — same per-group split as the new-range branch above. + const keyframeGroups = partitionKeyframesByGroup(keyframes); + for (const [index, groupKfs] of keyframeGroups.entries()) { + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: writeSelector, + position: roundTo3(recStart), + duration: roundTo3(duration), + keyframes: groupKfs, + // Linear fallback (see above) — constant-speed segments stay linear. + easeEach: "none", + }, + { + label: "Gesture recording", + ...coalesceOptions, + ...reloadOnlyLast(index, keyframeGroups.length), + }, + ); + } + } + } + showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); + } catch (err) { + console.error("[GR:error]", err); + showToast(`Gesture commit failed: ${err}`, "error"); + } finally { + after(); + } +} + interface UseGestureCommitParams { domEditSessionRef: React.MutableRefObject; previewIframeRef: React.RefObject; @@ -115,7 +341,6 @@ export function useGestureCommit({ // Unmount: clear auto-stop interval useEffect(() => () => clearInterval(recordingAutoStopRef.current), []); - // fallow-ignore-next-line complexity const stopAndCommitRecording = useCallback(async () => { clearInterval(recordingAutoStopRef.current); if (commitInFlightRef.current) { @@ -123,7 +348,7 @@ export function useGestureCommit({ } commitInFlightRef.current = true; const coalesceOptions = { - coalesceKey: `gesture-recording:${++gestureRecordingCommitCounter}`, + coalesceKey: nextGestureCoalesceKey(), coalesceMs: Number.POSITIVE_INFINITY, }; gestureStateRef.current = "idle"; @@ -131,205 +356,22 @@ export function useGestureCommit({ const frozenSamples = gestureRecording.stopRecording(); const store = usePlayerStore.getState(); store.setIsPlaying(false); - try { - const liveSession = domEditSessionRef.current; - const sel = capturedSelectionRef.current; - if (!sel) { - if (frozenSamples.length > 2) { - showToast("Selection lost during recording", "error"); - } - return; - } - const duration = - frozenSamples.length > 0 ? (frozenSamples[frozenSamples.length - 1]?.time ?? 0) : 0; - - if (frozenSamples.length <= 2) { - showToast("No gesture detected — move the pointer while recording", "error"); - return; - } - if (duration <= 0) { - showToast("Recording too short — try again", "error"); - return; - } - - // Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10) - // need a much tighter tolerance than positional properties (x/y in px). - // fallow-ignore-next-line complexity - const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => { - if (key === "opacity") return 0.01; - if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01; - return 5; - }); - const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); - - // Ensure a 0% keyframe exists with the element's start-of-recording position - if (!simplified.has(0) && frozenSamples.length > 0) { - simplified.set(0, frozenSamples[0]!.properties); - if (!sortedPcts.includes(0)) sortedPcts.unshift(0); - } - - // Two different jobs, two different selectors. `selector` is the string an - // ALREADY-AUTHORED tween is matched against (and retargeted with, so a - // tween aimed at a whole group stays aimed at it). `writeSelector` is what - // a NEW tween is authored with: the bare class the id-less case yields here - // would record the gesture onto every sibling sharing it. - const selector = sel.id ? idSelector(sel.id) : sel.selector; - if (!selector) { - showToast("Cannot save — element has no selector", "error"); - return; - } - // A recorded gesture becomes a NEW tween, so its target must address one - // element; the selection's own selector would record the motion onto - // every sibling sharing its class (see writeTargetSelector). - const writeSelector = writeTargetSelector(sel); - if (!writeSelector) { - showToast("Cannot save: element has no unique selector", "error"); - return; - } - if (liveSession.commitMutation) { - const recStart = recordingStartTimeRef.current; - const rawKeyframes = sortedPcts.map((pct) => ({ - percentage: pct, - properties: simplified.get(pct) as Record, - })); - const smoothed = smoothGestureKeyframes(rawKeyframes, 3); - const keyframes = fitEasesFromVelocity(smoothed, frozenSamples, duration); - const hasPositionProps = keyframes.some((kf) => - Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), - ); - const allAnims = liveSession.selectedGsapAnimations ?? []; - const existingPositionTween = hasPositionProps - ? allAnims.find( - (a) => - a.propertyGroup === "position" && - tweenTargetsElement(a.targetSelector, selector, sel.element), - ) - : undefined; - if (existingPositionTween) { - if (isInstantHold(existingPositionTween)) { - // An instant hold is not a tween to merge into — replace it with the - // recorded motion (which already starts from the held position). - await liveSession.commitMutation( - { - type: "replace-with-keyframes", - animationId: existingPositionTween.id, - targetSelector: selector, - position: roundTo3(recStart), - duration: roundTo3(duration), - keyframes, - }, - { label: "Gesture recording (replace set)", softReload: true }, - ); - } else { - const tweenStart = existingPositionTween.resolvedStart ?? 0; - const tweenDur = existingPositionTween.duration ?? duration; - const tweenEnd = tweenStart + tweenDur; - const recEnd = recStart + duration; - - // Only merge if the recording overlaps the existing tween's time range. - // No overlap → fall through to add-with-keyframes (creates a separate tween). - const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05; - - if (overlaps) { - const existingKfs = existingPositionTween.keyframes?.keyframes ?? []; - const rangeStartPct = - tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0; - const rangeEndPct = - tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100; - - const preserved = existingKfs - .filter( - (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5, - ) - .map((kf) => ({ - percentage: kf.percentage, - properties: kf.properties, - ...(kf.ease ? { ease: kf.ease } : {}), - })); - - const mapped = keyframes.map((kf) => ({ - percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct), - properties: kf.properties, - ...(kf.ease ? { ease: kf.ease } : {}), - })); - - const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage); - - await liveSession.commitMutation( - { - type: "replace-with-keyframes", - animationId: existingPositionTween.id, - targetSelector: selector, - position: - typeof existingPositionTween.position === "number" - ? existingPositionTween.position - : tweenStart, - duration: tweenDur, - keyframes: merged, - }, - { label: "Gesture recording (merge)", softReload: true }, - ); - } else { - // Emit one tween per property group so a mixed-prop gesture (e.g. - // x/y + opacity) doesn't collapse into an untagged legacy mixed - // tween that the position-only drag intercept can't edit. - const keyframeGroups = partitionKeyframesByGroup(keyframes); - for (const [index, groupKfs] of keyframeGroups.entries()) { - await liveSession.commitMutation( - { - type: "add-with-keyframes", - targetSelector: writeSelector, - position: roundTo3(recStart), - duration: roundTo3(duration), - keyframes: groupKfs, - // Linear fallback: the velocity fitter assigns a per-keyframe - // ease to non-constant segments and intentionally leaves - // constant-speed segments undefined → they must stay linear, - // not inherit a sigmoid. - easeEach: "none", - }, - { - label: "Gesture recording (new range)", - ...coalesceOptions, - ...reloadOnlyLast(index, keyframeGroups.length), - }, - ); - } - } - } - } else { - // No existing tween — same per-group split as the new-range branch above. - const keyframeGroups = partitionKeyframesByGroup(keyframes); - for (const [index, groupKfs] of keyframeGroups.entries()) { - await liveSession.commitMutation( - { - type: "add-with-keyframes", - targetSelector: writeSelector, - position: roundTo3(recStart), - duration: roundTo3(duration), - keyframes: groupKfs, - // Linear fallback (see above) — constant-speed segments stay linear. - easeEach: "none", - }, - { - label: "Gesture recording", - ...coalesceOptions, - ...reloadOnlyLast(index, keyframeGroups.length), - }, - ); - } - } - } - showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); - } catch (err) { - console.error("[GR:error]", err); - showToast(`Gesture commit failed: ${err}`, "error"); - } finally { - store.requestSeek(recordingStartTimeRef.current); - gestureRecording.clearSamples(); - setGestureState("idle"); - commitInFlightRef.current = false; - } + await commitRecordedGesture( + { + frozenSamples, + coalesceOptions, + liveSession: domEditSessionRef.current, + selection: capturedSelectionRef.current, + recStart: recordingStartTimeRef.current, + showToast, + }, + () => { + store.requestSeek(recordingStartTimeRef.current); + gestureRecording.clearSamples(); + setGestureState("idle"); + commitInFlightRef.current = false; + }, + ); }, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]); // fallow-ignore-next-line complexity diff --git a/packages/studio/src/hooks/useGestureRecording.test.tsx b/packages/studio/src/hooks/useGestureRecording.test.tsx index abcd00e87d..e9588e6a0e 100644 --- a/packages/studio/src/hooks/useGestureRecording.test.tsx +++ b/packages/studio/src/hooks/useGestureRecording.test.tsx @@ -53,6 +53,13 @@ describe("useGestureRecording", () => { nowMs = 30; act(() => animationFrames.shift()?.(30)); + // The live overlay reads through samplesRef while the gesture is still + // running, so the alias has to point at the array the frames append to. + expect(recording!.samplesRef.current).toEqual([ + { time: 0, properties: { x: 10, y: 0 } }, + { time: 1 / 30, properties: { x: 40, y: 0 } }, + ]); + let samples: GestureSample[] = []; act(() => { samples = recording?.stopRecording() ?? []; diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 78c778b24c..143c09528a 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -284,8 +284,13 @@ export function useGestureRecording() { const refs = useRef(createRecordingRefs()); // Stable reference aliases for the return value — consumers read these directly. - const samplesRef = useRef(refs.current.samples); - const trailRef = useRef>(refs.current.trail); + // They start empty rather than seeded from `refs.current`, which is a ref read + // during render and makes the React Compiler decline the whole hook. Nothing + // fills `refs.current.samples` before a recording starts, and startRecording + // and clearSamples both re-point these at the arrays they create, so the alias + // holds from the first sample onward exactly as it did. + const samplesRef = useRef([]); + const trailRef = useRef>([]); // Unmount safety: cancel RAF + remove listeners if component tears down mid-recording. useEffect(() => { diff --git a/packages/studio/src/hooks/useGsapAnimationsForElement.test.tsx b/packages/studio/src/hooks/useGsapAnimationsForElement.test.tsx new file mode 100644 index 0000000000..e383868311 --- /dev/null +++ b/packages/studio/src/hooks/useGsapAnimationsForElement.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { useGsapAnimationsForElement } from "./useGsapTweenCache"; + +Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + +const fetchParsedAnimations = vi.hoisted(() => vi.fn()); +vi.mock("./keyframeCacheAstLoad", async (importOriginal) => ({ + ...(await importOriginal()), + fetchParsedAnimations, +})); + +/** One result per render, so the assertions read a list instead of a mutated binding. */ +const rendered: GsapAnimation[][] = []; + +function Probe({ previewIframe }: { previewIframe: HTMLIFrameElement | null }) { + const { animations } = useGsapAnimationsForElement( + "p1", + "index.html", + { id: "dot-2", selector: "#dot-2" }, + 0, + previewIframe, + ); + rendered.push(animations); + return null; +} + +function latest(): GsapAnimation[] { + return rendered[rendered.length - 1] ?? []; +} + +/** + * A class tween — the `gsap.from(".dot", { stagger })` shape. It matches the + * selected element only through the live DOM, so it is exactly what proves the + * hook resolved the preview document it was handed. + */ +const CLASS_TWEEN = { + id: "dot-from-0", + targetSelector: ".dot", + method: "from", + position: 0, + properties: {}, +} as unknown as GsapAnimation; + +let iframe: HTMLIFrameElement; +let root: ReturnType; + +beforeEach(() => { + rendered.length = 0; + fetchParsedAnimations.mockResolvedValue({ animations: [CLASS_TWEEN] }); + iframe = document.createElement("iframe"); + document.body.append(iframe); + iframe.contentDocument!.body.innerHTML = `
`; + root = createRoot(document.createElement("div")); +}); + +afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + vi.clearAllMocks(); +}); + +describe("useGsapAnimationsForElement", () => { + it("attributes a class tween to the selected element through the preview document", async () => { + await act(async () => { + root.render(); + }); + + expect(latest()).toEqual([CLASS_TWEEN]); + }); + + it("finds nothing without a preview document to match the class against", async () => { + await act(async () => { + root.render(); + }); + + expect(latest()).toEqual([]); + }); + + it("re-resolves when the preview iframe is replaced", async () => { + await act(async () => { + root.render(); + }); + expect(latest()).toEqual([]); + + await act(async () => { + root.render(); + }); + + expect(latest()).toEqual([CLASS_TWEEN]); + }); +}); diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index ea1e2accd3..ae95def9f7 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -41,6 +41,14 @@ import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback" // into one another's undo entry (module-local counter, not Date.now()). let groupDragCommitCounter = 0; +/** + * Module scope, not the hook's: the React Compiler cannot lower `++` on a module + * binding and declines the hook that holds it. + */ +function nextGroupDragCoalesceKey(): string { + return `group-drag:${++groupDragCommitCounter}`; +} + function firstPreflightFailure( results: PromiseSettledResult[], updates: DomEditGroupPathOffsetCommit[], @@ -182,7 +190,7 @@ export function useGsapAwareEditing({ // a single undo entry by forcing a shared coalesceKey (infinite window, so // it survives the N sequential server round-trips) onto each commit — // otherwise each member records its own entry and it takes N presses to undo. - const coalesceKey = `group-drag:${++groupDragCommitCounter}`; + const coalesceKey = nextGroupDragCoalesceKey(); // Members are written one at a time, and a write that re-renders the preview // re-runs the whole script — which still holds the OLD position of every // member not yet written. Those members snap back to where they started and @@ -255,7 +263,10 @@ export function useGsapAwareEditing({ ); throw preflightFailure.error; } - for (const [index, { selection, next }] of updates.entries()) { + // Destructured inside the loop, not in its head: the React Compiler cannot + // lower a pattern in a `for..of` init and declines the whole hook. + for (const [index, update] of updates.entries()) { + const { selection, next } = update; renderOnCommit = index === updates.length - 1; try { const outcome = await tryGsapDragIntercept( diff --git a/packages/studio/src/hooks/useGsapPropertyDebounce.ts b/packages/studio/src/hooks/useGsapPropertyDebounce.ts index 7fd5708ed0..70d60df74a 100644 --- a/packages/studio/src/hooks/useGsapPropertyDebounce.ts +++ b/packages/studio/src/hooks/useGsapPropertyDebounce.ts @@ -72,8 +72,15 @@ export function useGsapPropertyDebounce( // re-render — so a playhead tick mid-slider-drag would flush + record an undo // entry per render. Hold the latest value in a ref instead so every callback // reads current deps without re-subscribing on identity churn. + // + // The write is in an effect, not in the body: a ref write during render is a + // render side effect and the React Compiler declines the whole hook when it + // sees one. Every reader below is a debounce timer or an unmount flush, and + // neither can run before the render that produced `sdk` has committed. const sdkRef = useRef(sdk); - sdkRef.current = sdk; + useEffect(() => { + sdkRef.current = sdk; + }); // fallow-ignore-next-line complexity const flushPendingPropertyEdit = useCallback(async () => { diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 8a902ce97a..5130724832 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; @@ -316,7 +316,11 @@ function instantPatchesFor( export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) { const activeProjectId = projectIdRef.current; const activeCompPathRef = useRef(activeCompPath); - activeCompPathRef.current = activeCompPath; + // 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 + // read is inside an async commit callback, which cannot run before the render + // that changed the composition has committed. + useEffect(() => { activeCompPathRef.current = activeCompPath; }); // Serializer for per-key commits (options.serializeKey). Keyed by // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for // the same animationId so their POSTs can't interleave. Held in a ref so the diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx b/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx index 6317c2b249..1b9dee3bda 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx @@ -182,3 +182,32 @@ describe("useGsapSelectionHandlers retime settlement", () => { withSelection.unmount(); }); }); + +describe("useGsapSelectionHandlers selection fallback", () => { + it("still targets the last selected element after the selection clears", async () => { + const updateGsapMeta = vi.fn().mockResolvedValue(undefined); + const selection = makeSelection(); + /** One handle per render, so this reads two renders instead of a mutated binding. */ + const renders: Handlers[] = []; + function Probe({ params }: { params: Params }) { + renders.push(useGsapSelectionHandlers(params)); + return null; + } + const root = createRoot(document.createElement("div")); + + act(() => + root.render(), + ); + act(() => + root.render(), + ); + + // No override argument means "use whatever is selected"; with nothing + // selected the retime still has to land on the element it was opened for, + // or an inspector edit made after a click-away writes nowhere. + await renders[renders.length - 1]!.handleGsapUpdateMeta("anim-1", { duration: 2 }); + + expect(updateGsapMeta).toHaveBeenCalledWith(selection, "anim-1", { duration: 2 }); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts index 6479061cd5..b346002ba6 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditing"; import { usePlayerStore } from "../player"; @@ -115,8 +115,14 @@ export function useGsapSelectionHandlers({ selectedGsapAnimations: GsapAnimation[]; showToast: (message: string, tone?: "error" | "info") => void; }) { + // 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. + // The two readers are both edit handlers, which cannot run before the render + // that changed the selection has committed. const lastSelectionRef = useRef(null); - if (domEditSelection) lastSelectionRef.current = domEditSelection; + useEffect(() => { + if (domEditSelection) lastSelectionRef.current = domEditSelection; + }); // `undefined` means the caller passed no override and accepts the current // selection. An explicit `null` means the caller RESOLVED a selection for the diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 9b3a8aa22f..b42bc0ad8f 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -75,7 +75,12 @@ export function useGsapAnimationsForElement( sourceFile: string, target: GsapElementTarget | null, version: number, - iframeRef?: React.RefObject, + // The element, not a ref to it. A ref read during render is a render side + // effect: the React Compiler declines any hook that does it, and the two memos + // below read the live preview DOM through this. The caller already holds the + // same element as state, and passing it makes it a real dependency, so a + // replaced iframe re-resolves instead of waiting for the next `version` bump. + previewIframe?: HTMLIFrameElement | null, ): { animations: GsapAnimation[]; multipleTimelines: boolean; @@ -151,14 +156,22 @@ export function useGsapAnimationsForElement( const targetId = target?.id ?? null; const targetSelector = target?.selector ?? null; + // The preview document, tagged with the composition generation that produced + // it. A soft reload swaps the document inside the SAME iframe element, so the + // element alone cannot say the DOM changed; `version` can. Carrying it in the + // value makes it a real input to the resolution below instead of an extra name + // on that dependency list, which is what needed suppressing before. + const previewDocument = useMemo( + () => ({ generation: version, doc: previewIframe?.contentDocument ?? null }), + [previewIframe, version], + ); const rawAnimations = useMemo(() => { if (!targetId && !targetSelector) return []; // Resolve the live element so class / descendant tweens (e.g. // gsap.from(".dot", {stagger})) attribute to every matching element, not - // just the one whose exact selector equals the tween's. `version` re-runs - // this after composition reloads. + // just the one whose exact selector equals the tween's. let element: Element | null = null; - const doc = iframeRef?.current?.contentDocument; + const doc = previewDocument.doc; if (doc) { try { element = @@ -173,12 +186,11 @@ export function useGsapAnimationsForElement( { id: targetId, selector: targetSelector }, element, ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allAnimations, targetId, targetSelector, version, iframeRef]); + }, [allAnimations, targetId, targetSelector, previewDocument]); // fallow-ignore-next-line complexity const animations = useMemo(() => { - const iframe = iframeRef?.current; + const iframe = previewIframe ?? null; let result = rawAnimations; // Enrich animations with unresolved keyframes from runtime @@ -237,7 +249,7 @@ export function useGsapAnimationsForElement( } return result; - }, [rawAnimations, allAnimations, iframeRef, targetId]); + }, [rawAnimations, allAnimations, previewIframe, targetId]); // Populate keyframe cache for the selected element. // Key format must match timeline element keys: "sourceFile#domId". @@ -327,7 +339,10 @@ export function useGsapAnimationsForElement( draft.keyframeCache.set(key, merged); } }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // `domClipChildrenKey` is a trigger, not a value this body reads: the store + // is read imperatively above, and the key is what says the sub-comp children + // changed. It was suppressed to stand; a suppression is a bail-out the React + // Compiler counts, and it cost this hook every memo in it. }, [elementId, sourceFile, animations, domClipChildrenKey]); return { animations, multipleTimelines, unsupportedTimelinePattern }; @@ -345,11 +360,33 @@ export function useGsapCacheVersion() { * requiring a selection. */ +/** + * Run `attempt` now, then every `everyMs` until it reports done or `maxTries` + * are spent. Returns the stop function an effect can hand back as its cleanup. + * + * Module scope, not the effect body: the retry counter has to be incremented + * from inside the interval callback, and the React Compiler cannot lower `++` on + * a variable a lambda captures. It declines the whole hook when it finds one. + */ +function pollUntilDone(attempt: () => boolean, everyMs: number, maxTries: number): () => void { + if (attempt()) return () => {}; + let tries = 0; + const interval = setInterval(() => { + tries++; + if (attempt() || tries >= maxTries) clearInterval(interval); + }, everyMs); + return () => clearInterval(interval); +} + export function usePopulateKeyframeCacheForFile( projectId: string | null, sourceFile: string, version: number, - iframeRef?: React.RefObject, + // The element, not a ref to it: a parameter ref's `.current` can never be a + // true dependency, and the omission had to be suppressed to stand. A + // suppression is a bail-out the React Compiler counts, and it cost this hook + // every memo in it. + previewIframe?: HTMLIFrameElement | null, ): void { const elementCount = usePlayerStore((s) => s.elements.length); // Every sub-composition file the timeline shows rows for. The cache is loaded @@ -387,7 +424,7 @@ export function usePopulateKeyframeCacheForFile( const files = Array.from( new Set([sourceFile, ...(compositionSrcKey ? compositionSrcKey.split("|") : [])]), ); - const doc = iframeRef?.current?.contentDocument; + const doc = previewIframe?.contentDocument; // Everything the previous scan cached for a file this one no longer covers // (the composition just switched away from) has no owner left to clear it. pruneKeyframeCacheToFiles(files); @@ -396,11 +433,18 @@ export function usePopulateKeyframeCacheForFile( }); // elementCount is in the deps because new timeline elements (e.g. after a // sub-composition expand) need their keyframe cache populated immediately; - // without it the effect won't re-run when elements appear/disappear. - // iframeRef is read for DOM selector resolution but intentionally not a dep - // (it's a stable ref; the separate runtime-scan effect owns iframe timing). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, sourceFile, version, elementCount, domClipChildrenKey, compositionSrcKey]); + // without it the effect won't re-run when elements appear/disappear. A + // replaced iframe re-enters here and leaves on the fetch-key guard above, + // which is what it did before it was a dependency at all. + }, [ + projectId, + sourceFile, + version, + elementCount, + domClipChildrenKey, + compositionSrcKey, + previewIframe, + ]); // Separate effect for runtime keyframe discovery — polls until the iframe // has loaded GSAP timelines, independent of the AST fetch lifecycle. @@ -408,14 +452,11 @@ export function usePopulateKeyframeCacheForFile( if (!projectId) return; const sf = sourceFile; - let attempts = 0; - const maxAttempts = 10; - // fallow-ignore-next-line complexity const tryRuntimeScan = () => { if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true; const iframe = - iframeRef?.current ?? document.querySelector("iframe[src*='/preview/']"); + previewIframe ?? document.querySelector("iframe[src*='/preview/']"); if (!iframe) return false; // Clip dims per element so the scan converts tween-relative keyframes to // clip-relative (matching the static path) instead of timeline-relative. @@ -451,13 +492,6 @@ export function usePopulateKeyframeCacheForFile( return true; }; - if (tryRuntimeScan()) return; - - const interval = setInterval(() => { - attempts++; - if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval); - }, 500); - - return () => clearInterval(interval); - }, [projectId, sourceFile, version, iframeRef]); + return pollUntilDone(tryRuntimeScan, 500, 10); + }, [projectId, sourceFile, version, previewIframe]); } diff --git a/packages/studio/src/styles/compiler-bailouts.json b/packages/studio/src/styles/compiler-bailouts.json index 89f96bcaf1..a87b94c7df 100644 --- a/packages/studio/src/styles/compiler-bailouts.json +++ b/packages/studio/src/styles/compiler-bailouts.json @@ -1,5 +1,5 @@ { - "total": 115, + "total": 104, "files": { "src/App.tsx": 1, "src/captions/components/CaptionOverlay.tsx": 1, @@ -65,17 +65,6 @@ "src/contexts/DomEditContext.tsx": 1, "src/contexts/TimelineEditContext.tsx": 1, "src/contexts/VariablePromoteContext.tsx": 1, - "src/hooks/useElementPicker.ts": 1, - "src/hooks/useExternalFileChangeCoordinator.ts": 1, - "src/hooks/useFileManager.ts": 1, - "src/hooks/useFrameCapture.ts": 1, - "src/hooks/useGestureCommit.ts": 1, - "src/hooks/useGestureRecording.ts": 1, - "src/hooks/useGsapAwareEditing.ts": 1, - "src/hooks/useGsapPropertyDebounce.ts": 1, - "src/hooks/useGsapScriptCommits.ts": 1, - "src/hooks/useGsapSelectionHandlers.ts": 1, - "src/hooks/useGsapTweenCache.ts": 1, "src/hooks/useLintModal.ts": 1, "src/hooks/useLivePlayheadTime.ts": 1, "src/hooks/useMountEffect.ts": 1,