diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index c2c467261d0..fada689c58a 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"; @@ -7,7 +8,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"; @@ -392,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; @@ -424,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); @@ -452,6 +454,25 @@ 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 () => { + const order: string[] = []; + const root = mountBurstHarness("dot-panel", async () => void order.push("nudge")); + try { + act(() => dispatchArrowRight()); + const panelEdit = trackedStudioEdit(async () => void order.push("width"), { + afterOlderSaves: true, + })(); + vi.useRealTimers(); + await panelEdit; + 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/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] }], 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/hooks/gsapRuntimePatch.ts b/packages/studio/src/hooks/gsapRuntimePatch.ts index 9d326503877..7768fb011e3 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 { @@ -34,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). */ @@ -101,6 +104,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/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); diff --git a/packages/studio/src/utils/gsapSoftReload.test.ts b/packages/studio/src/utils/gsapSoftReload.test.ts index 6284196a12e..674d4546d61 100644 --- a/packages/studio/src/utils/gsapSoftReload.test.ts +++ b/packages/studio/src/utils/gsapSoftReload.test.ts @@ -1,5 +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, @@ -70,6 +72,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 +152,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 +166,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({ @@ -917,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 806029fe41f..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 }>; @@ -370,7 +376,12 @@ 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.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. @@ -423,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 34f0599a180..b6aa714436f 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 { recordLiveSet } from "./softReloadTargets"; import { applyPatch } from "./sourcePatcher"; import { beginStudioManualEditGesture } from "../components/editor/manualEdits"; import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate"; @@ -214,10 +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) => { + const undoScriptEdit = ( + live: string, + authored: string, + edit: string, + patchedLive?: (doc: Document) => void, + ) => { const { iframe, contentWindow, doc } = buildLiveIframe( - `${root(live)}`, + `${root(live)}`, ); + patchedLive?.(doc); const clearProps = (targets: HTMLElement[]) => targets.forEach((t) => t.removeAttribute("style")); Object.assign(contentWindow.gsap, { set: clearProps }); @@ -242,6 +249,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});`, + (live) => recordLiveSet(live.getElementById("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/softReloadTargets.ts b/packages/studio/src/utils/softReloadTargets.ts index dab4fae714f..0ba79ec0b95 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 string[] = [], ): Map> { const targets = new Map>(); const timelines = (win.__timelines ?? {}) as Record; @@ -61,6 +63,8 @@ export function collectResetTargets( } catch {} } sweepHeldElements(doc, targetKeys, others, targets); + addLiveSetTargets(doc, targetKeys, targets); + for (const script of outgoingScripts) addStandaloneSetTargets(doc, script, targets); return targets; } @@ -105,6 +109,58 @@ 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, + 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( diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 306fe2d0fcd..a0c47d3c9a3 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, @@ -199,6 +200,128 @@ 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"]); + }); + 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"), { + afterOlderSaves: true, + })(); + fetched(); + await panelEdit; + remove(); + 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 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(); + await Promise.all([first, second, dragged]); + drag.settle(); + remove(); + 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("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)); + const drag = beginStudioPendingEdit(null); + drag.adopt(() => trackedStudioEdit(() => undefined)()); + drag.settle(); + remove(); + expect(flushes).toBe(1); + }); +}); + 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..548fbf08d21 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -107,10 +107,34 @@ export function trackStudioPendingEdit( return promise; } +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 }), + ); + if (!detail.promises.length) return; + const saves = Promise.allSettled([flushedSavesStillWriting, ...detail.promises]); + flushedSavesStillWriting = saves; + void saves.then(() => { + if (flushedSavesStillWriting === saves) flushedSavesStillWriting = null; + }); +} + +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) => { + commitOlderDebouncedEdits(); + 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); return result; @@ -118,6 +142,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)!; @@ -157,7 +182,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 = afterOlderSaves(() => inFlight.within(start)); + const committed = (deferred ?? inFlight.within(start)) as T; landed = Promise.resolve(committed).then( () => true, () => saved, 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"] },