Skip to content

Commit ae71733

Browse files
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).
1 parent d32c8dc commit ae71733

6 files changed

Lines changed: 235 additions & 2 deletions

File tree

‎packages/core/src/studio-api/routes/files.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
713713

714714
// Re-parse the mutated script so the UI gets fresh state
715715
const freshParsed = parseGsapScript(newScript);
716-
return c.json({ ok: true, parsed: freshParsed, before: html, after: newHtml });
716+
return c.json({
717+
ok: true,
718+
parsed: freshParsed,
719+
before: html,
720+
after: newHtml,
721+
scriptText: newScript,
722+
});
717723
});
718724
}

‎packages/shader-transitions/src/hyper-shader.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,32 @@ function getDocumentStyleSignature(doc: Document): string {
245245
return stableHash(`${styleText}\n${linkedStyles}`);
246246
}
247247

248+
// fallow-ignore-next-line complexity
249+
function isGsapAnimationOnlyScript(text: string): boolean {
250+
const hasGsap =
251+
text.includes("gsap.timeline") ||
252+
text.includes("__timelines") ||
253+
text.includes(".to(") ||
254+
text.includes(".set(");
255+
if (!hasGsap) return false;
256+
return (
257+
!text.includes("HyperShader") && !text.includes("hyper-shader") && !text.includes("hyperShader")
258+
);
259+
}
260+
248261
function getDocumentScriptSignature(doc: Document): string {
249262
const projectSignature = Array.from(
250263
doc.querySelectorAll<HTMLMetaElement>('meta[name="hyperframes-project-signature"]'),
251264
)
252265
.map((meta) => meta.getAttribute("content") || "")
253266
.join("\n");
254267
const scriptText = Array.from(doc.querySelectorAll<HTMLScriptElement>("script"))
268+
.filter((script) => {
269+
if (script.src) return true;
270+
const text = script.textContent || "";
271+
if (!text.trim()) return false;
272+
return !isGsapAnimationOnlyScript(text);
273+
})
255274
.map((script) => {
256275
const attrs = [
257276
script.type,
@@ -2192,6 +2211,10 @@ export function init(config: HyperShaderConfig): GsapTimeline {
21922211
hfWin.__hf = hfWin.__hf || {};
21932212
hfWin.__hf.shaderTransitionsReady = prewarmPromise;
21942213

2214+
(
2215+
window as Window & { __hfSuppressSceneMutations?: <T>(fn: () => T) => T }
2216+
).__hfSuppressSceneMutations = <T>(fn: () => T): T => suppressSceneMutationTracking(fn);
2217+
21952218
registerTimeline(compId, tl, config.timeline);
21962219
return tl;
21972220
}

‎packages/studio/src/hooks/useDomEditSession.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export function useDomEditSession({
224224
} = useGsapScriptCommits({
225225
projectIdRef,
226226
activeCompPath,
227+
previewIframeRef,
227228
editHistory,
228229
domEditSaveTimestampRef,
229230
reloadPreview,

‎packages/studio/src/hooks/useGsapScriptCommits.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
22
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
33
import type { DomEditSelection } from "../components/editor/domEditingTypes";
44
import type { EditHistoryKind } from "../utils/editHistory";
5+
import { applySoftReload } from "../utils/gsapSoftReload";
56

67
const PROPERTY_DEFAULTS: Record<string, number> = {
78
opacity: 1,
@@ -45,6 +46,7 @@ interface MutationResult {
4546
parsed?: ParsedGsap;
4647
before?: string;
4748
after?: string;
49+
scriptText?: string;
4850
}
4951

5052
async function mutateGsapScript(
@@ -71,6 +73,7 @@ async function mutateGsapScript(
7173
interface GsapScriptCommitsParams {
7274
projectIdRef: React.MutableRefObject<string | null>;
7375
activeCompPath: string | null;
76+
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
7477
editHistory: {
7578
recordEdit: (entry: {
7679
label: string;
@@ -90,6 +93,7 @@ const DEBOUNCE_MS = 150;
9093
export function useGsapScriptCommits({
9194
projectIdRef,
9295
activeCompPath,
96+
previewIframeRef,
9397
editHistory,
9498
domEditSaveTimestampRef,
9599
reloadPreview,
@@ -131,13 +135,18 @@ export function useGsapScriptCommits({
131135

132136
onCacheInvalidate();
133137

134-
if (!options.softReload) {
138+
if (options.softReload && result.scriptText) {
139+
if (!applySoftReload(previewIframeRef.current, result.scriptText)) {
140+
reloadPreview();
141+
}
142+
} else {
135143
reloadPreview();
136144
}
137145
},
138146
[
139147
projectIdRef,
140148
activeCompPath,
149+
previewIframeRef,
141150
editHistory,
142151
domEditSaveTimestampRef,
143152
reloadPreview,
@@ -156,6 +165,7 @@ export function useGsapScriptCommits({
156165
{
157166
label: `Edit GSAP ${property}`,
158167
coalesceKey: `gsap:${animationId}:${property}`,
168+
softReload: true,
159169
},
160170
);
161171
}, [commitMutation]);
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// @vitest-environment happy-dom
2+
3+
import { describe, it, expect, vi } from "vitest";
4+
import { applySoftReload } from "./gsapSoftReload";
5+
6+
const SCRIPT_TEXT = `
7+
window.__timelines = window.__timelines || {};
8+
const tl = gsap.timeline({ paused: true });
9+
tl.to("#box", { opacity: 0.8 });
10+
window.__timelines["root"] = tl;
11+
`;
12+
13+
function buildMockIframe(overrides: Record<string, unknown> = {}) {
14+
const scriptEl = document.createElement("script");
15+
scriptEl.textContent =
16+
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
17+
const container = document.createElement("div");
18+
container.appendChild(scriptEl);
19+
20+
const mockTimeline = { kill: vi.fn(), pause: vi.fn() };
21+
const contentWindow = {
22+
gsap: { timeline: vi.fn() },
23+
__hfForceTimelineRebind: vi.fn(),
24+
__timelines: { root: mockTimeline } as Record<string, typeof mockTimeline>,
25+
__player: { getTime: () => 2.0, seek: vi.fn() },
26+
__hfStudioManualEditsApply: vi.fn(),
27+
__hfSuppressSceneMutations: undefined as undefined | (<T>(fn: () => T) => T),
28+
...overrides,
29+
};
30+
31+
const contentDocument = {
32+
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
33+
createElement: (tag: string) => document.createElement(tag),
34+
body: container,
35+
};
36+
37+
return {
38+
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
39+
contentWindow,
40+
mockTimeline,
41+
};
42+
}
43+
44+
describe("applySoftReload", () => {
45+
it("returns false when iframe is null", () => {
46+
expect(applySoftReload(null, SCRIPT_TEXT)).toBe(false);
47+
});
48+
49+
it("returns false when scriptText is empty", () => {
50+
const { iframe } = buildMockIframe();
51+
expect(applySoftReload(iframe, "")).toBe(false);
52+
});
53+
54+
it("returns false when gsap is not on iframe window", () => {
55+
const { iframe } = buildMockIframe({ gsap: undefined });
56+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
57+
});
58+
59+
it("returns false when __hfForceTimelineRebind is missing", () => {
60+
const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
61+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
62+
});
63+
64+
it("kills existing timelines, rebinds, and re-seeks on success", () => {
65+
const { iframe, contentWindow, mockTimeline } = buildMockIframe();
66+
const result = applySoftReload(iframe, SCRIPT_TEXT);
67+
expect(result).toBe(true);
68+
expect(mockTimeline.kill).toHaveBeenCalled();
69+
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
70+
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
71+
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
72+
});
73+
74+
it("wraps execution in __hfSuppressSceneMutations when available", () => {
75+
let suppressionCalled = false;
76+
const { iframe } = buildMockIframe({
77+
__hfSuppressSceneMutations: <T>(fn: () => T): T => {
78+
suppressionCalled = true;
79+
return fn();
80+
},
81+
});
82+
const result = applySoftReload(iframe, SCRIPT_TEXT);
83+
expect(result).toBe(true);
84+
expect(suppressionCalled).toBe(true);
85+
});
86+
87+
it("returns false when multiple GSAP scripts exist (ambiguous)", () => {
88+
const script1 = document.createElement("script");
89+
script1.textContent = "const tl = gsap.timeline({ paused: true });";
90+
const script2 = document.createElement("script");
91+
script2.textContent = 'tl.to("#other", { x: 10 });';
92+
const container = document.createElement("div");
93+
container.appendChild(script1);
94+
container.appendChild(script2);
95+
96+
const { iframe } = buildMockIframe();
97+
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
98+
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [script1, script2] : []),
99+
createElement: (tag: string) => document.createElement(tag),
100+
body: container,
101+
};
102+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
103+
});
104+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
type IframeWindow = Window & {
2+
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
3+
__player?: { getTime?: () => number; seek?: (t: number) => void };
4+
__hfForceTimelineRebind?: () => void;
5+
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
6+
__hfStudioManualEditsApply?: () => void;
7+
gsap?: { timeline?: (...args: unknown[]) => unknown };
8+
};
9+
10+
function isGsapScript(text: string): boolean {
11+
return (
12+
text.includes("gsap.timeline") ||
13+
text.includes("__timelines") ||
14+
text.includes(".to(") ||
15+
text.includes(".set(")
16+
);
17+
}
18+
19+
function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
20+
const results: HTMLScriptElement[] = [];
21+
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
22+
for (const script of scripts) {
23+
if (isGsapScript(script.textContent || "")) results.push(script);
24+
}
25+
return results;
26+
}
27+
28+
/**
29+
* Replace the GSAP script in the live iframe without reloading. This preserves
30+
* the WebGL context and shader transition cache.
31+
*
32+
* Scoped to root-document GSAP scripts only — scripts inside `<template>`
33+
* elements (sub-compositions) are not visible to `querySelectorAll` and will
34+
* fall back to a full iframe reload.
35+
*
36+
* Returns false (triggering a full reload fallback) when:
37+
* - The iframe or GSAP runtime isn't available
38+
* - Multiple GSAP scripts are found (ambiguous which to replace)
39+
* - No matching GSAP script element exists in the live DOM
40+
*/
41+
export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: string): boolean {
42+
if (!iframe || !scriptText) return false;
43+
44+
const win = iframe.contentWindow as IframeWindow | null;
45+
const doc = iframe.contentDocument;
46+
if (!win || !doc) return false;
47+
if (!win.gsap || !win.__hfForceTimelineRebind) return false;
48+
49+
const gsapScripts = findGsapScriptElements(doc);
50+
if (gsapScripts.length !== 1) return false;
51+
const oldScriptEl = gsapScripts[0]!;
52+
53+
const currentTime = win.__player?.getTime?.() ?? 0;
54+
55+
const doReload = () => {
56+
const timelines = win.__timelines;
57+
if (timelines) {
58+
for (const key of Object.keys(timelines)) {
59+
try {
60+
timelines[key]?.kill?.();
61+
} catch {}
62+
delete timelines[key];
63+
}
64+
}
65+
66+
oldScriptEl.remove();
67+
const newScript = doc.createElement("script");
68+
// IIFE prevents const/let redeclaration errors across consecutive edits.
69+
// Top-level declarations are scoped to the IIFE; window.* assignments
70+
// (e.g. window.__timelines["root"] = tl) still reach the global scope.
71+
newScript.textContent = `(function(){${scriptText}\n})();`;
72+
doc.body.appendChild(newScript);
73+
74+
win.__hfForceTimelineRebind?.();
75+
win.__player?.seek?.(currentTime);
76+
win.__hfStudioManualEditsApply?.();
77+
};
78+
79+
try {
80+
if (win.__hfSuppressSceneMutations) {
81+
win.__hfSuppressSceneMutations(doReload);
82+
} else {
83+
doReload();
84+
}
85+
return true;
86+
} catch {
87+
return false;
88+
}
89+
}

0 commit comments

Comments
 (0)