diff --git a/packages/studio/src/hooks/timelineTimingSync.ts b/packages/studio/src/hooks/timelineTimingSync.ts index e66ff3bc84a..1541855b96c 100644 --- a/packages/studio/src/hooks/timelineTimingSync.ts +++ b/packages/studio/src/hooks/timelineTimingSync.ts @@ -116,13 +116,14 @@ function syncTimingEditPreview( if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview(); return; } - if (!iframe || !outcome.scriptText) { + if (!iframe || !outcome.scriptText || !outcome.after) { reloadPreview(); return; } const result = applySoftReload(iframe, outcome.scriptText, { onAsyncFailure: reloadPreview, currentTimeOverride: currentTime, + authoredHtml: outcome.after, }); if (result === "cannot-soft-reload") reloadPreview(); } diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index e4ed1a27ca9..421bbe77ad2 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -36,7 +36,7 @@ import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits"; const FAKE_IFRAME = {} as HTMLIFrameElement; function result(over: Partial = {}): MutationResult { - return { ok: true, scriptText: "tl.set('#a',{})", ...over }; + return { ok: true, scriptText: "tl.set('#a',{})", after: "AFTER", ...over }; } /** The canonical drag commit options every path-decision test drives with. */ @@ -52,7 +52,7 @@ function syncDragPreview(res: MutationResult, reloadPreview: () => void) { applyPreviewSync(FAKE_IFRAME, res, dragOptions(), reloadPreview); } -function expectSoftReloadedWith(onAsyncFailure: unknown, authoredHtml: string | undefined) { +function expectSoftReloadedWith(onAsyncFailure: unknown, authoredHtml: string) { expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", { onAsyncFailure, currentTimeOverride: 0, @@ -235,7 +235,7 @@ describe("applyPreviewSync", () => { // reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin // CDN load failure escalates to a full reload — but it is NOT called eagerly. - expectSoftReloadedWith(reloadPreview, undefined); + expectSoftReloadedWith(reloadPreview, "AFTER"); expect(reloadPreview).not.toHaveBeenCalled(); // A successful instant patch is the fast path; here it missed → fallback event. expect(trackStudioEvent).toHaveBeenCalledWith( @@ -244,46 +244,40 @@ describe("applyPreviewSync", () => { ); }); - it('instantPatch + patch fails + soft reload "verify-failed": transient, does NOT escalate (U4)', () => { + // U4: "verify-failed" is the transient empty-timeline window (the live state is right, no escalation); + // "cannot-soft-reload" is structural (the preview is stale), so it reloads. + it.each([ + { outcome: "verify-failed", reloads: 0, escalated: false }, + { outcome: "cannot-soft-reload", reloads: 1, escalated: true }, + ])("instantPatch + patch fails + soft reload $outcome: $reloads full reload(s)", (c) => { patchRuntimeTweenInPlace.mockReturnValue(false); - applySoftReload.mockReturnValue("verify-failed"); + applySoftReload.mockReturnValue(c.outcome); const reloadPreview = vi.fn(); syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview); - // U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state - // is correct, so we must NOT escalate to a full reload. - expectSoftReloadedWith(reloadPreview, undefined); - expect(reloadPreview).not.toHaveBeenCalled(); - // Telemetry records the suppressed transient (escalated: false). + expectSoftReloadedWith(reloadPreview, "AFTER"); + expect(reloadPreview).toHaveBeenCalledTimes(c.reloads); expect(trackStudioEvent).toHaveBeenCalledWith( "gsap_soft_reload_outcome", expect.objectContaining({ origin: "preview_sync", - result: "verify-failed", - escalated: false, + result: c.outcome, + escalated: c.escalated, }), ); }); - it('instantPatch + patch fails + soft reload "cannot-soft-reload": escalates to full reload', () => { - patchRuntimeTweenInPlace.mockReturnValue(false); - applySoftReload.mockReturnValue("cannot-soft-reload"); + it("reloads the preview when the result carries no written file to restore from", () => { const reloadPreview = vi.fn(); - - syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview); - - // Structural failure: the preview is genuinely stale/broken → full reload. - expectSoftReloadedWith(reloadPreview, undefined); - expect(reloadPreview).toHaveBeenCalledTimes(1); - expect(trackStudioEvent).toHaveBeenCalledWith( - "gsap_soft_reload_outcome", - expect.objectContaining({ - origin: "preview_sync", - result: "cannot-soft-reload", - escalated: true, - }), + applyPreviewSync( + FAKE_IFRAME, + result({ scriptText: "SCRIPT", after: undefined }), + { label: "drag", softReload: true }, + reloadPreview, ); + expect(applySoftReload).not.toHaveBeenCalled(); + expect(reloadPreview).toHaveBeenCalledTimes(1); }); it("no instantPatch + softReload + scriptText: soft reloads, passing onAsyncFailure", () => { @@ -298,14 +292,17 @@ describe("applyPreviewSync", () => { ); expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled(); - expectSoftReloadedWith(reloadPreview, undefined); + expectSoftReloadedWith(reloadPreview, "AFTER"); expect(reloadPreview).not.toHaveBeenCalled(); // "applied" emits no telemetry (only the failure paths do). expect(trackStudioEvent).not.toHaveBeenCalled(); }); - it('no instantPatch + softReload "verify-failed": transient, does NOT escalate (U4)', () => { - applySoftReload.mockReturnValue("verify-failed"); + it.each([ + { outcome: "verify-failed", reloads: 0, escalated: false }, + { outcome: "cannot-soft-reload", reloads: 1, escalated: true }, + ])("no instantPatch + softReload $outcome: $reloads full reload(s)", (c) => { + applySoftReload.mockReturnValue(c.outcome); const reloadPreview = vi.fn(); applyPreviewSync( @@ -315,31 +312,11 @@ describe("applyPreviewSync", () => { reloadPreview, ); - // onAsyncFailure is wired, but the transient result does not trigger it. - expectSoftReloadedWith(reloadPreview, undefined); - expect(reloadPreview).not.toHaveBeenCalled(); - expect(trackStudioEvent).toHaveBeenCalledWith( - "gsap_soft_reload_outcome", - expect.objectContaining({ result: "verify-failed", escalated: false }), - ); - }); - - it('no instantPatch + softReload "cannot-soft-reload": escalates to full reload', () => { - applySoftReload.mockReturnValue("cannot-soft-reload"); - const reloadPreview = vi.fn(); - - applyPreviewSync( - FAKE_IFRAME, - result({ scriptText: "SCRIPT" }), - { label: "x", softReload: true }, - reloadPreview, - ); - - expectSoftReloadedWith(reloadPreview, undefined); - expect(reloadPreview).toHaveBeenCalledTimes(1); + expectSoftReloadedWith(reloadPreview, "AFTER"); + expect(reloadPreview).toHaveBeenCalledTimes(c.reloads); expect(trackStudioEvent).toHaveBeenCalledWith( "gsap_soft_reload_outcome", - expect.objectContaining({ result: "cannot-soft-reload", escalated: true }), + expect.objectContaining({ result: c.outcome, escalated: c.escalated }), ); }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index efdad24d3a5..c10b5f76169 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -217,7 +217,7 @@ function softReloadOrEscalate( scriptText: string, reloadPreview: () => void, origin: "preview_sync" | "sdk_refresh", - authoredHtml?: string, + authoredHtml: string, ): void { // Seek the rebuilt timeline to the studio's own authoritative scrub position, // not the iframe's raw `__player.getTime()` — see the comment in @@ -283,20 +283,14 @@ export function applyPreviewSync( } if (options.deferPreviewSync && !needsFallback) return; if (options.previewFallbackLatch) options.previewFallbackLatch.pending = false; - if (options.softReload && result.scriptText) { + if (options.softReload && result.scriptText && result.after) { // A soft-reloadable edit escalates to a full iframe remount ONLY on the // PERMANENT "cannot-soft-reload" result (the preview is genuinely stale/ // broken). The TRANSIENT "verify-failed" does NOT escalate — the value is // already correct on screen, and a remount re-flashes the WebGL context AND // re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin // load failure escalates separately via `onAsyncFailure`. - softReloadOrEscalate( - iframe, - result.scriptText, - reloadPreview, - "preview_sync", - result.after ?? undefined, - ); + softReloadOrEscalate(iframe, result.scriptText, reloadPreview, "preview_sync", result.after); } else { reloadPreview(); } diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts index c2723da5b4c..5817e876c56 100644 --- a/packages/studio/src/utils/gsapSoftReload.test.ts +++ b/packages/studio/src/utils/gsapSoftReload.test.ts @@ -7,6 +7,8 @@ import { ensureMotionPathPluginLoaded, } from "./gsapSoftReload"; +const FILE = { authoredHtml: "" }; + const SCRIPT_TEXT = ` window.__timelines = window.__timelines || {}; const tl = gsap.timeline({ paused: true }); @@ -71,33 +73,33 @@ function buildMockIframe(overrides: Record = {}) { describe("applySoftReload", () => { it('returns "cannot-soft-reload" when iframe is null', () => { - expect(applySoftReload(null, SCRIPT_TEXT)).toBe("cannot-soft-reload"); + expect(applySoftReload(null, SCRIPT_TEXT, FILE)).toBe("cannot-soft-reload"); }); it('returns "cannot-soft-reload" when scriptText is empty', () => { const { iframe } = buildMockIframe(); - expect(applySoftReload(iframe, "")).toBe("cannot-soft-reload"); + expect(applySoftReload(iframe, "", FILE)).toBe("cannot-soft-reload"); }); it('returns "cannot-soft-reload" when gsap is not on iframe window', () => { const { iframe } = buildMockIframe({ gsap: undefined }); - expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload"); + expect(applySoftReload(iframe, SCRIPT_TEXT, FILE)).toBe("cannot-soft-reload"); }); it('returns "cannot-soft-reload" when __hfForceTimelineRebind is missing', () => { const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined }); - expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload"); + expect(applySoftReload(iframe, SCRIPT_TEXT, FILE)).toBe("cannot-soft-reload"); }); it('returns "cannot-soft-reload" when the script registers no scopable key', () => { // No __timelines["key"] pattern → targetKeys is empty → can't scope safely. const { iframe } = buildMockIframe(); - expect(applySoftReload(iframe, 'gsap.to("#box", { x: 1 });')).toBe("cannot-soft-reload"); + expect(applySoftReload(iframe, 'gsap.to("#box", { x: 1 });', FILE)).toBe("cannot-soft-reload"); }); it("kills existing timelines, rebinds, and re-seeks on success", () => { const { iframe, contentWindow, mockTimeline } = buildMockIframe(); - const result = applySoftReload(iframe, SCRIPT_TEXT); + const result = applySoftReload(iframe, SCRIPT_TEXT, FILE); expect(result).toBe("applied"); expect(mockTimeline.kill).toHaveBeenCalled(); expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled(); @@ -112,13 +114,13 @@ describe("applySoftReload", () => { // async commit resolves. The rebuilt timeline must re-seek to the caller's // value, not the iframe's possibly-stale one. const { iframe, contentWindow } = buildMockIframe(); - const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 }); + const result = applySoftReload(iframe, SCRIPT_TEXT, { ...FILE, currentTimeOverride: 0 }); expect(result).toBe("applied"); expect(contentWindow.__player.seek).toHaveBeenCalledWith(0); }); describe("resets what GSAP parsed in the reloaded composition only", () => { - function reloadRoot(body: string) { + function reloadRoot(body: string, file = "") { const doc = document.implementation.createHTMLDocument(""); doc.body.innerHTML = `${body}`; for (const el of doc.querySelectorAll("[data-gsap]")) Object.assign(el, { _gsap: {} }); @@ -126,7 +128,7 @@ describe("applySoftReload", () => { const set = (targets: Element[]) => cleared.push(...targets); const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set } }); Object.assign(iframe, { contentDocument: doc }); - applySoftReload(iframe, SCRIPT_TEXT); + applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: `${file}` }); return { doc, cleared: cleared.map((el) => el.id) }; } @@ -140,11 +142,44 @@ describe("applySoftReload", () => { it("strips a stale inline transform from an orphaned (non-timeline-child) element", () => { const { doc } = reloadRoot( `
`, + `
`, ); expect(doc.getElementById("orphan")!.style.transform).toBe(""); expect(doc.getElementById("orphan")!.style.left).toBe("1240px"); }); + it("leaves what a nested composition's own timeline animates alone, in the preview's inlined markup", () => { + // The markup the preview bundler writes for an inlined sub-composition: no data-composition-src left. + const doc = document.implementation.createHTMLDocument(""); + doc.body.innerHTML = + `
` + + `
` + + `
` + + `
` + + `
`; + const [plain, nsty] = [doc.getElementById("plain")!, doc.getElementById("nsty")!]; + const subTween = { targets: () => [nsty] }; + const sub = { getChildren: () => [subTween] }; + const own = { targets: () => [plain] }; + const root = { + kill: vi.fn(), + getChildren: (deep: boolean) => (deep ? [sub, subTween, own] : [sub, own]), + }; + const cleared: Element[] = []; + const { iframe } = buildMockIframe({ + gsap: { timeline: vi.fn(), set: (targets: Element[]) => cleared.push(...targets) }, + __timelines: { root, sub }, + }); + Object.assign(iframe, { contentDocument: doc }); + applySoftReload(iframe, SCRIPT_TEXT, { + authoredHtml: `
`, + }); + expect(cleared).toEqual([plain]); + expect(nsty.getAttribute("style")).toBe( + "left: 420px; background: #f0f040; transform: translate(0px, 30px)", + ); + }); + it("leaves a nested composition's element alone: its script is not re-run", () => { const { doc, cleared } = reloadRoot( `
` + @@ -159,8 +194,8 @@ describe("applySoftReload", () => { const { iframe } = buildMockIframe(); const doc = document.implementation.createHTMLDocument(""); Object.assign(iframe, { contentDocument: doc }); - expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload"); - expect(applySoftReload(iframe, SCRIPT_TEXT, { bootstrap: "added" })).not.toBe( + expect(applySoftReload(iframe, SCRIPT_TEXT, FILE)).toBe("cannot-soft-reload"); + expect(applySoftReload(iframe, SCRIPT_TEXT, { ...FILE, bootstrap: "added" })).not.toBe( "cannot-soft-reload", ); }); @@ -173,7 +208,7 @@ describe("applySoftReload", () => { return fn(); }, }); - const result = applySoftReload(iframe, SCRIPT_TEXT); + const result = applySoftReload(iframe, SCRIPT_TEXT, FILE); expect(result).toBe("applied"); expect(suppressionCalled).toBe(true); }); @@ -184,7 +219,7 @@ describe("applySoftReload", () => { // expected target key is present (not merely "some key"), so a correct re-run // reliably reports "applied" — it doesn't spuriously hit the transient window. const { iframe, contentWindow } = buildMockIframe(); - expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied"); + expect(applySoftReload(iframe, SCRIPT_TEXT, FILE)).toBe("applied"); expect(contentWindow.__timelines.root).toBeDefined(); }); @@ -204,7 +239,7 @@ describe("applySoftReload", () => { body: container, head: document.createElement("div"), }; - expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("verify-failed"); + expect(applySoftReload(iframe, SCRIPT_TEXT, FILE)).toBe("verify-failed"); }); it("editing composition A leaves composition B's timeline intact (scoped kill)", () => { @@ -220,7 +255,7 @@ describe("applySoftReload", () => { }); void mockTimeline; - expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied"); + expect(applySoftReload(iframe, SCRIPT_TEXT, FILE)).toBe("applied"); // Comp B was never killed and is still registered. expect(subsceneTimeline.kill).not.toHaveBeenCalled(); expect(contentWindow.__timelines.subscene).toBe(subsceneTimeline); @@ -242,7 +277,7 @@ describe("applySoftReload", () => { const { iframe, contentWindow } = buildMockIframe({ MotionPathPlugin: {} }); (iframe.contentDocument as unknown as { head: unknown }).head = head; - const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT); + const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, FILE); expect(result).toBe("applied"); // No CDN plugin `; + const [host, inner] = ["hf-host", "hf-in"].map( + (id) => doc.querySelector(`[data-hf-id="${id}"]`) as HTMLElement, + ); + const { iframe } = buildMockIframe({ + gsap: { timeline: vi.fn(), set: vi.fn() }, + __timelines: { + sub: { kill: vi.fn(), getChildren: () => [{ targets: () => [host, inner] }] }, + }, + }); + Object.assign(iframe, { contentDocument: doc }); + const sub = ``; + applySoftReload(iframe, SCRIPT_TEXT.replaceAll('"root"', '"sub"'), { authoredHtml: sub }); + expect([host.style.left, host.style.transform]).toEqual(["300px", ""]); + expect([inner.style.width, inner.style.height]).toEqual(["1920px", "400px"]); }); - it("restores the file's inline translate, rotate and scale and drops GSAP's transform", () => { - const el = document.createElement("div"); - el.setAttribute("data-hf-id", "hf-1"); - el.style.cssText = - "left: 10px; translate: none; rotate: none; scale: none; transform: translate(9px, 9px)"; + it("keeps soft reloads for a script-made node unlike any plain template's content", () => { + const el = document.createElement("span"); + el.setAttribute("style", "opacity: 0.3"); const { iframe } = buildIframeWithTarget(el); - const authoredHtml = `
`; + const authoredHtml = page(`
`); expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied"); - const read = (prop: string) => el.style.getPropertyValue(prop); - expect(["translate", "rotate", "scale", "transform"].map(read)).toEqual([ - "60px 40px", - "15deg", - "1.5", - "", - ]); + expect(el.hasAttribute("style")).toBe(false); }); - it("keeps an SVG child's authored inline transform", () => { - const el = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - el.setAttribute("data-hf-id", "hf-1"); - el.setAttribute("style", "transform: translate(9px, 9px) rotate(20deg)"); + it("keeps a nested composition's element's inline style, minus GSAP's transform", () => { + const host = document.createElement("div"); + host.setAttribute("data-composition-file", "compositions/sub.html"); // as the preview inlines it + const el = document.createElement("img"); + el.setAttribute("data-hf-id", "hf-from-sub"); + el.setAttribute("data-hf-authored-opacity", "0.5"); + el.setAttribute("style", "left: 10px; opacity: 0; transform: translate(9px, 9px)"); + host.appendChild(el); const { iframe } = buildIframeWithTarget(el); - const authoredHtml = ``; - applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml }); - expect(el.style.transform).toBe("rotate(20deg)"); + applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: page(`
`) }); + expect([el.style.left, el.style.opacity, el.style.transform]).toEqual(["10px", "0.5", ""]); }); it("the finalize seek cannot paint the killed timeline over the restored transform", () => { @@ -597,7 +627,7 @@ describe("applySoftReload authored-style restore", () => { __player: { getTime: () => 1, seek }, __hfForceTimelineRebind: () => (transformAtRebind = el.style.transform), }); - applySoftReload(iframe, SCRIPT_TEXT); + applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: page("") }); expect(transformAtRebind).toBe(""); }); }); diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 2a900c7c4df..de8bcd99a21 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -1,4 +1,5 @@ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading"; +import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids"; import { findAuthoredElement, parseSavedSource } from "./authoredSource"; import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity"; @@ -131,22 +132,116 @@ 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( - style: CSSStyleDeclaration, - authored: CSSStyleDeclaration | null, -) { - style.transform = authored?.transform ?? ""; - if (!authored) return; - for (const prop of ["translate", "rotate", "scale"]) { - style.setProperty(prop, authored.getPropertyValue(prop)); - } +// The reloaded file, and which composition file it is (null for the top-level one). +type Fresh = { file: Document; own: string | null }; + +// What a fresh load puts in the style attribute; undefined inside an inlined composition, which the bundler rewrites. +function freshInlineStyle({ file, own }: Fresh, el: Element): string | null | undefined { + if (own !== null || sourceFile(el.parentElement) !== null) return undefined; + return findAuthoredElement(file, el)?.getAttribute("style") ?? null; } -function gsapParsedInOwnComposition(doc: Document, key: string): Element[] { - const comp = [...doc.querySelectorAll("[data-composition-id]")].find( +// The preview marks an inlined composition with data-composition-file; the runtime loader keeps -src. +function sourceFile(el: Element | null | undefined): string | null { + const host = el?.closest("[data-composition-file], [data-composition-src]"); + return ( + host?.getAttribute("data-composition-file") ?? + host?.getAttribute("data-composition-src") ?? + null + ); +} + +function compositionRoot(doc: Document, key: string): Element | undefined { + return [...doc.querySelectorAll("[data-composition-id]")].find( (el) => el.getAttribute("data-composition-id") === key, ); +} + +function restoreNestedInline(el: HTMLElement, css: string): void { + const s = el.style; + s.cssText = css; + s.transform = ""; + const authored = readStampedAuthoredOpacity(el); + if (authored !== null) { + applyAuthoredInlineOpacity(s, authored); + } else if ( + el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) && + s.getPropertyValue("opacity") === "0" && + s.getPropertyPriority("opacity") === "important" + ) { + s.removeProperty("opacity"); + } +} + +// A script's clone of a plain template's element has no counterpart in the file; only a full load rebuilds it. +function mayBeTemplateClone(fresh: Fresh, el: Element): boolean { + if (freshInlineStyle(fresh, el) !== null) return false; + return [...fresh.file.querySelectorAll("template")].some( + (t) => + !isCompositionTemplate(t) && + [...t.content.querySelectorAll("*")].some( + (c) => c.tagName === el.tagName && [...c.classList].every((k) => el.classList.contains(k)), + ), + ); +} + +type TimelineLike = { + kill?: () => void; + clear?: () => void; + getChildren?: (deep: boolean) => Array Element[] }>; +}; + +function resetToFile(win: IframeWindow, fresh: Fresh, targets: Element[]): void { + if (targets.length === 0 || !win.gsap?.set) return; + const saved = targets.map( + (el) => [el as HTMLElement, (el as HTMLElement).style?.cssText] as const, + ); + try { + win.gsap.set(targets, { clearProps: "all" }); + } catch {} + for (const [el, css] of saved) { + const style = freshInlineStyle(fresh, el); + if (style === undefined) restoreNestedInline(el, css ?? ""); + else if (style === null) el.removeAttribute("style"); + else el.setAttribute("style", style); + } +} + +// A nested composition's timeline belongs to its own script, which this re-run neither rebuilds nor resets. +function ownTweenTargets(tl: TimelineLike | undefined, nested: Set): Element[] { + return (tl?.getChildren?.(false) ?? []).flatMap((child) => { + if (nested.has(child)) return []; + return child.getChildren ? ownTweenTargets(child, nested) : (child.targets?.() ?? []); + }); +} + +function timelineTargets(win: IframeWindow, key: string): Element[] { + const timelines: Record = win.__timelines ?? {}; + const nested = new Set(Object.keys(timelines).flatMap((k) => (k === key ? [] : [timelines[k]]))); + try { + return ownTweenTargets(timelines[key] as TimelineLike | undefined, nested); + } catch { + return []; + } +} + +function collectTargets(win: IframeWindow, doc: Document, keys: string[]): Element[] { + const targets = keys.flatMap((key) => [ + ...timelineTargets(win, key), + ...gsapParsedInOwnComposition(doc, key), + ]); + return [...new Set(targets)]; +} + +function planReset(win: IframeWindow, doc: Document, keys: string[], authoredHtml: string) { + const own = sourceFile(keys.map((key) => compositionRoot(doc, key)).find(Boolean)); + const fresh: Fresh = { file: parseSavedSource(authoredHtml), own }; + const targets = collectTargets(win, doc, keys); + return targets.some((el) => mayBeTemplateClone(fresh, el)) ? null : { fresh, targets }; +} + +function gsapParsedInOwnComposition(doc: Document, key: string): Element[] { + const comp = compositionRoot(doc, key); if (!comp) return []; return [comp, ...comp.querySelectorAll("*")].filter( (el) => @@ -203,8 +298,8 @@ export interface SoftReloadOptions { onAsyncFailure?: () => void; /** Seek target for the rebuilt timeline; defaults to the iframe player time. */ currentTimeOverride?: number; - /** After-write file HTML — the primary source for the authored opacity and transform restore. */ - authoredHtml?: string; + /** The file as just written: every reset element gets its inline style back from it. */ + authoredHtml: string; /** A first edit's GSAP bootstrap: "added" may run with no live script, "removed" tears down and runs nothing. */ bootstrap?: "added" | "removed"; } @@ -258,20 +353,7 @@ export function applySoftReloadFinalization( } } -export function applySoftReload( - iframe: HTMLIFrameElement | null, - scriptText: string, - options: SoftReloadOptions = {}, -): SoftReloadResult { - const { onAsyncFailure, currentTimeOverride, authoredHtml, bootstrap } = options; - const removeScript = bootstrap === "removed"; - if (!iframe || !scriptText) return "cannot-soft-reload"; - - const win = iframe.contentWindow as IframeWindow | null; - const doc = iframe.contentDocument; - if (!win || !doc) return "cannot-soft-reload"; - if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload"; - +function scopeScript(doc: Document, scriptText: string, bootstrap: SoftReloadOptions["bootstrap"]) { // 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]. // In a multi-composition preview (top-level + inlined subcompositions) each @@ -282,9 +364,9 @@ export function applySoftReload( const targetKeys = [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)] .map((m) => m[1]!) .filter((key) => key !== "__proxied"); - if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload + if (targetKeys.length === 0) return null; // can't scope safely → full reload const gsapScripts = findGsapScriptElements(doc); - if (gsapScripts.length === 0 && bootstrap !== "added") return "cannot-soft-reload"; + if (gsapScripts.length === 0 && bootstrap !== "added") return null; // Remove only the stale script element(s) that registered a target key; one we // can't match in the doc is left alone (re-running appends a fresh element). const staleScripts = gsapScripts.filter((script) => @@ -297,7 +379,27 @@ export function applySoftReload( // can't identify which element to replace (ambiguous, matching // extractGsapScriptText's single-script requirement). Escalate to a full reload // rather than killing the target timeline and appending an orphan script. - if (gsapScripts.length > 1 && staleScripts.length === 0) return "cannot-soft-reload"; + if (gsapScripts.length > 1 && staleScripts.length === 0) return null; + return { targetKeys, staleScripts }; +} + +export function applySoftReload( + iframe: HTMLIFrameElement | null, + scriptText: string, + options: SoftReloadOptions, +): SoftReloadResult { + const { onAsyncFailure, currentTimeOverride, authoredHtml, bootstrap } = options; + const removeScript = bootstrap === "removed"; + if (!iframe || !scriptText) return "cannot-soft-reload"; + + const win = iframe.contentWindow as IframeWindow | null; + const doc = iframe.contentDocument; + if (!win || !doc) return "cannot-soft-reload"; + if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload"; + + const scope = scopeScript(doc, scriptText, bootstrap); + if (!scope) return "cannot-soft-reload"; + const { targetKeys, staleScripts } = scope; // Prefer the caller-supplied scrub position (the studio's own authoritative // currentTime, e.g. usePlayerStore) over the iframe's raw `__player.getTime()`: @@ -315,56 +417,20 @@ export function applySoftReload( // full iframe reload that destroys the very WebGL context we're preserving. let deferredToAsync = false; - // Authored-opacity resolution for the restore loop below. Three-state: - // "0.98" — the element's authored inline opacity - // "" — resolved, and the element has NO authored inline opacity - // null — unknown (no authored HTML supplied, element not found in it, - // and no runtime parse-time stamp) - // 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 findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => { - if (authoredDoc === undefined) { - try { - authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null; - } catch { - authoredDoc = null; - } - } - const source = authoredDoc ? findAuthoredElement(authoredDoc, 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; - }; - const readAuthoredOpacity = (el: HTMLElement): string | null => - findAuthoredStyle(el)?.opacity ?? readStampedAuthoredOpacity(el); + const reset = planReset(win, doc, targetKeys, authoredHtml); + if (!reset) return "cannot-soft-reload"; + const { fresh, targets } = reset; // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; - const allTargets: Element[] = []; // 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 TimelineLike | 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?.(); @@ -374,54 +440,7 @@ export function applySoftReload( } } - const seenTargets = new Set(allTargets); - for (const el of targetKeys.flatMap((key) => gsapParsedInOwnComposition(doc, key))) { - if (!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. - if (allTargets.length > 0 && win.gsap?.set) { - const saved: Array<[HTMLElement, string]> = []; - for (const el of allTargets) { - // Iframe-realm node: instanceof HTMLElement fails across realms, and - // gsap targets() only yields elements here — style access is duck-typed. - const styled = el as HTMLElement; - if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]); - } - try { - win.gsap.set(allTargets, { clearProps: "all" }); - } catch {} - for (const [el, css] of saved) { - const s = el.style; - s.cssText = css; - restoreAuthoredTransforms(s, findAuthoredStyle(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 - // against it — a from() captures it as its END, a to() as its START — - // turning the transient into the tween's permanent bound (dimmed or - // invisible elements). Put the AUTHORED inline opacity back; the seek - // below re-renders the correct animated value either way. - const authored = readAuthoredOpacity(el); - if (authored !== null) { - applyAuthoredInlineOpacity(s, authored); - } else if ( - el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) && - s.getPropertyValue("opacity") === "0" && - s.getPropertyPriority("opacity") === "important" - ) { - // Authored value unknown, but this is definitely the grading hide — - // never let a from() capture 0; fall back to the CSS cascade. - s.removeProperty("opacity"); - } - } - } + resetToFile(win, fresh, targets); for (const script of staleScripts) script.remove(); if (removeScript) {