Skip to content

Commit a67ae18

Browse files
committed
fix(studio): a soft reload clears exactly the set props a live patch applied
1 parent c390fee commit a67ae18

7 files changed

Lines changed: 94 additions & 43 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type RuntimeTween,
2020
type RuntimeTimeline,
2121
} from "./gsapRuntimeKeyframes";
22+
import { recordLiveSet } from "../utils/softReloadTargets";
2223

2324
/** Value-only channels a `tl.set(...)` patch may touch. */
2425
export interface SetPatchProps {
@@ -101,6 +102,7 @@ function applyGlobalSet(
101102
const numeric = finiteNumericProps(props);
102103
if (!gsapLib?.set || !el || !numeric) return false;
103104
gsapLib.set(el, numeric);
105+
recordLiveSet(el, numeric);
104106
return true;
105107
} catch {
106108
return false;

‎packages/studio/src/hooks/useGsapScriptCommits.test.tsx‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// fallow-ignore-file code-duplication
21
// @vitest-environment happy-dom
32
import { act } from "react";
43
import { createRoot } from "react-dom/client";
@@ -12,7 +11,6 @@ const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
1211
const applySoftReload = vi.fn<(...args: unknown[]) => string>();
1312
const trackStudioEvent = vi.fn();
1413
const readNestedFiles = vi.fn<(...args: unknown[]) => unknown>(() => null);
15-
const noteScriptWritten = vi.fn();
1614

1715
vi.mock("./gsapRuntimePatch", () => ({
1816
patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
@@ -22,7 +20,6 @@ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
2220
applySoftReload: (...args: unknown[]) => applySoftReload(...args),
2321
extractGsapScriptText: () => "",
2422
readNestedFiles: (...args: unknown[]) => readNestedFiles(...args),
25-
noteScriptWritten: (...args: unknown[]) => noteScriptWritten(...args),
2623
}));
2724
vi.mock("../utils/studioTelemetry", () => ({
2825
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
@@ -81,7 +78,6 @@ describe("applyPreviewSync", () => {
8178
beforeEach(() => {
8279
patchRuntimeTweenInPlace.mockReset();
8380
applySoftReload.mockReset();
84-
noteScriptWritten.mockReset();
8581
trackStudioEvent.mockReset();
8682
});
8783

@@ -695,7 +691,6 @@ describe("runCommit — instantPatch wiring", () => {
695691
beforeEach(() => {
696692
patchRuntimeTweenInPlace.mockReset();
697693
applySoftReload.mockReset();
698-
noteScriptWritten.mockReset();
699694
trackStudioEvent.mockReset();
700695
readNestedFiles.mockReset().mockReturnValue(null);
701696
});
@@ -748,19 +743,6 @@ describe("runCommit — instantPatch wiring", () => {
748743
expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]);
749744
});
750745

751-
it("notes the script a commit wrote though it skips the reload, so the next one clears its sets", async () => {
752-
const written = 'gsap.set("#a", { width: 300 });';
753-
mockFetchResult({ scriptText: written });
754-
const deps = renderCommitHook();
755-
await deps.api.commitMutation(
756-
selection,
757-
{ type: "set" },
758-
{ label: "Resize", skipReload: true },
759-
);
760-
expect(noteScriptWritten).toHaveBeenCalledWith(FAKE_IFRAME, written);
761-
expect(applySoftReload).not.toHaveBeenCalled();
762-
});
763-
764746
it("does not count a failed write that skipReload suppresses", async () => {
765747
vi.stubGlobal(
766748
"fetch",

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { usePlayerStore } from "../player/store/playerStore";
66
import {
77
applySoftReload,
88
extractGsapScriptText,
9-
noteScriptWritten,
109
readNestedFiles,
1110
settleNestedReads,
1211
} from "../utils/gsapSoftReload";
@@ -176,7 +175,6 @@ function syncCommittedGsapMutation({
176175
nestedFiles?: Map<string, string> | null;
177176
}): void {
178177
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
179-
noteScriptWritten(iframe, result.scriptText);
180178
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
181179
// later SDK-routed edit doesn't serialize the pre-write doc and revert this.
182180
forceReloadSdkSession?.();

‎packages/studio/src/utils/gsapSoftReload.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// fallow-ignore-file code-duplication
22
// @vitest-environment happy-dom
33

4+
import { patchRuntimeTweenInPlace } from "../hooks/gsapRuntimePatch";
45
import { afterEach, describe, it, expect, vi } from "vitest";
56
import {
67
readNestedFiles,
@@ -962,3 +963,41 @@ describe("applySoftReload over a composition's own markup", () => {
962963
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
963964
});
964965
});
966+
967+
describe("a gsap.set a live patch applied", () => {
968+
it("is cleared by the next soft reload once the new script no longer sets it, and only once", () => {
969+
const markup = `<div data-composition-id="root"><div id="a" style="left: 10px"></div></div>`;
970+
const doc = document.implementation.createHTMLDocument("");
971+
doc.body.innerHTML = `${markup}<script>window.__timelines["root"]=gsap.timeline();</script>`;
972+
const set = (target: HTMLElement | HTMLElement[], vars: Record<string, unknown>) => {
973+
for (const el of [target].flat()) {
974+
if (vars.clearProps) el.removeAttribute("style");
975+
else for (const [k, v] of Object.entries(vars)) el.style.setProperty(k, `${v}px`);
976+
}
977+
};
978+
const iframe = {
979+
contentDocument: doc,
980+
contentWindow: {
981+
gsap: { timeline: () => {}, set },
982+
__hfForceTimelineRebind: () => {},
983+
__timelines: {},
984+
__player: { getTime: () => 0, seek: () => {} },
985+
},
986+
} as unknown as HTMLIFrameElement;
987+
const el = doc.getElementById("a")!;
988+
const reload = () => {
989+
const script = `window.__timelines["root"]=gsap.timeline();gsap.set("#a",{width:300});`;
990+
applySoftReload(iframe, script, { authoredHtml: `${markup}<script>${script}</script>` });
991+
};
992+
993+
// W 300, then H 200, then a commit that removes height from the set.
994+
patchRuntimeTweenInPlace(iframe, "#a", { kind: "global-set", props: { width: 300 } });
995+
patchRuntimeTweenInPlace(iframe, "#a", { kind: "global-set", props: { height: 200 } });
996+
reload();
997+
expect([el.style.height, el.style.left]).toEqual(["", "10px"]);
998+
999+
el.style.height = "50px";
1000+
reload();
1001+
expect(el.style.height).toBe("50px");
1002+
});
1003+
});

‎packages/studio/src/utils/gsapSoftReload.ts‎

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-gradin
22
import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn";
33
import { findAuthoredElement } from "./authoredSource";
44
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
5-
import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
5+
import {
6+
authoringFile,
7+
collectResetTargets,
8+
compositionFile,
9+
fileDocs,
10+
forgetLiveSets,
11+
} from "./softReloadTargets";
612

713
type IframeWindow = Window & {
814
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
@@ -228,19 +234,6 @@ export interface SoftReloadOptions {
228234
nestedFiles?: Map<string, string> | null;
229235
}
230236

231-
// The first and latest script written since the preview last ran one; a live patch applies its sets.
232-
const writtenUnrun = new WeakMap<Element, [string, string]>();
233-
234-
export function noteScriptWritten(
235-
iframe: HTMLIFrameElement | null,
236-
scriptText?: string | null,
237-
): void {
238-
const doc = iframe?.contentDocument;
239-
if (!doc || !scriptText?.includes("gsap.set(")) return;
240-
for (const script of scriptsRegistering(findGsapScriptElements(doc), timelineKeys(scriptText)))
241-
writtenUnrun.set(script, [writtenUnrun.get(script)?.[0] ?? scriptText, scriptText]);
242-
}
243-
244237
/**
245238
* The soft reload's finalization step, shared with the rebind-only preview sync
246239
* below: seek → force timeline rebind → reapply studio manual edits.
@@ -383,11 +376,12 @@ export function applySoftReload(
383376
// fallow-ignore-next-line complexity
384377
const doReload = () => {
385378
const timelines = win.__timelines;
386-
const outgoing = staleScripts.flatMap((s) => [
387-
s.textContent ?? "",
388-
...(writtenUnrun.get(s) ?? []),
389-
]);
390-
const targets = collectResetTargets(win, doc, targetKeys, [...new Set(outgoing)]);
379+
const targets = collectResetTargets(
380+
win,
381+
doc,
382+
targetKeys,
383+
staleScripts.map((script) => script.textContent ?? ""),
384+
);
391385

392386
// Kill ONLY the target composition's timeline(s) — leaving every other
393387
// composition's timeline (and its children on the global timeline) intact.
@@ -440,6 +434,7 @@ export function applySoftReload(
440434
}
441435
}
442436
}
437+
forgetLiveSets(targets);
443438

444439
for (const script of staleScripts) script.remove();
445440

‎packages/studio/src/utils/gsapUndoRestore.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
diffSoftReloadableRestore,
2020
showRestoreInPlace,
2121
} from "./gsapUndoRestore";
22-
import { noteScriptWritten } from "./gsapSoftReload";
22+
import { recordLiveSet } from "./softReloadTargets";
2323
import { applyPatch } from "./sourcePatcher";
2424
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
2525
import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
@@ -215,11 +215,16 @@ describe("applyUndoRestoreToPreview", () => {
215215
describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
216216
const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
217217
const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
218-
const undoScriptEdit = (live: string, authored: string, edit: string, patchedLive = false) => {
218+
const undoScriptEdit = (
219+
live: string,
220+
authored: string,
221+
edit: string,
222+
patchedLive?: (doc: Document) => void,
223+
) => {
219224
const { iframe, contentWindow, doc } = buildLiveIframe(
220225
`${root(live)}<script>${script(patchedLive ? "" : edit)}</script>`,
221226
);
222-
if (patchedLive) noteScriptWritten(iframe, script(edit));
227+
patchedLive?.(doc);
223228
const clearProps = (targets: HTMLElement[]) =>
224229
targets.forEach((t) => t.removeAttribute("style"));
225230
Object.assign(contentWindow.gsap, { set: clearProps });
@@ -250,7 +255,7 @@ describe("applyUndoRestoreToPreview", () => {
250255
`<div id="a" style="left: 10px; width: 300px">t</div>`,
251256
`<div id="a" style="left: 10px">t</div>`,
252257
`gsap.set("#a",{width:300});`,
253-
true,
258+
(live) => recordLiveSet(live.getElementById("a")!, { width: 300 }),
254259
);
255260
const { style } = doc.getElementById("a")!;
256261
expect([style.width, style.left]).toEqual(["", "10px"]);

‎packages/studio/src/utils/softReloadTargets.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function collectResetTargets(
6363
} catch {}
6464
}
6565
sweepHeldElements(doc, targetKeys, others, targets);
66+
addLiveSetTargets(doc, targetKeys, targets);
6667
for (const script of outgoingScripts) addStandaloneSetTargets(doc, script, targets);
6768
return targets;
6869
}
@@ -108,6 +109,35 @@ function sweepHeldElements(
108109
}
109110
}
110111

112+
// What a live patch set on each element since its last reload; no script in the preview ran it.
113+
const liveSets = new WeakMap<Document, Map<Element, Set<string>>>();
114+
115+
export function recordLiveSet(el: Element, vars: Record<string, unknown>): void {
116+
const byElement = liveSets.get(el.ownerDocument) ?? new Map<Element, Set<string>>();
117+
liveSets.set(el.ownerDocument, byElement);
118+
const props = byElement.get(el) ?? new Set<string>();
119+
for (const prop of tweenedProps(vars)) props.add(prop);
120+
byElement.set(el, props);
121+
}
122+
123+
function addLiveSetTargets(
124+
doc: Document,
125+
targetKeys: string[],
126+
targets: Map<Element, Set<string>>,
127+
): void {
128+
const roots = new Set<Element | null | undefined>(targetKeys.map((k) => compositionRoot(doc, k)));
129+
for (const [el, props] of liveSets.get(doc) ?? []) {
130+
if (!roots.has(el) && !roots.has(el.parentElement?.closest("[data-composition-id]"))) continue;
131+
const seen = targets.get(el) ?? new Set<string>();
132+
for (const prop of props) seen.add(prop);
133+
targets.set(el, seen);
134+
}
135+
}
136+
137+
export function forgetLiveSets(reset: Map<Element, unknown>): void {
138+
for (const el of reset.keys()) liveSets.get(el.ownerDocument)?.delete(el);
139+
}
140+
111141
/** Adds what each standalone `gsap.set` in the outgoing script wrote, which no timeline child records. */
112142
function addStandaloneSetTargets(
113143
doc: Document,

0 commit comments

Comments
 (0)