mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(studio): soft-reload GSAP property edits, preserve shader cache (#1129)
* fix(studio): soft-reload GSAP property edits without iframe reload GSAP property value edits (opacity, x, scale, etc.) now update the live timeline inside the preview iframe without triggering a full iframe reload. This preserves the WebGL context and shader transition cache, eliminating the loading overlay that appeared on every property edit. Implementation: - New gsapSoftReload.ts: kills the old GSAP timeline, re-executes the updated script, calls __hfForceTimelineRebind(), and re-seeks to the current time. Falls back to full reload on failure. - useGsapScriptCommits: passes softReload: true for property value edits via the existing (previously unused) softReload flag on commitMutation. - hyper-shader.ts: exposes __hfSuppressSceneMutations on the window so the soft-reload can suppress the MutationObserver during re-execution. - hyper-shader.ts: getDocumentScriptSignature now excludes pure GSAP animation scripts from the cache key hash, so full reloads (undo, external changes) don't invalidate transition caches when only animation values changed. * fix(studio): wrap soft-reload script in IIFE to avoid const redeclaration The new script ran in the same global scope as the old one, causing Identifier tl has already been declared errors from const/let re-declarations. Wrapping in an IIFE creates a new lexical scope. Also remove the old script element before inserting the new one. * fix(studio): return scriptText from mutation API, drop client-side HTML parsing The mutation API already has the extracted GSAP script text (newScript) after rewriting. Return it as scriptText in the response so applySoftReload receives the script directly instead of parsing HTML client-side. This avoids DOMParser compatibility issues across test environments and is more reliable than regex-based extraction. * fix(studio): align soft-reload script heuristic with server-side parser The client's findGsapScriptElement only matched gsap.timeline and __timelines. The server's extractGsapScriptBlock also matches .to( and .set(. Aligned the client heuristic to prevent silent fallback to full reload for compositions that use tl.to() without gsap.timeline in the same script. * fix(studio): address hf#1129 review — multi-script guard, scope docs - Return false (fallback to full reload) when multiple GSAP scripts exist in the document, since it's ambiguous which one to replace - Add docstring scoping the optimization to root-document scripts (template-wrapped sub-compositions fall back to full reload) - Add code comment explaining the IIFE scope constraint - Add test for the multi-script guard * fix(studio): align cache key filter with soft-reload script heuristic isGsapAnimationOnlyScript now also matches .to( and .set( patterns, matching findGsapScriptElement. Scripts using only tl.to() without gsap.timeline were excluded from soft-reload but still busted the shader cache on full-reload paths (undo, external changes).
This commit is contained in:
@@ -224,6 +224,7 @@ export function useDomEditSession({
|
||||
} = useGsapScriptCommits({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
editHistory,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { applySoftReload } from "../utils/gsapSoftReload";
|
||||
|
||||
const PROPERTY_DEFAULTS: Record<string, number> = {
|
||||
opacity: 1,
|
||||
@@ -45,6 +46,7 @@ interface MutationResult {
|
||||
parsed?: ParsedGsap;
|
||||
before?: string;
|
||||
after?: string;
|
||||
scriptText?: string;
|
||||
}
|
||||
|
||||
async function mutateGsapScript(
|
||||
@@ -71,6 +73,7 @@ async function mutateGsapScript(
|
||||
interface GsapScriptCommitsParams {
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
activeCompPath: string | null;
|
||||
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
editHistory: {
|
||||
recordEdit: (entry: {
|
||||
label: string;
|
||||
@@ -90,6 +93,7 @@ const DEBOUNCE_MS = 150;
|
||||
export function useGsapScriptCommits({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
editHistory,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
@@ -131,13 +135,18 @@ export function useGsapScriptCommits({
|
||||
|
||||
onCacheInvalidate();
|
||||
|
||||
if (!options.softReload) {
|
||||
if (options.softReload && result.scriptText) {
|
||||
if (!applySoftReload(previewIframeRef.current, result.scriptText)) {
|
||||
reloadPreview();
|
||||
}
|
||||
} else {
|
||||
reloadPreview();
|
||||
}
|
||||
},
|
||||
[
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
editHistory,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
@@ -156,6 +165,7 @@ export function useGsapScriptCommits({
|
||||
{
|
||||
label: `Edit GSAP ${property}`,
|
||||
coalesceKey: `gsap:${animationId}:${property}`,
|
||||
softReload: true,
|
||||
},
|
||||
);
|
||||
}, [commitMutation]);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { applySoftReload } from "./gsapSoftReload";
|
||||
|
||||
const SCRIPT_TEXT = `
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { opacity: 0.8 });
|
||||
window.__timelines["root"] = tl;
|
||||
`;
|
||||
|
||||
function buildMockIframe(overrides: Record<string, unknown> = {}) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.textContent =
|
||||
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(scriptEl);
|
||||
|
||||
const mockTimeline = { kill: vi.fn(), pause: vi.fn() };
|
||||
const contentWindow = {
|
||||
gsap: { timeline: vi.fn() },
|
||||
__hfForceTimelineRebind: vi.fn(),
|
||||
__timelines: { root: mockTimeline } as Record<string, typeof mockTimeline>,
|
||||
__player: { getTime: () => 2.0, seek: vi.fn() },
|
||||
__hfStudioManualEditsApply: vi.fn(),
|
||||
__hfSuppressSceneMutations: undefined as undefined | (<T>(fn: () => T) => T),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const contentDocument = {
|
||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
};
|
||||
|
||||
return {
|
||||
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
|
||||
contentWindow,
|
||||
mockTimeline,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applySoftReload", () => {
|
||||
it("returns false when iframe is null", () => {
|
||||
expect(applySoftReload(null, SCRIPT_TEXT)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when scriptText is empty", () => {
|
||||
const { iframe } = buildMockIframe();
|
||||
expect(applySoftReload(iframe, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when gsap is not on iframe window", () => {
|
||||
const { iframe } = buildMockIframe({ gsap: undefined });
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when __hfForceTimelineRebind is missing", () => {
|
||||
const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
|
||||
});
|
||||
|
||||
it("kills existing timelines, rebinds, and re-seeks on success", () => {
|
||||
const { iframe, contentWindow, mockTimeline } = buildMockIframe();
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT);
|
||||
expect(result).toBe(true);
|
||||
expect(mockTimeline.kill).toHaveBeenCalled();
|
||||
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
|
||||
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
|
||||
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wraps execution in __hfSuppressSceneMutations when available", () => {
|
||||
let suppressionCalled = false;
|
||||
const { iframe } = buildMockIframe({
|
||||
__hfSuppressSceneMutations: <T>(fn: () => T): T => {
|
||||
suppressionCalled = true;
|
||||
return fn();
|
||||
},
|
||||
});
|
||||
const result = applySoftReload(iframe, SCRIPT_TEXT);
|
||||
expect(result).toBe(true);
|
||||
expect(suppressionCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when multiple GSAP scripts exist (ambiguous)", () => {
|
||||
const script1 = document.createElement("script");
|
||||
script1.textContent = "const tl = gsap.timeline({ paused: true });";
|
||||
const script2 = document.createElement("script");
|
||||
script2.textContent = 'tl.to("#other", { x: 10 });';
|
||||
const container = document.createElement("div");
|
||||
container.appendChild(script1);
|
||||
container.appendChild(script2);
|
||||
|
||||
const { iframe } = buildMockIframe();
|
||||
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
||||
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [script1, script2] : []),
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
body: container,
|
||||
};
|
||||
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
type IframeWindow = Window & {
|
||||
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
|
||||
__player?: { getTime?: () => number; seek?: (t: number) => void };
|
||||
__hfForceTimelineRebind?: () => void;
|
||||
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
|
||||
__hfStudioManualEditsApply?: () => void;
|
||||
gsap?: { timeline?: (...args: unknown[]) => unknown };
|
||||
};
|
||||
|
||||
function isGsapScript(text: string): boolean {
|
||||
return (
|
||||
text.includes("gsap.timeline") ||
|
||||
text.includes("__timelines") ||
|
||||
text.includes(".to(") ||
|
||||
text.includes(".set(")
|
||||
);
|
||||
}
|
||||
|
||||
function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
||||
const results: HTMLScriptElement[] = [];
|
||||
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
|
||||
for (const script of scripts) {
|
||||
if (isGsapScript(script.textContent || "")) results.push(script);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the GSAP script in the live iframe without reloading. This preserves
|
||||
* the WebGL context and shader transition cache.
|
||||
*
|
||||
* Scoped to root-document GSAP scripts only — scripts inside `<template>`
|
||||
* elements (sub-compositions) are not visible to `querySelectorAll` and will
|
||||
* fall back to a full iframe reload.
|
||||
*
|
||||
* Returns false (triggering a full reload fallback) when:
|
||||
* - The iframe or GSAP runtime isn't available
|
||||
* - Multiple GSAP scripts are found (ambiguous which to replace)
|
||||
* - No matching GSAP script element exists in the live DOM
|
||||
*/
|
||||
export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: string): boolean {
|
||||
if (!iframe || !scriptText) return false;
|
||||
|
||||
const win = iframe.contentWindow as IframeWindow | null;
|
||||
const doc = iframe.contentDocument;
|
||||
if (!win || !doc) return false;
|
||||
if (!win.gsap || !win.__hfForceTimelineRebind) return false;
|
||||
|
||||
const gsapScripts = findGsapScriptElements(doc);
|
||||
if (gsapScripts.length !== 1) return false;
|
||||
const oldScriptEl = gsapScripts[0]!;
|
||||
|
||||
const currentTime = win.__player?.getTime?.() ?? 0;
|
||||
|
||||
const doReload = () => {
|
||||
const timelines = win.__timelines;
|
||||
if (timelines) {
|
||||
for (const key of Object.keys(timelines)) {
|
||||
try {
|
||||
timelines[key]?.kill?.();
|
||||
} catch {}
|
||||
delete timelines[key];
|
||||
}
|
||||
}
|
||||
|
||||
oldScriptEl.remove();
|
||||
const newScript = doc.createElement("script");
|
||||
// IIFE prevents const/let redeclaration errors across consecutive edits.
|
||||
// Top-level declarations are scoped to the IIFE; window.* assignments
|
||||
// (e.g. window.__timelines["root"] = tl) still reach the global scope.
|
||||
newScript.textContent = `(function(){${scriptText}\n})();`;
|
||||
doc.body.appendChild(newScript);
|
||||
|
||||
win.__hfForceTimelineRebind?.();
|
||||
win.__player?.seek?.(currentTime);
|
||||
win.__hfStudioManualEditsApply?.();
|
||||
};
|
||||
|
||||
try {
|
||||
if (win.__hfSuppressSceneMutations) {
|
||||
win.__hfSuppressSceneMutations(doReload);
|
||||
} else {
|
||||
doReload();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user