mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user