fix(studio): soft reloads restore what GSAP wrote and holds from the saved files (#4871)

* fix(studio): soft reloads put a nested element's tweened style back from its own file

* fix(studio): timing edits, held elements and DOM-building scripts reload from the files

* refactor(studio): one owner settles the nested file reads for every soft reload
This commit is contained in:
Miguel Ángel
2026-10-01 23:24:32 -07:00
committed by GitHub
parent 6dcc300fd2
commit b5e3ffa6a7
11 changed files with 753 additions and 87 deletions
@@ -548,6 +548,65 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
});
});
describe("a timing edit's soft reload restores what GSAP wrote from the files", () => {
const script = (at: number) =>
`var tl = gsap.timeline({ paused: true }); tl.to("#wt", { width: 450 }, ${at}); tl.to("#nwid", { width: 400 }, ${at}); window.__timelines["root"] = tl;`;
const SUB = `<template><div data-composition-id="sub"><div id="nwid" data-hf-id="hf-n" style="left: 40px"></div></div></template>`;
// The live preview: the top-level timeline tweened a root element's and a nested element's width inline.
async function shift(fileOk: boolean) {
const doc = document.implementation.createHTMLDocument("");
doc.body.innerHTML =
`<div id="root" data-composition-id="root"><div id="wt" data-hf-id="hf-w" style="left: 700px; width: 366px"></div>` +
`<div data-composition-file="compositions/sub.html"><div id="nwid" data-hf-id="hf-n" style="left: 40px; width: 288px"></div></div></div>` +
`<script>${script(0)}</script>`;
const [wt, nwid] = [doc.getElementById("wt")!, doc.getElementById("nwid")!];
const contentWindow = {
gsap: { timeline: vi.fn(), set: vi.fn() },
__hfForceTimelineRebind: vi.fn(),
__timelines: {
root: {
kill: vi.fn(),
getChildren: () => [{ targets: () => [wt, nwid], vars: { width: 450 } }],
},
} as Record<string, unknown>,
__player: { getTime: () => 1, seek: vi.fn() },
};
const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
const fetchMock = vi.fn(async () =>
fileOk ? jsonResponse({ content: SUB }) : new Response("{}", { status: 404 }),
);
vi.stubGlobal("fetch", fetchMock);
const after = `<html><body><div id="root" data-composition-id="root"><div id="wt" data-hf-id="hf-w" style="left: 700px"></div></div><script>${script(1)}</script></body></html>`;
const reloadPreview = vi.fn();
await finishClipTimingFallback({
...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
iframe,
sdkGsap: { mutated: true, scriptText: script(1), after },
});
return { wt, nwid, reloadPreview, fetchMock };
}
it("puts back the root element from the file it wrote and the nested one from its own file", async () => {
const { wt, nwid, reloadPreview, fetchMock } = await shift(true);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/files/compositions%2Fsub.html"),
);
expect(reloadPreview).not.toHaveBeenCalled();
expect(wt.getAttribute("style")).toBe("left: 700px;");
expect(nwid.getAttribute("style")).toBe("left: 40px;");
});
it("reloads the preview in full when the nested file cannot be read", async () => {
const { nwid, reloadPreview } = await shift(false);
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 288px");
});
});
function installOwnedFileServer(
contents: Map<string, string>,
options: {
@@ -7,6 +7,8 @@ import {
applySoftReload,
applySoftReloadFinalization,
extractGsapScriptText,
readNestedFiles,
settleNestedReads,
} from "../utils/gsapSoftReload";
import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
import type { RecordEditInput } from "../utils/studioFileHistory";
@@ -118,6 +120,7 @@ function syncTimingEditPreview(
currentTime: number,
reloadPreview: () => void,
rebindWhenUnmutated: boolean,
nestedFiles: Map<string, string> | null | undefined,
): void {
if (!outcome.mutated && rebindWhenUnmutated) {
if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
@@ -130,12 +133,15 @@ function syncTimingEditPreview(
const result = applySoftReload(iframe, outcome.scriptText, {
onAsyncFailure: reloadPreview,
currentTimeOverride: currentTime,
authoredHtml: outcome.after,
nestedFiles,
});
if (result === "cannot-soft-reload") reloadPreview();
}
async function finishTimelineTimingFallback(input: {
iframe: HTMLIFrameElement | null;
projectId: string | null;
reloadPreview: () => void;
gsapMutation?: () => Promise<GsapMutationStatus>;
onGsapError: (error: unknown) => void;
@@ -160,12 +166,21 @@ async function finishTimelineTimingFallback(input: {
return;
}
}
const { projectId } = input;
const nestedFiles = await settleNestedReads(
projectId && outcome.scriptText
? readNestedFiles(input.iframe, outcome.scriptText, (path) =>
readFileContent(projectId, path),
)
: null,
);
syncTimingEditPreview(
input.iframe,
outcome,
usePlayerStore.getState().currentTime,
input.reloadPreview,
input.rebindWhenUnmutated,
nestedFiles,
);
}
@@ -384,7 +399,7 @@ export function sdkTimingGsapSync(
return findTimelineScript(scripts)?.textContent ?? null;
};
if (timelineText(result.before) === timelineText(result.after)) return null;
return { mutated: true, scriptText: extractGsapScriptText(result.after) };
return { mutated: true, scriptText: extractGsapScriptText(result.after), after: result.after };
}
/** Timing delta a single-clip edit applies to its GSAP tweens. */
@@ -440,6 +455,7 @@ export function finishClipTimingFallback(input: {
console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err);
return finishTimelineTimingFallback({
iframe: input.iframe,
projectId,
reloadPreview: input.reloadPreview,
gsapMutation: sdkGsap
? () => Promise.resolve(sdkGsap)
@@ -498,6 +514,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
if (sdkGsap) {
await finishTimelineTimingFallback({
iframe: input.iframe,
projectId: input.projectId,
reloadPreview: input.reloadPreview,
gsapMutation: () => Promise.resolve(sdkGsap),
onGsapError,
@@ -507,6 +524,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
}
await finishTimelineTimingFallback({
iframe: input.iframe,
projectId: input.projectId,
reloadPreview: input.reloadPreview,
gsapMutation: () =>
foldGsapMutationIntoHistory({
@@ -519,6 +537,7 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
gsapMutation: async (runOwnedMutation) => {
let mutated = false;
let scriptText: GsapMutationStatus["scriptText"] = null;
let after: string | undefined;
for (const change of input.changes) {
const changePath = input.resolveChangePath(change.element);
const status = await runOwnedMutation(changePath, () =>
@@ -527,9 +546,9 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
mutated = mutated || status.mutated;
// The LAST mutation against the active comp carries the cumulative
// rewritten script for that file.
if (changePath === activePath) scriptText = status.scriptText;
if (changePath === activePath) ({ scriptText, after } = status);
}
return { mutated, scriptText: otherFileChanged ? null : scriptText };
return { mutated, scriptText: otherFileChanged ? null : scriptText, after };
},
onRollbackError: onGsapError,
}),
@@ -10,13 +10,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
const applySoftReload = vi.fn<(...args: unknown[]) => string>();
const trackStudioEvent = vi.fn();
const readNestedFiles = vi.fn<(...args: unknown[]) => unknown>(() => null);
vi.mock("./gsapRuntimePatch", () => ({
patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
}));
vi.mock("../utils/gsapSoftReload", () => ({
vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
applySoftReload: (...args: unknown[]) => applySoftReload(...args),
extractGsapScriptText: () => "",
readNestedFiles: (...args: unknown[]) => readNestedFiles(...args),
}));
vi.mock("../utils/studioTelemetry", () => ({
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
@@ -364,6 +367,7 @@ let cleanup: (() => void) | null = null;
function renderCommitHook(
options: {
writeProjectFile?: (path: string, content: string) => Promise<void>;
iframe?: HTMLIFrameElement;
} = {},
) {
const reloadPreview = vi.fn();
@@ -378,7 +382,7 @@ function renderCommitHook(
captured.api = useGsapScriptCommits({
projectIdRef: { current: "proj-1" },
activeCompPath: "index.html",
previewIframeRef: { current: FAKE_IFRAME },
previewIframeRef: { current: options.iframe ?? FAKE_IFRAME },
editHistory: { recordEdit },
reloadPreview,
onCacheInvalidate,
@@ -578,6 +582,7 @@ describe("runCommit — instantPatch wiring", () => {
patchRuntimeTweenInPlace.mockReset();
applySoftReload.mockReset();
trackStudioEvent.mockReset();
readNestedFiles.mockReset().mockReturnValue(null);
});
afterEach(() => {
cleanup?.();
@@ -585,6 +590,83 @@ describe("runCommit — instantPatch wiring", () => {
vi.unstubAllGlobals();
});
const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
// A preview whose top-level timeline tweens an element written in a nested composition file.
function nestedPreview(): HTMLIFrameElement {
const doc = document.implementation.createHTMLDocument("");
doc.body.innerHTML = `<div data-composition-id="root"><div data-composition-file="compositions/sub.html"><div id="nwid"></div></div></div>`;
const nwid = doc.getElementById("nwid")!;
const tween = { targets: () => [nwid], vars: { width: 400 } };
return {
contentDocument: doc,
contentWindow: { __timelines: { root: { getChildren: () => [tween] } } },
} as unknown as HTMLIFrameElement;
}
// The mutation endpoint answers the commit; the files endpoint serves the nested file, or fails.
function mockServer(fileOk: boolean): ReturnType<typeof vi.fn> {
const body = {
ok: true,
changed: true,
before: "BEFORE",
after: "AFTER",
scriptText: NESTED_SCRIPT,
};
const fetch = vi.fn(async (url: string) =>
url.includes("/files/")
? ({ ok: fileOk, json: async () => ({ content: SUB }) } as unknown as Response)
: ({ ok: true, json: async () => body } as unknown as Response),
);
vi.stubGlobal("fetch", fetch);
return fetch;
}
async function useRealNestedRead() {
const actual =
await vi.importActual<typeof import("../utils/gsapSoftReload")>("../utils/gsapSoftReload");
readNestedFiles.mockImplementation((...args) =>
actual.readNestedFiles(...(args as Parameters<typeof actual.readNestedFiles>)),
);
}
it("hands the soft reload the nested composition file it read for a reset element", async () => {
await useRealNestedRead();
applySoftReload.mockReturnValue("applied");
const fetch = mockServer(true);
const deps = renderCommitHook({ iframe: nestedPreview() });
await act(async () => {
await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
});
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
expect(applySoftReload).toHaveBeenCalledWith(
expect.anything(),
NESTED_SCRIPT,
expect.objectContaining({ nestedFiles: new Map([["compositions/sub.html", SUB]]) }),
);
});
it("reloads the preview in full when that nested file cannot be read", async () => {
await useRealNestedRead();
applySoftReload.mockReturnValue("cannot-soft-reload");
mockServer(false);
const deps = renderCommitHook({ iframe: nestedPreview() });
await act(async () => {
await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
});
expect(applySoftReload).toHaveBeenCalledWith(
expect.anything(),
NESTED_SCRIPT,
expect.objectContaining({ nestedFiles: null }),
);
expect(deps.reloadPreview).toHaveBeenCalledTimes(1);
});
it("instantPatch succeeds: persists, invalidates cache, NO reload", async () => {
patchRuntimeTweenInPlace.mockReturnValue(true);
mockFetchResult();
@@ -3,7 +3,12 @@ import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mu
import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
import {
applySoftReload,
extractGsapScriptText,
readNestedFiles,
settleNestedReads,
} from "../utils/gsapSoftReload";
import type { SoftReloadResult } from "../utils/gsapSoftReload";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
@@ -142,9 +147,10 @@ function refreshMutationPreview(
options: CommitMutationOptions,
reloadPreview: () => void,
onCacheInvalidate: () => void,
nestedFiles?: Map<string, string> | null,
): void {
options.beforeReload?.();
applyPreviewSync(iframe, result, options, reloadPreview);
applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
onCacheInvalidate();
}
@@ -168,6 +174,7 @@ function syncCommittedGsapMutation({
forceReloadSdkSession,
reloadPreview,
onCacheInvalidate,
nestedFiles,
}: {
iframe: HTMLIFrameElement | null;
selection: DomEditSelection;
@@ -179,6 +186,7 @@ function syncCommittedGsapMutation({
forceReloadSdkSession?: () => void;
reloadPreview: () => void;
onCacheInvalidate: () => void;
nestedFiles?: Map<string, string> | null;
}): void {
if (result.after != null) onFileContentChanged?.(targetPath, result.after);
// Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
@@ -196,7 +204,7 @@ function syncCommittedGsapMutation({
iframe?.contentDocument,
);
}
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate);
refreshMutationPreview(iframe, result, options, reloadPreview, onCacheInvalidate, nestedFiles);
}
/**
@@ -219,6 +227,7 @@ function softReloadOrEscalate(
reloadPreview: () => void,
origin: "preview_sync" | "sdk_refresh",
authoredHtml?: string,
nestedFiles?: Map<string, string> | null,
): void {
// Seek the rebuilt timeline to the studio's own authoritative scrub position,
// not the iframe's raw `__player.getTime()` — see the comment in
@@ -228,6 +237,7 @@ function softReloadOrEscalate(
onAsyncFailure: reloadPreview,
currentTimeOverride: currentTime,
authoredHtml,
nestedFiles,
});
if (result === "applied") return;
trackStudioEvent("gsap_soft_reload_outcome", {
@@ -252,6 +262,7 @@ export function applyPreviewSync(
result: MutationResult,
options: CommitMutationOptions,
reloadPreview: () => void,
nestedFiles?: Map<string, string> | null,
): void {
const patches = instantPatchesFor(options);
let needsFallback = options.previewFallbackLatch?.pending === true;
@@ -297,6 +308,7 @@ export function applyPreviewSync(
reloadPreview,
"preview_sync",
result.after ?? undefined,
nestedFiles,
);
} else {
reloadPreview();
@@ -349,6 +361,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
return;
}
await recordMutationEdit(targetPath, result, options);
const nestedFiles = await settleNestedReads(
result.scriptText
? readNestedFiles(previewIframeRef.current, result.scriptText, (path) =>
readSharedProjectFileContent(projectId, path),
)
: null,
);
// The durable mutation belongs to the project captured when it was queued.
// A later project must never receive its file state or preview refresh.
if (!isActiveCommitTarget(projectIdRef, activeCompPathRef, projectId, compositionPath)) return;
@@ -363,6 +382,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
forceReloadSdkSession,
reloadPreview,
onCacheInvalidate,
nestedFiles,
});
}, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
@@ -45,3 +45,79 @@ describe("undo that reloads the preview", () => {
expect([player.elements, player.timelineReady]).toEqual([clips, true]);
});
});
describe("undo that re-runs the top-level script over an element of a nested composition", () => {
const script = (width: number) =>
`var tl = gsap.timeline({ paused: true }); tl.to("#nwid", { width: ${width} }); window.__timelines["root"] = tl;`;
const page = (width: number) =>
`<html><body><div id="root" data-composition-id="root"></div><script>${script(width)}</script></body></html>`;
const SUB = `<template><div data-composition-id="sub"><div id="nwid" data-hf-id="hf-n" style="left: 40px"></div></div></template>`;
// The live preview: the top-level timeline tweened the nested element's width inline.
function nestedPreview() {
const doc = document.implementation.createHTMLDocument("");
doc.body.innerHTML =
`<div id="root" data-composition-id="root"><div data-composition-file="compositions/sub.html">` +
`<div id="nwid" data-hf-id="hf-n" style="left: 40px; width: 337px"></div></div></div>` +
`<script>${script(400)}</script>`;
const nwid = doc.getElementById("nwid")!;
const contentWindow = {
gsap: { timeline: vi.fn(), set: vi.fn() },
__hfForceTimelineRebind: vi.fn(),
__timelines: {
root: {
kill: vi.fn(),
getChildren: () => [{ targets: () => [nwid], vars: { width: 400 } }],
},
} as Record<string, unknown>,
__player: { getTime: () => 1, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
};
return {
iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
nwid,
};
}
async function undo(fileOk: boolean) {
const { iframe, nwid } = nestedPreview();
const reloadPreview = vi.fn();
const fetch = vi.fn(async () => ({ ok: fileOk, json: async () => ({ content: SUB }) }));
vi.stubGlobal("fetch", fetch);
usePlayerStore.getState().beginTimelineSession("p1");
let sync: ReturnType<typeof usePreviewPersistence>["syncHistoryPreviewAfterApply"] | null =
null;
function Harness() {
sync = usePreviewPersistence({
showToast: () => {},
readOptionalProjectFile: async () => "",
writeProjectFile: async () => {},
recordEdit: async () => {},
previewIframeRef: { current: iframe },
activeCompPathRef: { current: "index.html" },
reloadPreview,
}).syncHistoryPreviewAfterApply;
return null;
}
mountReactHarness(<Harness />);
const files = { "index.html": { previous: page(400), restored: page(450) } };
await act(async () => sync!({ paths: Object.keys(files), files }));
vi.unstubAllGlobals();
return { nwid, reloadPreview, fetch };
}
it("restores the element from its own composition file", async () => {
const { nwid, reloadPreview, fetch } = await undo(true);
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
expect(reloadPreview).not.toHaveBeenCalled();
expect(nwid.getAttribute("style")).toBe("left: 40px;");
});
it("reloads the preview in full when that file cannot be read", async () => {
const { nwid, reloadPreview } = await undo(false);
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
});
});
@@ -14,9 +14,12 @@ import {
import { trackStudioEvent } from "../utils/studioTelemetry";
import {
applyUndoRestoreToPreview,
readUndoNestedFiles,
showRestoreInPlace,
type RestoreFiles,
} from "../utils/gsapUndoRestore";
import { settleNestedReads } from "../utils/gsapSoftReload";
import { readProjectFileContent } from "../utils/studioFileHistory";
import { usePlayerStore } from "../player";
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
@@ -204,12 +207,24 @@ export function usePreviewPersistence({
// attributes onto the live DOM and re-runs the timeline at the SAME playhead,
// falling back to reloadPreview for anything structural (split/delete undo),
// multi-file, sub-comp, or a permanent soft-reload failure.
const projectId = usePlayerStore.getState().timelineProjectId;
const nestedFiles = await settleNestedReads(
projectId
? readUndoNestedFiles(
previewIframeRef.current,
activeCompPathRef.current,
restore.files,
(path) => readProjectFileContent(projectId, path),
)
: null,
);
const strategy = applyUndoRestoreToPreview(
previewIframeRef.current,
activeCompPathRef.current,
restore.files,
usePlayerStore.getState().currentTime,
reloadPreview,
nestedFiles,
);
if (strategy === "full") {
usePlayerStore.getState().setSelectedElementId(null);
@@ -1,7 +1,8 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
import {
readNestedFiles,
applySoftReload,
applySoftReloadFinalization,
ensureMotionPathPluginLoaded,
@@ -131,10 +132,14 @@ describe("applySoftReload", () => {
const container = document.createElement("div");
container.appendChild(scriptEl);
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.appendChild(orphan);
const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
querySelectorAll: (sel: string) =>
sel === "script:not([src])" ? [scriptEl] : sel === "[style*='transform']" ? [orphan] : [],
sel === "script:not([src])" ? [scriptEl] : sel.includes("composition-id") ? [root] : [],
createElement: (tag: string) => document.createElement(tag),
body: container,
head: document.createElement("div"),
@@ -427,6 +432,8 @@ describe("ensureMotionPathPluginLoaded", () => {
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
describe("applySoftReload authored-style restore", () => {
afterEach(() => document.body.replaceChildren());
function buildIframeWithTarget(el: Element, overrides: Record<string, unknown> = {}) {
const scriptEl = document.createElement("script");
scriptEl.textContent =
@@ -581,4 +588,233 @@ describe("applySoftReload authored-style restore", () => {
applySoftReload(iframe, SCRIPT_TEXT);
expect(transformAtRebind).toBe("");
});
// The preview inlines a sub-composition into a host that names its file.
function inlinedIn(file: string, el: HTMLElement): HTMLElement {
const host = document.createElement("div");
host.setAttribute("data-composition-file", file);
host.appendChild(el);
document.body.appendChild(host);
return el;
}
function answerQueries(iframe: HTMLIFrameElement, styled: Element, root: Element): void {
const doc = iframe.contentDocument as unknown as { querySelectorAll: (s: string) => unknown };
const query = doc.querySelectorAll;
doc.querySelectorAll = (sel: string) =>
sel.includes("transform") ? [styled] : sel.includes("composition-id") ? [root] : query(sel);
}
const SUB_FILE = `<template id="sub-template"><div id="sub" data-composition-id="sub">
<div id="nroot" data-hf-id="hf-n" style="left: 560px; top: 300px"></div></div></template>`;
it("gives a root tween's nested target what its own file authors for what GSAP wrote", () => {
const el = document.createElement("div");
el.id = "nroot";
el.setAttribute("data-hf-id", "hf-n");
// GSAP folded the stylesheet translate into its transform, masked it, and wrote the tweened width.
el.style.cssText =
"left: 560px; top: 300px; width: 337px; translate: none; transform: translate(12.5px, 0px)";
inlinedIn("compositions/sub.html", el);
const tween = { targets: () => [el], vars: { x: 50, width: 400, duration: 4 } };
const { iframe } = buildIframeWithTarget(el, {
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
});
const authoredHtml = `<html><body><div id="root"></div></body></html>`;
const nestedFiles = new Map([["compositions/sub.html", SUB_FILE]]);
expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml, nestedFiles })).toBe("applied");
expect(el.getAttribute("style")).toBe("left: 560px; top: 300px;");
});
it("puts back a root element's tweened width from the file instead of the live value", () => {
const el = document.createElement("div");
el.setAttribute("data-hf-id", "hf-1");
el.style.cssText = "left: 700px; width: 366px";
const tween = { targets: () => [el], vars: { width: 450, duration: 4 } };
const { iframe } = buildIframeWithTarget(el, {
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
});
const authoredHtml = `<html><body><div data-hf-id="hf-1" style="left: 700px"></div></body></html>`;
expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied");
expect(el.getAttribute("style")).toBe("left: 700px;");
});
it("leaves what a nested composition's own timeline animates to that timeline", () => {
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
el.id = "nroot";
el.style.cssText = "translate: none; transform: translate(40px, 10px)";
const sub = { getChildren: () => [{ targets: () => [el], vars: { x: 100 } }] };
const { iframe } = buildIframeWithTarget(el, {
__timelines: { root: { kill: vi.fn(), getChildren: () => [sub] }, sub },
});
const readFile = vi.fn(async () => SUB_FILE);
expect(readNestedFiles(iframe, SCRIPT_TEXT, readFile)).toBeNull();
applySoftReload(iframe, SCRIPT_TEXT);
expect(readFile).not.toHaveBeenCalled();
expect(el.style.cssText).toBe("translate: none; transform: translate(40px, 10px);");
});
it("a nested composition's reload leaves a GSAP transform in the top-level file alone", () => {
const host = document.createElement("div");
host.setAttribute("data-composition-id", "root");
host.setAttribute("data-composition-file", "compositions/sub.html");
const outside = Object.assign(document.createElement("div"), { _gsap: {} });
outside.style.cssText = "rotate: none; transform: rotate(30deg)";
const { iframe } = buildIframeWithTarget(host, {
__timelines: { root: { kill: vi.fn(), getChildren: () => [] } },
});
answerQueries(iframe, outside, host);
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: SUB_FILE });
expect(outside.style.cssText).toBe("rotate: none; transform: rotate(30deg);");
});
it("a nested composition's reload leaves its element that the top-level timeline animates alone", () => {
const host = document.createElement("div");
host.setAttribute("data-composition-id", "root");
host.setAttribute("data-composition-file", "compositions/sub.html");
const el = Object.assign(document.createElement("div"), { _gsap: {} });
// The top-level tween folded the stylesheet translate into its transform.
el.style.cssText = "translate: none; transform: translate(23.75px, 5px)";
host.appendChild(el);
const main = { getChildren: () => [{ targets: () => [el], vars: { x: 50 } }] };
const { iframe } = buildIframeWithTarget(host, {
__timelines: { root: { kill: vi.fn(), getChildren: () => [] }, main },
});
answerQueries(iframe, el, host);
applySoftReload(iframe, SCRIPT_TEXT, {
authoredHtml: SUB_FILE,
});
expect(el.style.cssText).toBe("translate: none; transform: translate(23.75px, 5px);");
});
it("a nested composition's own reload restores its element from the file just written", () => {
const host = document.createElement("div");
host.setAttribute("data-composition-id", "root");
host.setAttribute("data-composition-file", "compositions/sub.html");
const el = document.createElement("div");
el.id = "nroot";
el.setAttribute("data-hf-id", "hf-n");
el.style.cssText = "left: 560px; top: 300px; width: 337px";
host.appendChild(el);
const tween = { targets: () => [el], vars: { width: 400 } };
const { iframe } = buildIframeWithTarget(el, {
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
});
answerQueries(iframe, el, host);
const readFile = vi.fn(async () => SUB_FILE);
expect(readNestedFiles(iframe, SCRIPT_TEXT, readFile)).toBeNull();
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: SUB_FILE });
expect(readFile).not.toHaveBeenCalled();
expect(el.getAttribute("style")).toBe("left: 560px; top: 300px;");
});
it("reads only the other composition files a top-level re-run resets elements of", async () => {
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
const tween = { targets: () => [el], vars: { width: 400 } };
const { iframe } = buildIframeWithTarget(el, {
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
});
const readFile = vi.fn(async () => SUB_FILE);
const files = await readNestedFiles(iframe, SCRIPT_TEXT, readFile);
expect(readFile).toHaveBeenCalledTimes(1);
expect(files).toEqual(new Map([["compositions/sub.html", SUB_FILE]]));
});
it("escalates, touching nothing, when a nested file the reset needs could not be read", () => {
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
el.style.cssText = "width: 337px; translate: none";
const tween = { targets: () => [el], vars: { width: 400 } };
const set = vi.fn();
const { iframe } = buildIframeWithTarget(el, {
gsap: { timeline: vi.fn(), set },
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
});
expect(applySoftReload(iframe, SCRIPT_TEXT, { nestedFiles: null })).toBe("cannot-soft-reload");
expect(set).not.toHaveBeenCalled();
expect(el.style.cssText).toBe("width: 337px; translate: none;");
});
it("keeps the runtime's own inline writes: only what GSAP wrote comes back from the file", () => {
const el = document.createElement("div");
el.setAttribute("data-hf-id", "hf-1");
// The runtime's layout pass wrote position and a data-width size; GSAP wrote the mask and transform.
el.style.cssText =
"left: 10px; position: absolute; width: 300px; translate: none; transform: translate(50px, 0px)";
const tween = { targets: () => [el], vars: { x: 50 } };
const { iframe } = buildIframeWithTarget(el, {
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
});
const authoredHtml = `<html><body><div data-hf-id="hf-1" style="left: 10px"></div></body></html>`;
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml });
expect(el.getAttribute("style")).toBe("left: 10px; position: absolute; width: 300px;");
});
});
describe("applySoftReload over a composition's own markup", () => {
// A live preview whose top-level timeline has no children; `_gsap` marks what a script set.
function reloadRoot(markup: string, held: string[]) {
const doc = document.implementation.createHTMLDocument("");
doc.body.innerHTML = `${markup}<script>${SCRIPT_TEXT}</script>`;
for (const id of held) Object.assign(doc.getElementById(id)!, { _gsap: {} });
const set = vi.fn();
const contentWindow = {
gsap: { timeline: vi.fn(), set },
__hfForceTimelineRebind: vi.fn(),
__timelines: { root: { kill: vi.fn(), getChildren: () => [] } } as Record<string, unknown>,
__player: { getTime: () => 0, seek: vi.fn() },
};
const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
const cleared = () => (set.mock.calls[0]?.[0] as Element[] | undefined)?.map((el) => el.id);
return { doc, iframe, cleared };
}
it("resets an element a standalone gsap.set holds even when no inline transform is left", () => {
const { iframe, cleared } = reloadRoot(
`<div data-composition-id="root"><div id="held" style="left: 5px"></div><div id="plain"></div></div>`,
["held"],
);
applySoftReload(iframe, SCRIPT_TEXT);
expect(cleared()).toEqual(["held"]);
});
it("leaves what a nested composition's script holds alone, but resets that composition's host", () => {
const { doc, iframe, cleared } = reloadRoot(
`<div data-composition-id="root"><div id="host" data-composition-id="sub">` +
`<div id="nested" style="transform: translate(90px, 60px)"></div></div></div>`,
["host", "nested"],
);
applySoftReload(iframe, SCRIPT_TEXT);
expect(cleared()).toEqual(["host"]);
expect(doc.getElementById("nested")!.style.transform).toBe("translate(90px, 60px)");
});
it("reloads in full when the re-run builds its DOM a second time", () => {
const { doc, iframe } = reloadRoot(`<div id="root" data-composition-id="root"></div>`, []);
const append = doc.body.appendChild.bind(doc.body);
// What a script's createElement or template clone does each time it runs.
doc.body.appendChild = <T extends Node>(node: T): T => {
doc.getElementById("root")!.appendChild(doc.createElement("div"));
return append(node);
};
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
});
});
+89 -74
View File
@@ -1,6 +1,7 @@
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
import { findAuthoredElement } from "./authoredSource";
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
type IframeWindow = Window & {
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
@@ -131,15 +132,28 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
}
// GSAP masks a folded CSS translate/rotate/scale with `none`; a fresh load has only what the file authors.
function restoreAuthoredTransforms(
// GSAP masks a folded CSS translate/rotate/scale with `none`, and a tween writes what it animates
// inline; a fresh load has only what the file authors. Opacity has its own restore.
function restoreAuthoredStyle(
style: CSSStyleDeclaration,
authored: CSSStyleDeclaration | null,
tweened: Set<string>,
) {
style.transform = authored?.transform ?? "";
if (!authored) return;
for (const prop of ["translate", "rotate", "scale"]) {
style.setProperty(prop, authored.getPropertyValue(prop));
for (const prop of ["translate", "rotate", "scale", ...tweened]) {
if (prop === "opacity") continue;
style.setProperty(prop, authored.getPropertyValue(prop), authored.getPropertyPriority(prop));
}
}
function runSuppressed(win: IframeWindow, reload: () => void): boolean {
try {
if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
else reload();
return true;
} catch {
return false;
}
}
@@ -196,6 +210,8 @@ export interface SoftReloadOptions {
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
authoredHtml?: string;
reparse?: Element[];
/** Other composition files a reset element is written in, by path; null when one could not be read. */
nestedFiles?: Map<string, string> | null;
}
/**
@@ -247,6 +263,12 @@ export function applySoftReloadFinalization(
}
}
function timelineKeys(scriptText: string): string[] {
return [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
.map((m) => m[1]!)
.filter((key) => key !== "__proxied");
}
export function applySoftReload(
iframe: HTMLIFrameElement | null,
scriptText: string,
@@ -259,6 +281,8 @@ export function applySoftReload(
const doc = iframe.contentDocument;
if (!win || !doc) return "cannot-soft-reload";
if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
// Without the file an element is written in, only a full load restores it.
if (options.nestedFiles === null) return "cannot-soft-reload";
// Which composition(s) does this script rebuild? A soft reload re-runs ONE
// composition's GSAP script, which re-registers its own window.__timelines[key].
@@ -267,9 +291,7 @@ export function applySoftReload(
// of the global timeline — so tearing down ALL of them (or the global timeline's
// children) and re-running a single script wipes every OTHER composition,
// reverting its edits. Scope the teardown to the keys THIS script re-registers.
const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
.map((m) => m[1]!)
.filter((key) => key !== "__proxied");
const targetKeys = timelineKeys(scriptText);
if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
const gsapScripts = findGsapScriptElements(doc);
if (gsapScripts.length === 0) return "cannot-soft-reload";
@@ -302,6 +324,7 @@ export function applySoftReload(
// once the plugin loads; the alternative (returning false) would trigger a
// full iframe reload that destroys the very WebGL context we're preserving.
let deferredToAsync = false;
let builtDom = false;
// Authored-opacity resolution for the restore loop below. Three-state:
// "0.98" — the element's authored inline opacity
@@ -311,16 +334,10 @@ export function applySoftReload(
// The just-written file (`authoredHtml`) is the current truth; the runtime's
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
let authoredDoc: Document | null | undefined;
const docFor = fileDocs(compositionFile(doc, targetKeys), authoredHtml, options.nestedFiles);
const findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => {
if (authoredDoc === undefined) {
try {
authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
} catch {
authoredDoc = null;
}
}
const source = authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
const fileDoc = docFor(authoringFile(el));
const source = fileDoc ? findAuthoredElement(fileDoc, el) : null;
// The parsed file lives in this realm, so instanceof holds here, unlike for the iframe nodes below.
return source instanceof HTMLElement || source instanceof SVGElement ? source.style : null;
};
@@ -330,29 +347,14 @@ export function applySoftReload(
// fallow-ignore-next-line complexity
const doReload = () => {
const timelines = win.__timelines;
const allTargets: Element[] = [...reparse];
const targets = collectResetTargets(win, doc, targetKeys, reparse);
// Kill ONLY the target composition's timeline(s) — leaving every other
// composition's timeline (and its children on the global timeline) intact.
if (timelines) {
for (const key of targetKeys) {
const tl = timelines[key] as
| {
kill?: () => void;
clear?: () => void;
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
}
| undefined;
const tl = timelines[key] as { kill?: () => void; clear?: () => void } | undefined;
if (!tl) continue;
if (tl.getChildren) {
try {
for (const child of tl.getChildren(true)) {
if (typeof child.targets === "function") {
for (const t of child.targets()) allTargets.push(t);
}
}
} catch {}
}
try {
// kill() keeps the children, and the finalize seek renders this timeline until the rebind swaps it.
tl.clear?.();
@@ -362,29 +364,11 @@ export function applySoftReload(
}
}
// Also reset elements carrying a GSAP-applied inline `transform` that the
// timeline-children sweep above missed — a dragged element whose position
// was a standalone `gsap.set` (never a timeline child), or one whose
// keyframes were just removed (no longer in any timeline). Their last
// `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
// and the sweep above can't see it, so the element renders offset from its
// source position (matching the overlay) until a full reload. The clear
// below runs BEFORE the re-run, which re-applies the transform for any
// element the new script still animates.
const seenTargets = new Set<Element>(allTargets);
for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
// Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
// never strip an authored, non-GSAP inline transform.
if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
seenTargets.add(el);
allTargets.push(el);
}
}
// Reset GSAP's internal transform cache so from() tweens don't read stale
// end values. `clearProps: "all"` is needed to flush the cache, but it also
// nukes the element's CSS base (position, width, height, etc.) from the
// HTML `style=""` attribute. Save → clear → restore → authored transform props.
// HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
const allTargets = [...targets.keys()];
if (allTargets.length > 0 && win.gsap?.set) {
const saved: Array<[HTMLElement, string]> = [];
for (const el of allTargets) {
@@ -399,7 +383,7 @@ export function applySoftReload(
for (const [el, css] of saved) {
const s = el.style;
s.cssText = css;
restoreAuthoredTransforms(s, findAuthoredStyle(el));
restoreAuthoredStyle(s, findAuthoredStyle(el), targets.get(el)!);
// The restored cssText carries RUNTIME opacity, not authored opacity:
// a mid-flight tween's interpolated value, or the color-grading hide
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
@@ -430,7 +414,14 @@ export function applySoftReload(
}
const s = doc.createElement("script");
s.textContent = `(function(){${scriptText}\n})();`;
const ownScriptNode = 1;
const before = doc.body.querySelectorAll("*").length + ownScriptNode;
doc.body.appendChild(s);
// A script that builds DOM builds it again on every run; only a full load starts from the file.
// Not seen: text-only writes, nodes put in the head or a shadow root, nodes added
// later, and a builder that removes its old nodes first (which needs no full load).
builtDom = doc.body.querySelectorAll("*").length > before;
if (builtDom && deferredToAsync) onAsyncFailure?.();
finalizeSoftReload(win, currentTime);
};
@@ -479,23 +470,47 @@ export function applySoftReload(
executeScript();
};
try {
if (win.__hfSuppressSceneMutations) {
win.__hfSuppressSceneMutations(doReload);
} else {
doReload();
}
// When MotionPath needs async loading, the script hasn't executed yet —
// skip the __timelines check and report success optimistically (the script
// WILL run on plugin load; onAsyncFailure covers the CDN-error case).
if (deferredToAsync) return "applied";
// The re-run executed. If the target keys read back, we're done; otherwise
// it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
// as "verify-failed" so callers know NOT to escalate.
return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
} catch {
// The synchronous re-run threw — the preview is now genuinely broken (target
// timeline killed, script not re-registered). Escalate to a full reload.
return "cannot-soft-reload";
}
const run = () => runSuppressed(win, doReload);
// The synchronous re-run threw or built DOM a second time: the preview is now
// genuinely broken (no timeline, or duplicated nodes). Escalate to a full reload.
if (!run() || builtDom) return "cannot-soft-reload";
// When MotionPath needs async loading, the script hasn't executed yet —
// skip the __timelines check and report success optimistically (the script
// WILL run on plugin load; onAsyncFailure covers the CDN-error case).
if (deferredToAsync) return "applied";
// The re-run executed. If the target keys read back, we're done; otherwise
// it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
// as "verify-failed" so callers know NOT to escalate.
return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
}
/** Other composition files for a soft reload: undefined when none are needed, null when a read failed. */
export type NestedFiles = Map<string, string> | null | undefined;
export async function settleNestedReads(
reads: Promise<Map<string, string>> | null,
): Promise<NestedFiles> {
return reads ? reads.catch(() => null) : undefined;
}
/**
* Reads the composition files, other than the reloaded one, that the elements `scriptText`'s
* re-run resets are written in; null when there are none. Read them before the soft reload.
*/
export function readNestedFiles(
iframe: HTMLIFrameElement | null,
scriptText: string,
readFile: (path: string) => Promise<string>,
): Promise<Map<string, string>> | null {
const win = iframe?.contentWindow as IframeWindow | null;
const doc = iframe?.contentDocument;
if (!win || !doc) return null;
const keys = timelineKeys(scriptText);
const own = compositionFile(doc, keys);
const files = [...collectResetTargets(win, doc, keys).keys()].map(authoringFile);
const paths = [...new Set(files)].filter((file): file is string => file !== null && file !== own);
if (paths.length === 0) return null;
return Promise.all(paths.map((path) => readFile(path))).then(
(texts) => new Map(paths.map((path, i) => [path, texts[i]!])),
);
}
@@ -170,9 +170,10 @@ describe("applyUndoRestoreToPreview", () => {
describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
const undoScriptEdit = (live: string, authored: string, edit: string) => {
const { iframe, contentWindow, doc } = buildLiveIframe(
`${live}<script>${script(edit)}</script>`,
`${root(live)}<script>${script(edit)}</script>`,
);
const clearProps = (targets: HTMLElement[]) =>
targets.forEach((t) => t.removeAttribute("style"));
@@ -180,8 +181,8 @@ describe("applyUndoRestoreToPreview", () => {
for (const el of doc.querySelectorAll("[style*=transform]")) Object.assign(el, { _gsap: {} });
const files = {
[ROOT]: {
previous: wrap(`${authored}<script>${script(edit)}</script>`),
restored: wrap(`${authored}<script>${script("")}</script>`),
previous: wrap(`${root(authored)}<script>${script(edit)}</script>`),
restored: wrap(`${root(authored)}<script>${script("")}</script>`),
},
};
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
@@ -7,6 +7,7 @@ import {
applySoftReloadFinalization,
extractGsapScriptText,
findGsapScriptElements,
readNestedFiles,
} from "./gsapSoftReload";
import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
@@ -324,6 +325,18 @@ export function showRestoreInPlace(
return null;
}
/** The other composition files a re-run of the restored script resets elements of; null when none. */
export function readUndoNestedFiles(
iframe: HTMLIFrameElement | null,
activeCompPath: string | null,
files: RestoreFiles | undefined,
readFile: (path: string) => Promise<string>,
): Promise<Map<string, string>> | null {
const active = files?.[activeCompPath ?? "index.html"];
const script = active ? extractGsapScriptText(active.restored) : null;
return script ? readNestedFiles(iframe, script, readFile) : null;
}
/**
* Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
* remount (which blanks the frame black and re-flashes the WebGL context). Eligible
@@ -358,6 +371,7 @@ export function applyUndoRestoreToPreview(
files: RestoreFiles | undefined,
currentTime: number,
reload: () => void,
nestedFiles?: Map<string, string> | null,
): "soft" | "full" {
// The master view carries a NULL activeCompPath but the root iframe shows
// index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
@@ -398,6 +412,7 @@ export function applyUndoRestoreToPreview(
currentTimeOverride: currentTime,
authoredHtml: active.restored,
reparse,
nestedFiles,
});
if (result === "cannot-soft-reload") {
reloadPreview();
@@ -0,0 +1,128 @@
// Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
import { parseSavedSource } from "./authoredSource";
type TweenLike = {
targets?: () => Element[];
vars?: Record<string, unknown>;
getChildren?: (deep: boolean) => TweenLike[];
};
const NESTED_VARS = new Set(["css", "startAt", "keyframes"]);
// The CSS names a tween's vars write; GSAP's own keys (ease, duration) are not CSS and restore as no-ops.
function tweenedProps(vars: unknown): string[] {
if (!vars || typeof vars !== "object") return [];
return Object.entries(vars).flatMap(([key, value]) => [
key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`),
...(NESTED_VARS.has(key) || /^\d|%$/.test(key) ? tweenedProps(value) : []),
]);
}
// A nested composition's timeline sits inside the root one but only its own script rebuilds it.
function ownTweens(tl: TweenLike, nested: Set<unknown>): TweenLike[] {
return (tl.getChildren?.(false) ?? []).flatMap((child) => {
if (nested.has(child)) return [];
return child.getChildren ? ownTweens(child, nested) : [child];
});
}
// The preview marks each inlined composition's host with its file; the host itself is the parent's.
export function authoringFile(el: Element): string | null {
return (
el.parentElement?.closest("[data-composition-file]")?.getAttribute("data-composition-file") ??
null
);
}
function addTweenTargets(tween: TweenLike, targets: Map<Element, Set<string>>): void {
const props = tweenedProps(tween.vars);
for (const el of tween.targets?.() ?? []) {
const seen = targets.get(el) ?? new Set<string>();
for (const prop of props) seen.add(prop);
targets.set(el, seen);
}
}
/** Each element the re-run rebuilds, with the CSS names its killed tweens wrote. */
export function collectResetTargets(
win: { __timelines?: Record<string, unknown> },
doc: Document,
targetKeys: string[],
reparse: Element[] = [],
): Map<Element, Set<string>> {
// Elements the caller needs GSAP to parse again, with nothing tweened yet.
const targets = new Map<Element, Set<string>>(reparse.map((el) => [el, new Set<string>()]));
const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
const others = new Set<unknown>(
Object.entries(timelines).flatMap(([key, tl]) => (targetKeys.includes(key) ? [] : [tl])),
);
for (const key of targetKeys) {
try {
for (const tween of ownTweens(timelines[key] ?? {}, others)) addTweenTargets(tween, targets);
} catch {}
}
sweepHeldElements(doc, targetKeys, others, targets);
return targets;
}
function tweenTargetsIn(timelines: Set<unknown>): Set<Element> {
const els = new Set<Element>();
for (const tl of timelines) {
try {
for (const tween of (tl as TweenLike | undefined)?.getChildren?.(true) ?? []) {
for (const el of tween.targets?.() ?? []) els.add(el);
}
} catch {}
}
return els;
}
function compositionRoot(doc: Document, key: string): Element | undefined {
return doc.querySelectorAll(`[data-composition-id="${CSS.escape(key)}"]`)[0];
}
export function compositionFile(doc: Document, targetKeys: string[]): string | null {
const root = targetKeys.map((key) => compositionRoot(doc, key)).find(Boolean);
return root?.closest("[data-composition-file]")?.getAttribute("data-composition-file") ?? null;
}
// A standalone gsap.set, or keyframes just removed, leaves GSAP state no timeline child shows.
// The nearest composition root says whose script set it; another timeline's targets stay its own.
function sweepHeldElements(
doc: Document,
targetKeys: string[],
others: Set<unknown>,
targets: Map<Element, Set<string>>,
): void {
const elsewhere = tweenTargetsIn(others);
const roots = new Set<Element | null | undefined>(targetKeys.map((k) => compositionRoot(doc, k)));
roots.delete(undefined);
for (const root of roots) {
for (const el of root ? [root, ...root.querySelectorAll("*")] : []) {
if (targets.has(el) || elsewhere.has(el) || !("_gsap" in el)) continue;
if (el === root || roots.has(el.parentElement?.closest("[data-composition-id]")))
targets.set(el, new Set());
}
}
}
// Each file a reset element may be written in, parsed at most once per reload: the reloaded
// composition's own file is the one just written, any other comes from the caller.
export function fileDocs(
ownFile: string | null,
written: string | undefined,
nestedFiles: Map<string, string> | null | undefined,
): (file: string | null) => Document | null {
const docs = new Map<string | null, Document | null>();
return (file) => {
if (!docs.has(file)) {
const text = file === ownFile ? written : nestedFiles?.get(file ?? "");
try {
docs.set(file, text ? parseSavedSource(text) : null);
} catch {
docs.set(file, null);
}
}
return docs.get(file) ?? null;
};
}