From 23e9d924f4aee5a113228ad445206e6fcb8a5d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 30 Sep 2026 10:17:34 -0400 Subject: [PATCH 1/2] fix(studio): undo and soft reloads restore each element's inline style from the file --- .../studio/src/hooks/timelineTimingSync.ts | 3 +- .../src/hooks/useGsapScriptCommits.test.tsx | 85 +++---- .../studio/src/hooks/useGsapScriptCommits.ts | 12 +- .../studio/src/utils/gsapSoftReload.test.ts | 172 ++++++-------- packages/studio/src/utils/gsapSoftReload.ts | 219 ++++++++---------- 5 files changed, 208 insertions(+), 283 deletions(-) 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..f4e15b0b49e 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,6 +142,7 @@ 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"); @@ -159,8 +162,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 +176,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 +187,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 +207,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 +223,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 +245,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 [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( `
` + @@ -532,6 +564,38 @@ describe("applySoftReload restores each element's inline style from the file", ( expect(el.getAttribute("style")).toBe("opacity: 0.3"); }); + it("a nested composition's reload keeps what the parent file and the bundler wrote", () => { + const doc = document.implementation.createHTMLDocument(""); + doc.body.innerHTML = + `
` + + `
` + + `
` + + ``; + 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("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 = page(`
`); + expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied"); + expect(el.hasAttribute("style")).toBe(false); + }); + 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 diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 45878dc923c..de8bcd99a21 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -132,13 +132,29 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0; } -// What a fresh load of `file` puts in the style attribute; undefined for a nested composition's element. -function freshInlineStyle(file: Document, el: Element): string | null | undefined { - const source = findAuthoredElement(file, el); - if (source) return source.getAttribute("style"); - if (el.parentElement?.closest("[data-composition-file], [data-composition-src]")) - return undefined; - return null; +// 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; +} + +// 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 { @@ -157,18 +173,25 @@ function restoreNestedInline(el: HTMLElement, css: string): void { } } -function mayBeTemplateClone(file: Document, el: Element): boolean { - if (freshInlineStyle(file, el) !== null) return false; - return [...file.querySelectorAll("template")].some((t) => !isCompositionTemplate(t)); +// 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<{ targets?: () => Element[] }>; + getChildren?: (deep: boolean) => Array Element[] }>; }; -function resetToFile(win: IframeWindow, file: Document, targets: Element[]): void { +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, @@ -177,16 +200,26 @@ function resetToFile(win: IframeWindow, file: Document, targets: Element[]): voi win.gsap.set(targets, { clearProps: "all" }); } catch {} for (const [el, css] of saved) { - const fresh = freshInlineStyle(file, el); - if (fresh === undefined) restoreNestedInline(el, css ?? ""); - else if (fresh === null) el.removeAttribute("style"); - else el.setAttribute("style", fresh); + const style = freshInlineStyle(fresh, el); + if (style === undefined) restoreNestedInline(el, css ?? ""); + else if (style === null) el.removeAttribute("style"); + else el.setAttribute("style", style); } } -function timelineTargets(tl: TimelineLike | undefined): Element[] { +// 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 (tl?.getChildren?.(true) ?? []).flatMap((child) => child.targets?.() ?? []); + return ownTweenTargets(timelines[key] as TimelineLike | undefined, nested); } catch { return []; } @@ -194,22 +227,21 @@ function timelineTargets(tl: TimelineLike | undefined): Element[] { function collectTargets(win: IframeWindow, doc: Document, keys: string[]): Element[] { const targets = keys.flatMap((key) => [ - ...timelineTargets(win.__timelines?.[key] as TimelineLike | undefined), + ...timelineTargets(win, key), ...gsapParsedInOwnComposition(doc, key), ]); return [...new Set(targets)]; } function planReset(win: IframeWindow, doc: Document, keys: string[], authoredHtml: string) { - const file = parseSavedSource(authoredHtml); + 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(file, el)) ? null : { file, targets }; + return targets.some((el) => mayBeTemplateClone(fresh, el)) ? null : { fresh, targets }; } function gsapParsedInOwnComposition(doc: Document, key: string): Element[] { - const comp = [...doc.querySelectorAll("[data-composition-id]")].find( - (el) => el.getAttribute("data-composition-id") === key, - ); + const comp = compositionRoot(doc, key); if (!comp) return []; return [comp, ...comp.querySelectorAll("*")].filter( (el) => @@ -387,7 +419,7 @@ export function applySoftReload( const reset = planReset(win, doc, targetKeys, authoredHtml); if (!reset) return "cannot-soft-reload"; - const { file, targets } = reset; + const { fresh, targets } = reset; // fallow-ignore-next-line complexity const doReload = () => { @@ -408,7 +440,7 @@ export function applySoftReload( } } - resetToFile(win, file, targets); + resetToFile(win, fresh, targets); for (const script of staleScripts) script.remove(); if (removeScript) {