fix(studio): undo takes back the last edit, even right after a resize or a nudge (#5048)

* fix(studio): a size edit right after a resize is its own undo step

* fix(studio): a nudge still saving is recorded before the next edit

* fix(studio): published types name cn by its package, not a node_modules path

* fix(studio): drop baseUrl so declarations never name a node_modules path

* fix(studio): an edit started inside the save of another edit never flushes

* fix(studio): an edit made while a nudge is still saving writes after it

* fix(studio): a later edit never overtakes one still waiting on a nudge save

* fix(studio): undoing a gsap.set clears what it applied from the preview

* fix(studio): an edit never waits on itself behind a nudge save

* fix(studio): a second nudge burst saves when its commit runs behind the first

* fix(studio): undoing a W on an animated box clears the width it set live

* fix(studio): any GSAP edit that drops a live-applied set clears it from the preview

* fix(studio): a soft reload clears exactly the set props a live patch applied

* fix(studio): name width and height as set-patch props
This commit is contained in:
Miguel Ángel
2026-10-05 16:52:53 +00:00
committed by GitHub
parent f7d6734761
commit 98fe011699
13 changed files with 423 additions and 50 deletions
@@ -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<void>((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<void>((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();
+2 -1
View File
@@ -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] }],
@@ -107,7 +107,9 @@ function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue {
const tracked: Record<string, unknown> = { ...actions };
for (const key of Object.keys(SAVES_AN_EDIT) as Array<keyof typeof SAVES_AN_EDIT>) {
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;
}
@@ -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;
@@ -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<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
.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"));
@@ -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);
@@ -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<string, unknown> = {}) {
};
}
/** 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<string, Element> = {}) {
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: `<html><body><div data-composition-id="root"><div id="target" style="left: 10px"></div></div><script>${restored}</script></body></html>`,
});
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 = `<div data-composition-id="root"><div id="a" style="left: 10px"></div></div>`;
const doc = document.implementation.createHTMLDocument("");
doc.body.innerHTML = `${markup}<script>window.__timelines["root"]=gsap.timeline();</script>`;
const set = (target: HTMLElement | HTMLElement[], vars: Record<string, unknown>) => {
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}<script>${script}</script>` });
};
// 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");
});
});
+14 -2
View File
@@ -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<string, { kill?: () => 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();
@@ -19,6 +19,7 @@ import {
diffSoftReloadableRestore,
showRestoreInPlace,
} from "./gsapUndoRestore";
import { recordLiveSet } from "./softReloadTargets";
import { applyPatch } from "./sourcePatcher";
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
@@ -215,10 +216,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) => `<div data-composition-id="root">${body}</div>`;
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)}<script>${script(edit)}</script>`,
`${root(live)}<script>${script(patchedLive ? "" : edit)}</script>`,
);
patchedLive?.(doc);
const clearProps = (targets: HTMLElement[]) =>
targets.forEach((t) => t.removeAttribute("style"));
Object.assign(contentWindow.gsap, { set: clearProps });
@@ -243,6 +250,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(
`<div id="a" style="left: 10px; width: 300px">t</div>`,
`<div id="a" style="left: 10px">t</div>`,
`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(
`<div id="r" style="left: 10px; transform: translate(116px, 72px) rotate(20deg)">t</div>`,
@@ -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<string, unknown> },
doc: Document,
targetKeys: string[],
outgoingScripts: readonly string[] = [],
): Map<Element, Set<string>> {
const targets = new Map<Element, Set<string>>();
const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
@@ -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<Document, Map<Element, Set<string>>>();
export function recordLiveSet(el: Element, vars: Record<string, unknown>): void {
const byElement = liveSets.get(el.ownerDocument) ?? new Map<Element, Set<string>>();
liveSets.set(el.ownerDocument, byElement);
const props = byElement.get(el) ?? new Set<string>();
for (const prop of tweenedProps(vars)) props.add(prop);
byElement.set(el, props);
}
function addLiveSetTargets(
doc: Document,
targetKeys: string[],
targets: Map<Element, Set<string>>,
): void {
const roots = new Set<Element | null | undefined>(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<string>();
for (const prop of props) seen.add(prop);
targets.set(el, seen);
}
}
export function forgetLiveSets(reset: Map<Element, unknown>): 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<Element, Set<string>>,
): 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<string>();
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(
@@ -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<void>((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<void>((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<void>((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<void>((resolve) => (fetched = resolve));
else await Promise.resolve();
order.push(name);
},
{ afterOlderSaves: true },
);
let burst: { name: string; edit: ReturnType<typeof beginStudioPendingEdit> } | 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;
@@ -117,10 +117,34 @@ export function trackStudioPendingEdit(
return promise;
}
let flushedSavesStillWriting: Promise<unknown> | null = null;
function commitOlderDebouncedEdits(): void {
if (typeof window === "undefined" || adopting) return;
const detail: StudioFlushPendingEditsDetail = { promises: [] };
window.dispatchEvent(
new CustomEvent<StudioFlushPendingEditsDetail>(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<T>(run: () => T): Promise<T> | null {
return flushedSavesStillWriting && flushedSavesStillWriting.then(run);
}
export function trackedStudioEdit<Args extends unknown[], R>(
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;
@@ -128,6 +152,7 @@ export function trackedStudioEdit<Args extends unknown[], R>(
}
export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
commitOlderDebouncedEdits();
let settle!: (saved?: Promise<unknown>) => void;
const promise = trackStudioPendingEdit(new Promise<unknown>((resolve) => (settle = resolve)))!;
const entry = pendingEdits.get(promise)!;
@@ -167,7 +192,8 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
// Only what `start` registers synchronously is adopted; a later write joins only through `within`.
adopt<T>(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,
-1
View File
@@ -3,7 +3,6 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@hyperframes/player": ["../player/src/hyperframes-player.ts"]
},