From 89d90841cc54ff7edd8eb2cac6c179c42509317a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 01:52:01 -0400 Subject: [PATCH 01/14] fix(studio): a size edit right after a resize is its own undo step --- .../src/hooks/useDomGeometryCommits.test.tsx | 21 +++++++++++++++++++ .../studio/src/hooks/useDomGeometryCommits.ts | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/hooks/useDomGeometryCommits.test.tsx b/packages/studio/src/hooks/useDomGeometryCommits.test.tsx index 4e0af6d849e..7ccaf983e09 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.test.tsx +++ b/packages/studio/src/hooks/useDomGeometryCommits.test.tsx @@ -376,6 +376,27 @@ describe("Reset after a plain move puts the author's translate back", () => { }); }); +describe("useDomGeometryCommits undo steps", () => { + it("never merges a resize with the next size edit of the same element", async () => { + const element = document.createElement("div"); + element.id = "box"; + document.body.append(element); + const selection = { id: "box", selector: "#box", element } as unknown as DomEditSelection; + const commitPositionPatchToHtml = vi + .fn() + .mockResolvedValue(undefined); + const { commits, unmount } = mountCommits(commitPositionPatchToHtml); + + await commits().handleDomBoxSizeCommit(selection, { width: 418, height: 200 }); + await commits().handleDomBoxSizeCommit(selection, { width: 280, height: 200 }); + + const [resize, field] = commitPositionPatchToHtml.mock.calls.map(([, , options]) => options); + expect(resize!.coalesceKey).not.toBe(field!.coalesceKey); + unmount(); + element.remove(); + }); +}); + describe("useDomGeometryCommits resize of a cropped element", () => { it("saves the crop scaled per axis with the box in the resize's own commit", async () => { const element = withInlineLayoutBox(document.createElement("div")); diff --git a/packages/studio/src/hooks/useDomGeometryCommits.ts b/packages/studio/src/hooks/useDomGeometryCommits.ts index 8c133fb3f00..a37a0cd8ea7 100644 --- a/packages/studio/src/hooks/useDomGeometryCommits.ts +++ b/packages/studio/src/hooks/useDomGeometryCommits.ts @@ -26,6 +26,8 @@ import { } from "../components/editor/manualEditsDomPatches"; import type { PatchOperation } from "../utils/sourcePatcher"; +let boxSizeCommitCounter = 0; + // ── Hook ── export interface UseDomGeometryCommitsParams { @@ -90,7 +92,10 @@ export function useDomGeometryCommits({ label: "Resize layer box", ...(undoKey ? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true } - : { coalesceKey: `box-size:${getDomEditTargetKey(selection)}` }), + : { + coalesceKey: `box-size:${++boxSizeCommitCounter}`, + coalesceMs: Number.POSITIVE_INFINITY, + }), }).catch((error) => { restoreStudioBoxSize(element, beforeSize); if (offset) restoreStudioPathOffset(element, beforeOffset); From 4efa79049fdbfe0c33af468379ee9a1476cb2d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 05:11:52 -0400 Subject: [PATCH 02/14] fix(studio): a nudge still saving is recorded before the next edit --- .../editor/useDomEditNudge.test.tsx | 33 ++++++++++++++++++- .../src/utils/studioPendingEdits.test.ts | 13 ++++++++ .../studio/src/utils/studioPendingEdits.ts | 10 ++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index c2c467261d0..beebc829a53 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -7,7 +7,11 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge"; import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge"; import { __resetForTests } from "../../utils/canvasNudgeGate"; -import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits"; +import { + flushStudioPendingEdits, + hasStudioPendingEdits, + trackedStudioEdit, +} from "../../utils/studioPendingEdits"; import type { DomEditSelection } from "./domEditing"; import type { OverlayRect } from "./domEditOverlayGeometry"; @@ -452,6 +456,33 @@ describe("useDomEditNudge — undo right after a burst", () => { }); }); +describe("useDomEditNudge — a Design-panel edit during a burst", () => { + it("commits the burst before the panel edit, so one undo takes back the panel edit", () => { + __resetForTests(); + vi.useFakeTimers(); + const root = createRoot(document.body.appendChild(document.createElement("div"))); + const element = document.body.appendChild(document.createElement("div")); + element.id = "dot-panel"; + const order: string[] = []; + act(() => { + root.render( + React.createElement(Harness, { + selection: makeSelection("Dot", element), + onPathOffsetCommit: async () => void order.push("nudge"), + }), + ); + }); + try { + act(() => dispatchArrowRight()); + trackedStudioEdit(async () => void order.push("width"))(); + expect(order).toEqual(["nudge", "width"]); + } finally { + vi.useRealTimers(); + act(() => root.unmount()); + } + }); +}); + describe("useDomEditNudge — a commit that throws", () => { it("still ends the burst's pending edit, so undo and export never wait on it", async () => { __resetForTests(); diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 306fe2d0fcd..67c5ac2f601 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -199,6 +199,19 @@ describe("a drain that meets a conflict", () => { }); }); +describe("a new edit after a debounced one", () => { + it("commits the debounced edit first, so history records them in the order they were made", () => { + const order: string[] = []; + const remove = addStudioPendingEditFlushListener(() => void order.push("nudge")); + trackedStudioEdit(() => void order.push("panel"))(); + const drag = beginStudioPendingEdit(null); + drag.adopt(() => void order.push("drag")); + drag.settle(); + remove(); + expect(order).toEqual(["nudge", "panel", "nudge", "drag"]); + }); +}); + describe("trackedStudioEdit", () => { it("counts each call as a pending edit until it settles, and reports a failure to the drain", async () => { let fail!: () => void; diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 58ed78710db..eedcaa4acca 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -107,10 +107,19 @@ export function trackStudioPendingEdit( return promise; } +function commitOlderDebouncedEdits(): void { + if (typeof window === "undefined") return; + const detail: StudioFlushPendingEditsDetail = { promises: [] }; + window.dispatchEvent( + new CustomEvent(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }), + ); +} + export function trackedStudioEdit( edit: (...args: Args) => R, ): (...args: Args) => R { return (...args) => { + commitOlderDebouncedEdits(); const result = edit(...args); if (result instanceof Promise) trackStudioPendingEdit(result); return result; @@ -118,6 +127,7 @@ export function trackedStudioEdit( } export function beginStudioPendingEdit(revert: StudioEditRevert | null) { + commitOlderDebouncedEdits(); let settle!: (saved?: Promise) => void; const promise = trackStudioPendingEdit(new Promise((resolve) => (settle = resolve)))!; const entry = pendingEdits.get(promise)!; From 8e1cdae640dc60a794fe05da83ddcd15c96a0441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 02:52:21 -0400 Subject: [PATCH 03/14] fix(studio): published types name cn by its package, not a node_modules path --- packages/studio/src/components/ui/cn.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/ui/cn.ts b/packages/studio/src/components/ui/cn.ts index 7cb71e3f1ee..cfd41e29ea7 100644 --- a/packages/studio/src/components/ui/cn.ts +++ b/packages/studio/src/components/ui/cn.ts @@ -3,6 +3,7 @@ * invents (`text-step-*`, `ctl`/`ctl-sm`/`ctl-lg`) so overrides replace, not stack. */ +import type { CnFunction } from "cn"; import { createCn } from "cn/config"; /** `text-step-11`, `text-step-9`, … — the type scale from `theme.css`. */ @@ -11,7 +12,7 @@ const isTypeStep = (value: string) => /^step-\d+$/.test(value); /** `ctl`, `ctl-sm`, `ctl-lg` — the control heights from `theme.css`. */ const isControlSize = (value: string) => /^ctl(-sm|-lg)?$/.test(value); -export const cn = createCn({ +export const cn: CnFunction = createCn({ extend: { classGroups: { "font-size": [{ text: [isTypeStep] }], From 98e46bfac75b221c6cfc5a3b4a19cb9b4ca51969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 03:54:59 -0400 Subject: [PATCH 04/14] fix(studio): drop baseUrl so declarations never name a node_modules path --- packages/studio/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json index a90d11223e0..8af20cf3f57 100644 --- a/packages/studio/tsconfig.json +++ b/packages/studio/tsconfig.json @@ -3,7 +3,6 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "baseUrl": ".", "paths": { "@hyperframes/player": ["../player/src/hyperframes-player.ts"] }, From 72492fd2c0ae1441b3c672ec4857875ec0cefc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 06:07:41 -0400 Subject: [PATCH 05/14] fix(studio): an edit started inside the save of another edit never flushes --- packages/studio/src/utils/studioPendingEdits.test.ts | 9 +++++++++ packages/studio/src/utils/studioPendingEdits.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 67c5ac2f601..6a374eee86d 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -210,6 +210,15 @@ describe("a new edit after a debounced one", () => { remove(); expect(order).toEqual(["nudge", "panel", "nudge", "drag"]); }); + it("never flushes from inside an edit's own save, where a flushed save would go untracked", () => { + let flushes = 0; + const remove = addStudioPendingEditFlushListener(() => void (flushes += 1)); + const drag = beginStudioPendingEdit(null); + drag.adopt(() => trackedStudioEdit(() => undefined)()); + drag.settle(); + remove(); + expect(flushes).toBe(1); + }); }); describe("trackedStudioEdit", () => { diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index eedcaa4acca..a2ea2170cee 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -108,7 +108,7 @@ export function trackStudioPendingEdit( } function commitOlderDebouncedEdits(): void { - if (typeof window === "undefined") return; + if (typeof window === "undefined" || adopting) return; const detail: StudioFlushPendingEditsDetail = { promises: [] }; window.dispatchEvent( new CustomEvent(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }), From 50843fef5ed929ec917d84d72d5a2a0e4679fefc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 06:45:53 -0400 Subject: [PATCH 06/14] fix(studio): an edit made while a nudge is still saving writes after it --- .../components/editor/useDomEditNudge.test.tsx | 6 ++++-- .../studio/src/utils/studioPendingEdits.test.ts | 15 +++++++++++++++ packages/studio/src/utils/studioPendingEdits.ts | 9 ++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index beebc829a53..dc7ecd472c4 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -457,7 +457,7 @@ describe("useDomEditNudge — undo right after a burst", () => { }); describe("useDomEditNudge — a Design-panel edit during a burst", () => { - it("commits the burst before the panel edit, so one undo takes back the panel edit", () => { + it("commits the burst before the panel edit, so one undo takes back the panel edit", async () => { __resetForTests(); vi.useFakeTimers(); const root = createRoot(document.body.appendChild(document.createElement("div"))); @@ -474,7 +474,9 @@ describe("useDomEditNudge — a Design-panel edit during a burst", () => { }); try { act(() => dispatchArrowRight()); - trackedStudioEdit(async () => void order.push("width"))(); + const panelEdit = trackedStudioEdit(async () => void order.push("width"))(); + vi.useRealTimers(); + await panelEdit; expect(order).toEqual(["nudge", "width"]); } finally { vi.useRealTimers(); diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 6a374eee86d..1afbdb1dba7 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -210,6 +210,21 @@ describe("a new edit after a debounced one", () => { remove(); expect(order).toEqual(["nudge", "panel", "nudge", "drag"]); }); + it("starts a new edit only after a flushed save that was still waiting has written", async () => { + const order: string[] = []; + let fetched!: () => void; + // A nudge on an animated layer: its save waits on the animation fetch before it writes. + const remove = addStudioPendingEditFlushListener(async () => { + await new Promise((resolve) => (fetched = resolve)); + order.push("nudge write"); + }); + const panelEdit = trackedStudioEdit(async () => void order.push("width write"))(); + fetched(); + await panelEdit; + remove(); + expect(order).toEqual(["nudge write", "width write"]); + }); + it("never flushes from inside an edit's own save, where a flushed save would go untracked", () => { let flushes = 0; const remove = addStudioPendingEditFlushListener(() => void (flushes += 1)); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index a2ea2170cee..7b3396f7c62 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -107,19 +107,22 @@ export function trackStudioPendingEdit( return promise; } -function commitOlderDebouncedEdits(): void { - if (typeof window === "undefined" || adopting) return; +function commitOlderDebouncedEdits(): Promise | null { + if (typeof window === "undefined" || adopting) return null; const detail: StudioFlushPendingEditsDetail = { promises: [] }; window.dispatchEvent( new CustomEvent(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }), ); + return detail.promises.length ? Promise.allSettled(detail.promises) : null; } export function trackedStudioEdit( edit: (...args: Args) => R, ): (...args: Args) => R { return (...args) => { - commitOlderDebouncedEdits(); + const olderSavesStillWriting = commitOlderDebouncedEdits(); + if (olderSavesStillWriting) + return trackStudioPendingEdit(olderSavesStillWriting.then(() => edit(...args))) as R; const result = edit(...args); if (result instanceof Promise) trackStudioPendingEdit(result); return result; From 5e6e4d6b488008480de9709a41ecd71bcef1519d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 07:40:22 -0400 Subject: [PATCH 07/14] fix(studio): a later edit never overtakes one still waiting on a nudge save --- .../src/utils/studioPendingEdits.test.ts | 22 ++++++++++++++++ .../studio/src/utils/studioPendingEdits.ts | 25 ++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 1afbdb1dba7..900b405e35c 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -225,6 +225,28 @@ describe("a new edit after a debounced one", () => { expect(order).toEqual(["nudge write", "width write"]); }); + it("keeps every later edit behind one that waits on a flushed save, so the newest value lands last", async () => { + const order: string[] = []; + let fetched!: () => void; + let saving = false; + const remove = addStudioPendingEditFlushListener(() => { + if (saving) return undefined; + saving = true; + return new Promise((resolve) => (fetched = resolve)).then( + () => void order.push("nudge"), + ); + }); + const first = trackedStudioEdit(async () => void order.push("W 200"))(); + const second = trackedStudioEdit(async () => void order.push("W 300"))(); + const drag = beginStudioPendingEdit(null); + const dragged = drag.adopt(() => Promise.resolve().then(() => void order.push("resize"))); + fetched(); + await Promise.all([first, second, dragged]); + drag.settle(); + remove(); + expect(order).toEqual(["nudge", "W 200", "W 300", "resize"]); + }); + it("never flushes from inside an edit's own save, where a flushed save would go untracked", () => { let flushes = 0; const remove = addStudioPendingEditFlushListener(() => void (flushes += 1)); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 7b3396f7c62..276f8f9f9a3 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -116,13 +116,29 @@ function commitOlderDebouncedEdits(): Promise | null { return detail.promises.length ? Promise.allSettled(detail.promises) : null; } +let deferredTail: Promise | null = null; + +function runAfterOlderEdits(run: () => T, flush = true): Promise | null { + const waits = [flush ? commitOlderDebouncedEdits() : null, deferredTail].filter(Boolean); + if (!waits.length) return null; + const ran = Promise.allSettled(waits).then(run); + const settled: Promise = ran.then( + () => undefined, + () => undefined, + ); + deferredTail = settled; + void settled.then(() => { + if (deferredTail === settled) deferredTail = null; + }); + return ran; +} + export function trackedStudioEdit( edit: (...args: Args) => R, ): (...args: Args) => R { return (...args) => { - const olderSavesStillWriting = commitOlderDebouncedEdits(); - if (olderSavesStillWriting) - return trackStudioPendingEdit(olderSavesStillWriting.then(() => edit(...args))) as R; + const deferred = runAfterOlderEdits(() => edit(...args)); + if (deferred) return trackStudioPendingEdit(deferred) as R; const result = edit(...args); if (result instanceof Promise) trackStudioPendingEdit(result); return result; @@ -170,7 +186,8 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) { // Only what `start` registers synchronously is adopted; a later write joins only through `within`. adopt(start: () => T): T { try { - const committed = inFlight.within(start); + const deferred = runAfterOlderEdits(() => inFlight.within(start), false); + const committed = (deferred ?? inFlight.within(start)) as T; landed = Promise.resolve(committed).then( () => true, () => saved, From 01aacf0d8a97b2f64510ce11613b5335fbaa02dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 08:20:12 -0400 Subject: [PATCH 08/14] fix(studio): undoing a gsap.set clears what it applied from the preview --- .../editor/useDomEditNudge.test.tsx | 64 +++++++--------- .../studio/src/utils/gsapSoftReload.test.ts | 73 +++++++++++++++---- packages/studio/src/utils/gsapSoftReload.ts | 2 +- .../studio/src/utils/softReloadTargets.ts | 27 +++++++ 4 files changed, 112 insertions(+), 54 deletions(-) diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index dc7ecd472c4..e41dc6793c0 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { trackStudioEvent } from "../../utils/studioTelemetry"; // @vitest-environment happy-dom import React, { act } from "react"; @@ -396,23 +397,32 @@ describe("useDomEditNudge pauses playback before it snapshots the timelines", () }); }); +/** A burst-ready Harness on a fresh element, under fake timers. */ +function mountBurstHarness( + id: string, + onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"], +) { + __resetForTests(); + vi.useFakeTimers(); + const root = createRoot(document.body.appendChild(document.createElement("div"))); + const element = document.body.appendChild(document.createElement("div")); + element.id = id; + act(() => { + root.render( + React.createElement(Harness, { + selection: makeSelection("Dot", element), + onPathOffsetCommit, + }), + ); + }); + return root; +} + describe("useDomEditNudge — undo right after a burst", () => { it("undo's drain commits a burst still inside its debounce and waits for its save", async () => { - __resetForTests(); - vi.useFakeTimers(); - const root = createRoot(document.body.appendChild(document.createElement("div"))); - const element = document.body.appendChild(document.createElement("div")); - element.id = "dot-undo"; let saved!: () => void; const commit = vi.fn(() => new Promise((resolve) => (saved = resolve))); - act(() => { - root.render( - React.createElement(Harness, { - selection: makeSelection("Dot", element), - onPathOffsetCommit: commit, - }), - ); - }); + const root = mountBurstHarness("dot-undo", commit); act(() => dispatchArrowRight()); let drained = false; @@ -428,21 +438,9 @@ describe("useDomEditNudge — undo right after a burst", () => { }); it("counts the burst as a pending edit from its first key until its save lands", async () => { - __resetForTests(); - vi.useFakeTimers(); - const root = createRoot(document.body.appendChild(document.createElement("div"))); - const element = document.body.appendChild(document.createElement("div")); - element.id = "dot-pending"; let saved!: () => void; const commit = vi.fn(() => new Promise((resolve) => (saved = resolve))); - act(() => { - root.render( - React.createElement(Harness, { - selection: makeSelection("Dot", element), - onPathOffsetCommit: commit, - }), - ); - }); + const root = mountBurstHarness("dot-pending", commit); expect(hasStudioPendingEdits()).toBe(false); act(() => dispatchArrowRight()); expect(hasStudioPendingEdits()).toBe(true); @@ -458,20 +456,8 @@ describe("useDomEditNudge — undo right after a burst", () => { describe("useDomEditNudge — a Design-panel edit during a burst", () => { it("commits the burst before the panel edit, so one undo takes back the panel edit", async () => { - __resetForTests(); - vi.useFakeTimers(); - const root = createRoot(document.body.appendChild(document.createElement("div"))); - const element = document.body.appendChild(document.createElement("div")); - element.id = "dot-panel"; const order: string[] = []; - act(() => { - root.render( - React.createElement(Harness, { - selection: makeSelection("Dot", element), - onPathOffsetCommit: async () => void order.push("nudge"), - }), - ); - }); + const root = mountBurstHarness("dot-panel", async () => void order.push("nudge")); try { act(() => dispatchArrowRight()); const panelEdit = trackedStudioEdit(async () => void order.push("width"))(); diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts index 6284196a12e..0bf6fe58b93 100644 --- a/packages/studio/src/utils/gsapSoftReload.test.ts +++ b/packages/studio/src/utils/gsapSoftReload.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication // @vitest-environment happy-dom import { afterEach, describe, it, expect, vi } from "vitest"; @@ -70,6 +71,29 @@ function buildMockIframe(overrides: Record = {}) { }; } +/** A mock iframe whose document holds one GSAP script and one composition root; `byId` answers id selectors. */ +function iframeWithComposition(script: string, root: Element, byId: Record = {}) { + const scriptEl = document.createElement("script"); + scriptEl.textContent = script; + const container = document.createElement("div"); + container.appendChild(scriptEl); + 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.includes("composition-id") + ? [root] + : byId[sel] + ? [byId[sel]] + : [], + createElement: (tag: string) => document.createElement(tag), + body: container, + head: document.createElement("div"), + }; + return iframe; +} + describe("applySoftReload", () => { it('returns "cannot-soft-reload" when iframe is null', () => { expect(applySoftReload(null, SCRIPT_TEXT)).toBe("cannot-soft-reload"); @@ -127,23 +151,13 @@ describe("applySoftReload", () => { orphan.style.cssText = "left: 1240px; top: 200px; transform: translate(449px, 0px)"; Object.assign(orphan, { _gsap: {} }); // GSAP cache marker (set by gsap.set) - const scriptEl = document.createElement("script"); - scriptEl.textContent = 'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });'; - 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.includes("composition-id") ? [root] : [], - createElement: (tag: string) => document.createElement(tag), - body: container, - head: document.createElement("div"), - }; + const iframe = iframeWithComposition( + 'const tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 });', + root, + ); applySoftReload(iframe, SCRIPT_TEXT); @@ -151,6 +165,37 @@ describe("applySoftReload", () => { expect(orphan.style.left).toBe("1240px"); // authored CSS base preserved }); + it("clears what a standalone gsap.set wrote once the new script no longer sets it", () => { + // An undo of a Design-panel W edit on an animated box removes its gsap.set width. + const target = document.createElement("div"); + target.id = "target"; + target.style.cssText = "left: 10px; width: 300px"; + Object.assign(target, { _gsap: {} }); + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "root"); + root.appendChild(target); + const iframe = iframeWithComposition( + `window.__timelines = window.__timelines || {}; +const tl = gsap.timeline({ paused: true }); +tl.to("#target", { x: 100 }); +gsap.set("#target", { width: 300 }); +window.__timelines["root"] = tl;`, + root, + { "#target": target }, + ); + const restored = `window.__timelines = window.__timelines || {}; +const tl = gsap.timeline({ paused: true }); +tl.to("#target", { x: 100 }); +window.__timelines["root"] = tl;`; + + applySoftReload(iframe, restored, { + authoredHtml: `
`, + }); + + expect(target.style.width).toBe(""); + expect(target.style.left).toBe("10px"); + }); + it("wraps execution in __hfSuppressSceneMutations when available", () => { let suppressionCalled = false; const { iframe } = buildMockIframe({ diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 806029fe41f..121cb6f51ef 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -370,7 +370,7 @@ export function applySoftReload( // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; - const targets = collectResetTargets(win, doc, targetKeys); + const targets = collectResetTargets(win, doc, targetKeys, staleScripts); // Kill ONLY the target composition's timeline(s) — leaving every other // composition's timeline (and its children on the global timeline) intact. diff --git a/packages/studio/src/utils/softReloadTargets.ts b/packages/studio/src/utils/softReloadTargets.ts index dab4fae714f..a38791d0bf6 100644 --- a/packages/studio/src/utils/softReloadTargets.ts +++ b/packages/studio/src/utils/softReloadTargets.ts @@ -1,4 +1,5 @@ // Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each. +import { parseGsapScript } from "@hyperframes/parsers/gsap-parser"; import { parseSavedSource } from "./authoredSource"; import { elementTargets } from "./elementGsap"; @@ -49,6 +50,7 @@ export function collectResetTargets( win: { __timelines?: Record }, doc: Document, targetKeys: string[], + outgoingScripts: readonly Element[] = [], ): Map> { const targets = new Map>(); const timelines = (win.__timelines ?? {}) as Record; @@ -61,6 +63,8 @@ export function collectResetTargets( } catch {} } sweepHeldElements(doc, targetKeys, others, targets); + for (const script of outgoingScripts) + addStandaloneSetTargets(doc, script.textContent ?? "", targets); return targets; } @@ -105,6 +109,29 @@ function sweepHeldElements( } } +/** Adds what each standalone `gsap.set` in the outgoing script wrote, which no timeline child records. */ +function addStandaloneSetTargets( + doc: Document, + outgoingScript: string, + targets: Map>, +): void { + if (!outgoingScript.includes("gsap.set(")) return; + for (const set of parseGsapScript(outgoingScript).animations) { + if (set.method !== "set" || !set.global) continue; + let els: Element[]; + try { + els = [...doc.querySelectorAll(set.targetSelector)]; + } catch { + continue; + } + for (const el of els) { + const seen = targets.get(el) ?? new Set(); + for (const prop of tweenedProps(set.properties)) seen.add(prop); + targets.set(el, seen); + } + } +} + // 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( From f1a4157deb6aab072ddd67f9e448ddcfdc6515a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 08:33:38 -0400 Subject: [PATCH 09/14] fix(studio): an edit never waits on itself behind a nudge save --- .../editor/useDomEditNudge.test.tsx | 4 ++- .../studio/src/contexts/DomEditContext.tsx | 4 ++- .../src/utils/studioPendingEdits.test.ts | 32 +++++++++++++++-- .../studio/src/utils/studioPendingEdits.ts | 36 +++++++++---------- 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index e41dc6793c0..fada689c58a 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -460,7 +460,9 @@ describe("useDomEditNudge — a Design-panel edit during a burst", () => { const root = mountBurstHarness("dot-panel", async () => void order.push("nudge")); try { act(() => dispatchArrowRight()); - const panelEdit = trackedStudioEdit(async () => void order.push("width"))(); + const panelEdit = trackedStudioEdit(async () => void order.push("width"), { + afterOlderSaves: true, + })(); vi.useRealTimers(); await panelEdit; expect(order).toEqual(["nudge", "width"]); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 3f180321d0f..45393b3d45a 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -107,7 +107,9 @@ function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue { const tracked: Record = { ...actions }; for (const key of Object.keys(SAVES_AN_EDIT) as Array) { if (!SAVES_AN_EDIT[key]) continue; - tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown); + tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown, { + afterOlderSaves: true, + }); } return tracked as unknown as DomEditActionsValue; } diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 900b405e35c..c403cde2c43 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -6,6 +6,7 @@ import { beginStudioPendingEdit, flushStudioPendingEdits, hasStudioPendingEdits, + isStudioEditSaving, paintBackNewestStudioPendingEdit, trackStudioPendingEdit, trackedStudioEdit, @@ -218,7 +219,9 @@ describe("a new edit after a debounced one", () => { await new Promise((resolve) => (fetched = resolve)); order.push("nudge write"); }); - const panelEdit = trackedStudioEdit(async () => void order.push("width write"))(); + const panelEdit = trackedStudioEdit(async () => void order.push("width write"), { + afterOlderSaves: true, + })(); fetched(); await panelEdit; remove(); @@ -236,8 +239,9 @@ describe("a new edit after a debounced one", () => { () => void order.push("nudge"), ); }); - const first = trackedStudioEdit(async () => void order.push("W 200"))(); - const second = trackedStudioEdit(async () => void order.push("W 300"))(); + const userEdit = (body: () => unknown) => trackedStudioEdit(body, { afterOlderSaves: true }); + const first = userEdit(async () => void order.push("W 200"))(); + const second = userEdit(async () => void order.push("W 300"))(); const drag = beginStudioPendingEdit(null); const dragged = drag.adopt(() => Promise.resolve().then(() => void order.push("resize"))); fetched(); @@ -247,6 +251,28 @@ describe("a new edit after a debounced one", () => { expect(order).toEqual(["nudge", "W 200", "W 300", "resize"]); }); + it("never waits on itself: a deferred edit or a flushed save may call a wrapped edit", async () => { + let fetched!: () => void; + let saving = false; + const internal = trackedStudioEdit(async () => undefined); + // The flushed save calls an internal wrapped commit after its fetch, as an animated nudge does. + const remove = addStudioPendingEditFlushListener(() => { + if (saving) return undefined; + saving = true; + return new Promise((resolve) => (fetched = resolve)).then(() => internal()); + }); + const userEdit = (body: () => unknown) => trackedStudioEdit(body, { afterOlderSaves: true }); + const panel = userEdit(() => internal())(); + const drag = beginStudioPendingEdit(null); + const dragged = drag.adopt(() => internal()); + fetched(); + await Promise.all([panel, dragged]); + drag.settle(); + await userEdit(async () => undefined)(); + remove(); + expect(isStudioEditSaving()).toBe(false); + }); + it("never flushes from inside an edit's own save, where a flushed save would go untracked", () => { let flushes = 0; const remove = addStudioPendingEditFlushListener(() => void (flushes += 1)); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 276f8f9f9a3..7df2d012e49 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -107,37 +107,33 @@ export function trackStudioPendingEdit( return promise; } -function commitOlderDebouncedEdits(): Promise | null { - if (typeof window === "undefined" || adopting) return null; +let flushedSavesStillWriting: Promise | null = null; + +function commitOlderDebouncedEdits(): void { + if (typeof window === "undefined" || adopting) return; const detail: StudioFlushPendingEditsDetail = { promises: [] }; window.dispatchEvent( new CustomEvent(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }), ); - return detail.promises.length ? Promise.allSettled(detail.promises) : null; + if (!detail.promises.length) return; + const saves = Promise.allSettled([flushedSavesStillWriting, ...detail.promises]); + flushedSavesStillWriting = saves; + void saves.then(() => { + if (flushedSavesStillWriting === saves) flushedSavesStillWriting = null; + }); } -let deferredTail: Promise | null = null; - -function runAfterOlderEdits(run: () => T, flush = true): Promise | null { - const waits = [flush ? commitOlderDebouncedEdits() : null, deferredTail].filter(Boolean); - if (!waits.length) return null; - const ran = Promise.allSettled(waits).then(run); - const settled: Promise = ran.then( - () => undefined, - () => undefined, - ); - deferredTail = settled; - void settled.then(() => { - if (deferredTail === settled) deferredTail = null; - }); - return ran; +function afterOlderSaves(run: () => T): Promise | null { + return flushedSavesStillWriting && flushedSavesStillWriting.then(run); } export function trackedStudioEdit( edit: (...args: Args) => R, + { afterOlderSaves: waits = false } = {}, ): (...args: Args) => R { return (...args) => { - const deferred = runAfterOlderEdits(() => edit(...args)); + commitOlderDebouncedEdits(); + const deferred = waits ? afterOlderSaves(() => edit(...args)) : null; if (deferred) return trackStudioPendingEdit(deferred) as R; const result = edit(...args); if (result instanceof Promise) trackStudioPendingEdit(result); @@ -186,7 +182,7 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) { // Only what `start` registers synchronously is adopted; a later write joins only through `within`. adopt(start: () => T): T { try { - const deferred = runAfterOlderEdits(() => inFlight.within(start), false); + const deferred = afterOlderSaves(() => inFlight.within(start)); const committed = (deferred ?? inFlight.within(start)) as T; landed = Promise.resolve(committed).then( () => true, From 545c6e074801e3d5b72b9b0dbaa59ca94d0efc8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 09:10:54 -0400 Subject: [PATCH 10/14] fix(studio): a second nudge burst saves when its commit runs behind the first --- .../src/utils/studioPendingEdits.test.ts | 38 +++++++++++++++++++ .../studio/src/utils/studioPendingEdits.ts | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index c403cde2c43..a0c47d3c9a3 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -273,6 +273,44 @@ describe("a new edit after a debounced one", () => { expect(isStudioEditSaving()).toBe(false); }); + it("lets a second burst flushed behind the first one save, though its commit is a panel action", async () => { + const order: string[] = []; + let fetched!: () => void; + const commitNudge = trackedStudioEdit( + async (name: string) => { + if (name === "nudge A") await new Promise((resolve) => (fetched = resolve)); + else await Promise.resolve(); + order.push(name); + }, + { afterOlderSaves: true }, + ); + let burst: { name: string; edit: ReturnType } | null = null; + const startBurst = (name: string) => { + const edit = beginStudioPendingEdit(null); + burst = { name, edit }; + }; + const remove = addStudioPendingEditFlushListener(() => { + if (!burst) return undefined; + const { name, edit } = burst; + burst = null; + const saved = edit.adopt(() => commitNudge(name)); + edit.settle(saved); + return saved; + }); + const panelEdit = trackedStudioEdit(async (name: string) => void order.push(name), { + afterOlderSaves: true, + }); + startBurst("nudge A"); + const first = panelEdit("W 200"); + startBurst("nudge B"); + const second = panelEdit("W 300"); + fetched(); + await Promise.all([first, second]); + remove(); + expect(order).toEqual(["nudge A", "W 200", "nudge B", "W 300"]); + expect(isStudioEditSaving()).toBe(false); + }); + it("never flushes from inside an edit's own save, where a flushed save would go untracked", () => { let flushes = 0; const remove = addStudioPendingEditFlushListener(() => void (flushes += 1)); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 7df2d012e49..548fbf08d21 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -133,7 +133,7 @@ export function trackedStudioEdit( ): (...args: Args) => R { return (...args) => { commitOlderDebouncedEdits(); - const deferred = waits ? afterOlderSaves(() => edit(...args)) : null; + const deferred = waits && !adopting ? afterOlderSaves(() => edit(...args)) : null; if (deferred) return trackStudioPendingEdit(deferred) as R; const result = edit(...args); if (result instanceof Promise) trackStudioPendingEdit(result); From 1e3b9d08132d80133b72c935ae0aaa5417fca13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 09:24:00 -0400 Subject: [PATCH 11/14] fix(studio): undoing a W on an animated box clears the width it set live --- packages/studio/src/utils/gsapSoftReload.ts | 7 ++++++- .../studio/src/utils/gsapUndoRestore.test.ts | 16 ++++++++++++++-- packages/studio/src/utils/gsapUndoRestore.ts | 1 + packages/studio/src/utils/softReloadTargets.ts | 5 ++--- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 121cb6f51ef..8945675dc6e 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -226,6 +226,8 @@ export interface SoftReloadOptions { authoredHtml?: string; /** Other composition files a reset element is written in, by path; null when one could not be read. */ nestedFiles?: Map | null; + /** The script the file held before this write; a live edit can apply a set the preview never ran. */ + replacedScript?: string; } /** @@ -370,7 +372,10 @@ export function applySoftReload( // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; - const targets = collectResetTargets(win, doc, targetKeys, staleScripts); + const targets = collectResetTargets(win, doc, targetKeys, [ + ...staleScripts.map((script) => script.textContent ?? ""), + options.replacedScript ?? "", + ]); // Kill ONLY the target composition's timeline(s) — leaving every other // composition's timeline (and its children on the global timeline) intact. diff --git a/packages/studio/src/utils/gsapUndoRestore.test.ts b/packages/studio/src/utils/gsapUndoRestore.test.ts index 34f0599a180..6b5702eb267 100644 --- a/packages/studio/src/utils/gsapUndoRestore.test.ts +++ b/packages/studio/src/utils/gsapUndoRestore.test.ts @@ -214,9 +214,9 @@ 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) => `
${body}
`; - const undoScriptEdit = (live: string, authored: string, edit: string) => { + const undoScriptEdit = (live: string, authored: string, edit: string, ran = edit) => { const { iframe, contentWindow, doc } = buildLiveIframe( - `${root(live)}`, + `${root(live)}`, ); const clearProps = (targets: HTMLElement[]) => targets.forEach((t) => t.removeAttribute("style")); @@ -242,6 +242,18 @@ describe("applyUndoRestoreToPreview", () => { expect(doc.getElementById("a")!.getAttribute("style")).toBe("width: 300px"); }); + it("drops the width a W edit set live, though the preview never ran its gsap.set", () => { + // The Design panel applies W to the live element and writes the set only to the file. + const doc = undoScriptEdit( + `
t
`, + `
t
`, + `gsap.set("#a",{width:300});`, + "", + ); + const { style } = doc.getElementById("a")!; + expect([style.width, style.left]).toEqual(["", "10px"]); + }); + it("keeps the authored inline rotation of an element GSAP moved", () => { const doc = undoScriptEdit( `
t
`, diff --git a/packages/studio/src/utils/gsapUndoRestore.ts b/packages/studio/src/utils/gsapUndoRestore.ts index 186fde36624..a91d3d5688b 100644 --- a/packages/studio/src/utils/gsapUndoRestore.ts +++ b/packages/studio/src/utils/gsapUndoRestore.ts @@ -413,6 +413,7 @@ export function applyUndoRestoreToPreview( currentTimeOverride: currentTime, authoredHtml: active.restored, nestedFiles, + replacedScript: previousScript ?? undefined, }); if (result === "cannot-soft-reload") { reloadPreview(); diff --git a/packages/studio/src/utils/softReloadTargets.ts b/packages/studio/src/utils/softReloadTargets.ts index a38791d0bf6..70e5e8ea1e4 100644 --- a/packages/studio/src/utils/softReloadTargets.ts +++ b/packages/studio/src/utils/softReloadTargets.ts @@ -50,7 +50,7 @@ export function collectResetTargets( win: { __timelines?: Record }, doc: Document, targetKeys: string[], - outgoingScripts: readonly Element[] = [], + outgoingScripts: readonly string[] = [], ): Map> { const targets = new Map>(); const timelines = (win.__timelines ?? {}) as Record; @@ -63,8 +63,7 @@ export function collectResetTargets( } catch {} } sweepHeldElements(doc, targetKeys, others, targets); - for (const script of outgoingScripts) - addStandaloneSetTargets(doc, script.textContent ?? "", targets); + for (const script of outgoingScripts) addStandaloneSetTargets(doc, script, targets); return targets; } From c390fee07a4f7e4c66e2a65e4b485eea48731cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 09:57:41 -0400 Subject: [PATCH 12/14] fix(studio): any GSAP edit that drops a live-applied set clears it from the preview --- .../src/hooks/useGsapScriptCommits.test.tsx | 18 +++++++++++++++ .../studio/src/hooks/useGsapScriptCommits.ts | 2 ++ packages/studio/src/utils/gsapSoftReload.ts | 22 ++++++++++++++----- .../studio/src/utils/gsapUndoRestore.test.ts | 8 ++++--- packages/studio/src/utils/gsapUndoRestore.ts | 1 - 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index 7718051724c..0d83a917505 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication // @vitest-environment happy-dom import { act } from "react"; import { createRoot } from "react-dom/client"; @@ -11,6 +12,7 @@ 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); +const noteScriptWritten = vi.fn(); vi.mock("./gsapRuntimePatch", () => ({ patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args), @@ -20,6 +22,7 @@ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({ applySoftReload: (...args: unknown[]) => applySoftReload(...args), extractGsapScriptText: () => "", readNestedFiles: (...args: unknown[]) => readNestedFiles(...args), + noteScriptWritten: (...args: unknown[]) => noteScriptWritten(...args), })); vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args), @@ -78,6 +81,7 @@ describe("applyPreviewSync", () => { beforeEach(() => { patchRuntimeTweenInPlace.mockReset(); applySoftReload.mockReset(); + noteScriptWritten.mockReset(); trackStudioEvent.mockReset(); }); @@ -691,6 +695,7 @@ describe("runCommit — instantPatch wiring", () => { beforeEach(() => { patchRuntimeTweenInPlace.mockReset(); applySoftReload.mockReset(); + noteScriptWritten.mockReset(); trackStudioEvent.mockReset(); readNestedFiles.mockReset().mockReturnValue(null); }); @@ -743,6 +748,19 @@ describe("runCommit — instantPatch wiring", () => { expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]); }); + it("notes the script a commit wrote though it skips the reload, so the next one clears its sets", async () => { + const written = 'gsap.set("#a", { width: 300 });'; + mockFetchResult({ scriptText: written }); + const deps = renderCommitHook(); + await deps.api.commitMutation( + selection, + { type: "set" }, + { label: "Resize", skipReload: true }, + ); + expect(noteScriptWritten).toHaveBeenCalledWith(FAKE_IFRAME, written); + expect(applySoftReload).not.toHaveBeenCalled(); + }); + it("does not count a failed write that skipReload suppresses", async () => { vi.stubGlobal( "fetch", diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 3082f97a2e4..511d498f281 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -6,6 +6,7 @@ import { usePlayerStore } from "../player/store/playerStore"; import { applySoftReload, extractGsapScriptText, + noteScriptWritten, readNestedFiles, settleNestedReads, } from "../utils/gsapSoftReload"; @@ -175,6 +176,7 @@ function syncCommittedGsapMutation({ nestedFiles?: Map | null; }): void { if (result.after != null) onFileContentChanged?.(targetPath, result.after); + noteScriptWritten(iframe, result.scriptText); // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a // later SDK-routed edit doesn't serialize the pre-write doc and revert this. forceReloadSdkSession?.(); diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 8945675dc6e..0c128a7c7c0 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -226,8 +226,19 @@ export interface SoftReloadOptions { authoredHtml?: string; /** Other composition files a reset element is written in, by path; null when one could not be read. */ nestedFiles?: Map | null; - /** The script the file held before this write; a live edit can apply a set the preview never ran. */ - replacedScript?: string; +} + +// The first and latest script written since the preview last ran one; a live patch applies its sets. +const writtenUnrun = new WeakMap(); + +export function noteScriptWritten( + iframe: HTMLIFrameElement | null, + scriptText?: string | null, +): void { + const doc = iframe?.contentDocument; + if (!doc || !scriptText?.includes("gsap.set(")) return; + for (const script of scriptsRegistering(findGsapScriptElements(doc), timelineKeys(scriptText))) + writtenUnrun.set(script, [writtenUnrun.get(script)?.[0] ?? scriptText, scriptText]); } /** @@ -372,10 +383,11 @@ export function applySoftReload( // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; - const targets = collectResetTargets(win, doc, targetKeys, [ - ...staleScripts.map((script) => script.textContent ?? ""), - options.replacedScript ?? "", + const outgoing = staleScripts.flatMap((s) => [ + s.textContent ?? "", + ...(writtenUnrun.get(s) ?? []), ]); + const targets = collectResetTargets(win, doc, targetKeys, [...new Set(outgoing)]); // Kill ONLY the target composition's timeline(s) — leaving every other // composition's timeline (and its children on the global timeline) intact. diff --git a/packages/studio/src/utils/gsapUndoRestore.test.ts b/packages/studio/src/utils/gsapUndoRestore.test.ts index 6b5702eb267..702a32ee71c 100644 --- a/packages/studio/src/utils/gsapUndoRestore.test.ts +++ b/packages/studio/src/utils/gsapUndoRestore.test.ts @@ -19,6 +19,7 @@ import { diffSoftReloadableRestore, showRestoreInPlace, } from "./gsapUndoRestore"; +import { noteScriptWritten } from "./gsapSoftReload"; import { applyPatch } from "./sourcePatcher"; import { beginStudioManualEditGesture } from "../components/editor/manualEdits"; import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate"; @@ -214,10 +215,11 @@ 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) => `
${body}
`; - const undoScriptEdit = (live: string, authored: string, edit: string, ran = edit) => { + const undoScriptEdit = (live: string, authored: string, edit: string, patchedLive = false) => { const { iframe, contentWindow, doc } = buildLiveIframe( - `${root(live)}`, + `${root(live)}`, ); + if (patchedLive) noteScriptWritten(iframe, script(edit)); const clearProps = (targets: HTMLElement[]) => targets.forEach((t) => t.removeAttribute("style")); Object.assign(contentWindow.gsap, { set: clearProps }); @@ -248,7 +250,7 @@ describe("applyUndoRestoreToPreview", () => { `
t
`, `
t
`, `gsap.set("#a",{width:300});`, - "", + true, ); const { style } = doc.getElementById("a")!; expect([style.width, style.left]).toEqual(["", "10px"]); diff --git a/packages/studio/src/utils/gsapUndoRestore.ts b/packages/studio/src/utils/gsapUndoRestore.ts index a91d3d5688b..186fde36624 100644 --- a/packages/studio/src/utils/gsapUndoRestore.ts +++ b/packages/studio/src/utils/gsapUndoRestore.ts @@ -413,7 +413,6 @@ export function applyUndoRestoreToPreview( currentTimeOverride: currentTime, authoredHtml: active.restored, nestedFiles, - replacedScript: previousScript ?? undefined, }); if (result === "cannot-soft-reload") { reloadPreview(); From a67ae18872a12b9d6d1aeb16ef06bac7b2fd79a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 10:30:25 -0400 Subject: [PATCH 13/14] fix(studio): a soft reload clears exactly the set props a live patch applied --- packages/studio/src/hooks/gsapRuntimePatch.ts | 2 + .../src/hooks/useGsapScriptCommits.test.tsx | 18 --------- .../studio/src/hooks/useGsapScriptCommits.ts | 2 - .../studio/src/utils/gsapSoftReload.test.ts | 39 +++++++++++++++++++ packages/studio/src/utils/gsapSoftReload.ts | 33 +++++++--------- .../studio/src/utils/gsapUndoRestore.test.ts | 13 +++++-- .../studio/src/utils/softReloadTargets.ts | 30 ++++++++++++++ 7 files changed, 94 insertions(+), 43 deletions(-) diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index 9d326503877..bcfa447144a 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -19,6 +19,7 @@ import { type RuntimeTween, type RuntimeTimeline, } from "./gsapRuntimeKeyframes"; +import { recordLiveSet } from "../utils/softReloadTargets"; /** Value-only channels a `tl.set(...)` patch may touch. */ export interface SetPatchProps { @@ -101,6 +102,7 @@ function applyGlobalSet( const numeric = finiteNumericProps(props); if (!gsapLib?.set || !el || !numeric) return false; gsapLib.set(el, numeric); + recordLiveSet(el, numeric); return true; } catch { return false; diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index 0d83a917505..7718051724c 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -1,4 +1,3 @@ -// fallow-ignore-file code-duplication // @vitest-environment happy-dom import { act } from "react"; import { createRoot } from "react-dom/client"; @@ -12,7 +11,6 @@ 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); -const noteScriptWritten = vi.fn(); vi.mock("./gsapRuntimePatch", () => ({ patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args), @@ -22,7 +20,6 @@ vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({ applySoftReload: (...args: unknown[]) => applySoftReload(...args), extractGsapScriptText: () => "", readNestedFiles: (...args: unknown[]) => readNestedFiles(...args), - noteScriptWritten: (...args: unknown[]) => noteScriptWritten(...args), })); vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args), @@ -81,7 +78,6 @@ describe("applyPreviewSync", () => { beforeEach(() => { patchRuntimeTweenInPlace.mockReset(); applySoftReload.mockReset(); - noteScriptWritten.mockReset(); trackStudioEvent.mockReset(); }); @@ -695,7 +691,6 @@ describe("runCommit — instantPatch wiring", () => { beforeEach(() => { patchRuntimeTweenInPlace.mockReset(); applySoftReload.mockReset(); - noteScriptWritten.mockReset(); trackStudioEvent.mockReset(); readNestedFiles.mockReset().mockReturnValue(null); }); @@ -748,19 +743,6 @@ describe("runCommit — instantPatch wiring", () => { expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]); }); - it("notes the script a commit wrote though it skips the reload, so the next one clears its sets", async () => { - const written = 'gsap.set("#a", { width: 300 });'; - mockFetchResult({ scriptText: written }); - const deps = renderCommitHook(); - await deps.api.commitMutation( - selection, - { type: "set" }, - { label: "Resize", skipReload: true }, - ); - expect(noteScriptWritten).toHaveBeenCalledWith(FAKE_IFRAME, written); - expect(applySoftReload).not.toHaveBeenCalled(); - }); - it("does not count a failed write that skipReload suppresses", async () => { vi.stubGlobal( "fetch", diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 511d498f281..3082f97a2e4 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -6,7 +6,6 @@ import { usePlayerStore } from "../player/store/playerStore"; import { applySoftReload, extractGsapScriptText, - noteScriptWritten, readNestedFiles, settleNestedReads, } from "../utils/gsapSoftReload"; @@ -176,7 +175,6 @@ function syncCommittedGsapMutation({ nestedFiles?: Map | null; }): void { if (result.after != null) onFileContentChanged?.(targetPath, result.after); - noteScriptWritten(iframe, result.scriptText); // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a // later SDK-routed edit doesn't serialize the pre-write doc and revert this. forceReloadSdkSession?.(); diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts index 0bf6fe58b93..674d4546d61 100644 --- a/packages/studio/src/utils/gsapSoftReload.test.ts +++ b/packages/studio/src/utils/gsapSoftReload.test.ts @@ -1,6 +1,7 @@ // fallow-ignore-file code-duplication // @vitest-environment happy-dom +import { patchRuntimeTweenInPlace } from "../hooks/gsapRuntimePatch"; import { afterEach, describe, it, expect, vi } from "vitest"; import { readNestedFiles, @@ -962,3 +963,41 @@ describe("applySoftReload over a composition's own markup", () => { expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload"); }); }); + +describe("a gsap.set a live patch applied", () => { + it("is cleared by the next soft reload once the new script no longer sets it, and only once", () => { + const markup = `
`; + const doc = document.implementation.createHTMLDocument(""); + doc.body.innerHTML = `${markup}`; + const set = (target: HTMLElement | HTMLElement[], vars: Record) => { + for (const el of [target].flat()) { + if (vars.clearProps) el.removeAttribute("style"); + else for (const [k, v] of Object.entries(vars)) el.style.setProperty(k, `${v}px`); + } + }; + const iframe = { + contentDocument: doc, + contentWindow: { + gsap: { timeline: () => {}, set }, + __hfForceTimelineRebind: () => {}, + __timelines: {}, + __player: { getTime: () => 0, seek: () => {} }, + }, + } as unknown as HTMLIFrameElement; + const el = doc.getElementById("a")!; + const reload = () => { + const script = `window.__timelines["root"]=gsap.timeline();gsap.set("#a",{width:300});`; + applySoftReload(iframe, script, { authoredHtml: `${markup}` }); + }; + + // W 300, then H 200, then a commit that removes height from the set. + patchRuntimeTweenInPlace(iframe, "#a", { kind: "global-set", props: { width: 300 } }); + patchRuntimeTweenInPlace(iframe, "#a", { kind: "global-set", props: { height: 200 } }); + reload(); + expect([el.style.height, el.style.left]).toEqual(["", "10px"]); + + el.style.height = "50px"; + reload(); + expect(el.style.height).toBe("50px"); + }); +}); diff --git a/packages/studio/src/utils/gsapSoftReload.ts b/packages/studio/src/utils/gsapSoftReload.ts index 0c128a7c7c0..2294337f626 100644 --- a/packages/studio/src/utils/gsapSoftReload.ts +++ b/packages/studio/src/utils/gsapSoftReload.ts @@ -2,7 +2,13 @@ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-gradin import { motionPathPluginUrl } from "@hyperframes/core/gsap-cdn"; import { findAuthoredElement } from "./authoredSource"; import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity"; -import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets"; +import { + authoringFile, + collectResetTargets, + compositionFile, + fileDocs, + forgetLiveSets, +} from "./softReloadTargets"; type IframeWindow = Window & { __timelines?: Record void; pause?: () => void }>; @@ -228,19 +234,6 @@ export interface SoftReloadOptions { nestedFiles?: Map | null; } -// The first and latest script written since the preview last ran one; a live patch applies its sets. -const writtenUnrun = new WeakMap(); - -export function noteScriptWritten( - iframe: HTMLIFrameElement | null, - scriptText?: string | null, -): void { - const doc = iframe?.contentDocument; - if (!doc || !scriptText?.includes("gsap.set(")) return; - for (const script of scriptsRegistering(findGsapScriptElements(doc), timelineKeys(scriptText))) - writtenUnrun.set(script, [writtenUnrun.get(script)?.[0] ?? scriptText, scriptText]); -} - /** * The soft reload's finalization step, shared with the rebind-only preview sync * below: seek → force timeline rebind → reapply studio manual edits. @@ -383,11 +376,12 @@ export function applySoftReload( // fallow-ignore-next-line complexity const doReload = () => { const timelines = win.__timelines; - const outgoing = staleScripts.flatMap((s) => [ - s.textContent ?? "", - ...(writtenUnrun.get(s) ?? []), - ]); - const targets = collectResetTargets(win, doc, targetKeys, [...new Set(outgoing)]); + const targets = collectResetTargets( + win, + doc, + targetKeys, + staleScripts.map((script) => script.textContent ?? ""), + ); // Kill ONLY the target composition's timeline(s) — leaving every other // composition's timeline (and its children on the global timeline) intact. @@ -440,6 +434,7 @@ export function applySoftReload( } } } + forgetLiveSets(targets); for (const script of staleScripts) script.remove(); diff --git a/packages/studio/src/utils/gsapUndoRestore.test.ts b/packages/studio/src/utils/gsapUndoRestore.test.ts index 702a32ee71c..b6aa714436f 100644 --- a/packages/studio/src/utils/gsapUndoRestore.test.ts +++ b/packages/studio/src/utils/gsapUndoRestore.test.ts @@ -19,7 +19,7 @@ import { diffSoftReloadableRestore, showRestoreInPlace, } from "./gsapUndoRestore"; -import { noteScriptWritten } from "./gsapSoftReload"; +import { recordLiveSet } from "./softReloadTargets"; import { applyPatch } from "./sourcePatcher"; import { beginStudioManualEditGesture } from "../components/editor/manualEdits"; import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate"; @@ -215,11 +215,16 @@ 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) => `
${body}
`; - const undoScriptEdit = (live: string, authored: string, edit: string, patchedLive = false) => { + const undoScriptEdit = ( + live: string, + authored: string, + edit: string, + patchedLive?: (doc: Document) => void, + ) => { const { iframe, contentWindow, doc } = buildLiveIframe( `${root(live)}`, ); - if (patchedLive) noteScriptWritten(iframe, script(edit)); + patchedLive?.(doc); const clearProps = (targets: HTMLElement[]) => targets.forEach((t) => t.removeAttribute("style")); Object.assign(contentWindow.gsap, { set: clearProps }); @@ -250,7 +255,7 @@ describe("applyUndoRestoreToPreview", () => { `
t
`, `
t
`, `gsap.set("#a",{width:300});`, - true, + (live) => recordLiveSet(live.getElementById("a")!, { width: 300 }), ); const { style } = doc.getElementById("a")!; expect([style.width, style.left]).toEqual(["", "10px"]); diff --git a/packages/studio/src/utils/softReloadTargets.ts b/packages/studio/src/utils/softReloadTargets.ts index 70e5e8ea1e4..0ba79ec0b95 100644 --- a/packages/studio/src/utils/softReloadTargets.ts +++ b/packages/studio/src/utils/softReloadTargets.ts @@ -63,6 +63,7 @@ export function collectResetTargets( } catch {} } sweepHeldElements(doc, targetKeys, others, targets); + addLiveSetTargets(doc, targetKeys, targets); for (const script of outgoingScripts) addStandaloneSetTargets(doc, script, targets); return targets; } @@ -108,6 +109,35 @@ function sweepHeldElements( } } +// What a live patch set on each element since its last reload; no script in the preview ran it. +const liveSets = new WeakMap>>(); + +export function recordLiveSet(el: Element, vars: Record): void { + const byElement = liveSets.get(el.ownerDocument) ?? new Map>(); + liveSets.set(el.ownerDocument, byElement); + const props = byElement.get(el) ?? new Set(); + for (const prop of tweenedProps(vars)) props.add(prop); + byElement.set(el, props); +} + +function addLiveSetTargets( + doc: Document, + targetKeys: string[], + targets: Map>, +): void { + const roots = new Set(targetKeys.map((k) => compositionRoot(doc, k))); + for (const [el, props] of liveSets.get(doc) ?? []) { + if (!roots.has(el) && !roots.has(el.parentElement?.closest("[data-composition-id]"))) continue; + const seen = targets.get(el) ?? new Set(); + for (const prop of props) seen.add(prop); + targets.set(el, seen); + } +} + +export function forgetLiveSets(reset: Map): void { + for (const el of reset.keys()) liveSets.get(el.ownerDocument)?.delete(el); +} + /** Adds what each standalone `gsap.set` in the outgoing script wrote, which no timeline child records. */ function addStandaloneSetTargets( doc: Document, From 191e331c34716a390d6d53d2ec842bcdb32efd28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 5 Oct 2026 10:47:00 -0400 Subject: [PATCH 14/14] fix(studio): name width and height as set-patch props --- packages/studio/src/hooks/gsapRuntimePatch.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/studio/src/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index bcfa447144a..7768fb011e3 100644 --- a/packages/studio/src/hooks/gsapRuntimePatch.ts +++ b/packages/studio/src/hooks/gsapRuntimePatch.ts @@ -35,6 +35,8 @@ export interface SetPatchProps { scaleY?: number; scale?: number; opacity?: number; + width?: number; + height?: number; } /** A single keyframe step's numeric channels (the GSAP array-keyframe form). */