fix(studio): undo right after an edit undoes that edit, even before its save lands (#4807)

* fix(studio): undo right after an edit undoes that edit, even before its save lands

* fix(studio): undo right after a nudge undoes the nudge

* fix(studio): undo during a nudge's wait for more keys never paints the edit before it

* fix(studio): a failed save never blocks undo, and a nudge burst always ends its pending edit

* fix(studio): undo waits out every failed or conflicting edit, and Delete counts as pending

* fix(studio): every GSAP script commit counts as a pending edit, so undo waits for it

* fix(studio): keep App under the file size limit

* test(studio): bank the nudge-then-undo sequences the gate now passes
This commit is contained in:
Miguel Ángel
2026-10-01 15:22:03 -07:00
committed by GitHub
parent 21a29c2ac7
commit c3b6e55f7e
17 changed files with 528 additions and 132 deletions
+5 -3
View File
@@ -30,6 +30,7 @@ import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { trackedStudioEdit } from "./utils/studioPendingEdits";
import { useClipboard } from "./hooks/useClipboard";
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
import { useCaptionDetection } from "./hooks/useCaptionDetection";
@@ -256,7 +257,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
showToast,
syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow,
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
settlePendingEdits: previewPersistence.settlePendingEdits,
handleCopy,
handlePaste,
handleCut,
@@ -315,9 +316,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
readOnlyPreview,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit;
const { handleDomZIndexReorderCommit: zCommit, handleDomEditElementDelete: del } = domEditSession;
handleDomZIndexReorderCommitRef.current = trackedStudioEdit(zCommit);
clearDomSelectionRef.current = domEditSession.clearDomSelection;
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
handleDomEditElementDeleteRef.current = trackedStudioEdit(del);
resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession);
@@ -52,7 +52,7 @@ function AppHotkeyHarness() {
writeProjectFile: vi.fn(async () => undefined),
showToast: vi.fn(),
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
waitForPendingDomEditSaves: vi.fn(async () => undefined),
settlePendingEdits: vi.fn(async () => undefined),
handleCopy: vi.fn(() => false),
handlePaste: vi.fn(async () => undefined),
handleCut: vi.fn(async () => false),
@@ -6,6 +6,7 @@ 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 type { DomEditSelection } from "./domEditing";
import type { OverlayRect } from "./domEditOverlayGeometry";
@@ -27,6 +28,7 @@ const REST_RECT: OverlayRect = {
// Stable across renders on purpose: the test targets the `selection` identity
// key specifically, so `groupSelections` must not itself be a source of churn.
const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
let flushNudge = () => {};
function Harness({
selection,
@@ -35,7 +37,7 @@ function Harness({
selection: DomEditSelection | null;
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
}) {
useDomEditNudge({
flushNudge = useDomEditNudge({
selection,
groupSelections: EMPTY_GROUP_SELECTIONS,
allowCanvasMovement: true,
@@ -49,7 +51,7 @@ function Harness({
onBlockedMoveRef: makeRef(() => {}),
onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
onGroupPathOffsetCommitRef: makeRef(async () => {}),
});
}).flushNudge;
return null;
}
@@ -336,3 +338,88 @@ describe("useDomEditNudge carries the route its press chose", () => {
element.remove();
});
});
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,
}),
);
});
act(() => dispatchArrowRight());
let drained = false;
const drain = flushStudioPendingEdits().then(() => (drained = true));
expect(commit).toHaveBeenCalledTimes(1);
vi.useRealTimers();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(drained).toBe(false);
saved();
await drain;
expect(drained).toBe(true);
act(() => root.unmount());
});
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,
}),
);
});
expect(hasStudioPendingEdits()).toBe(false);
act(() => dispatchArrowRight());
expect(hasStudioPendingEdits()).toBe(true);
act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
expect(commit).toHaveBeenCalledTimes(1);
expect(hasStudioPendingEdits()).toBe(true);
vi.useRealTimers();
saved();
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
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();
const root = createRoot(document.body.appendChild(document.createElement("div")));
const element = document.body.appendChild(document.createElement("div"));
element.id = "dot-throws";
const commit = vi.fn(() => {
throw new Error("The commit threw.");
});
act(() => {
root.render(
React.createElement(Harness, {
selection: makeSelection("Dot", element),
onPathOffsetCommit: commit,
}),
);
});
act(() => dispatchArrowRight());
expect(hasStudioPendingEdits()).toBe(true);
expect(() => flushNudge()).toThrow("The commit threw.");
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
act(() => root.unmount());
});
});
@@ -9,6 +9,10 @@ import { useCallback, useEffect, useRef, type RefObject } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { ownsPlainKeys } from "../../utils/typingTarget";
import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
import {
addStudioPendingEditFlushListener,
trackStudioPendingEdit,
} from "../../utils/studioPendingEdits";
import type { DomEditSelection } from "./domEditing";
import {
type GroupOverlayItem,
@@ -43,6 +47,7 @@ interface NudgeSession {
/** Accumulated delta of the burst, in composition px. */
accum: { x: number; y: number };
timer: ReturnType<typeof setTimeout> | null;
endPendingEdit: (saved?: Promise<unknown>) => void;
}
export interface UseDomEditNudgeParams {
@@ -143,31 +148,37 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
// Commit the pending burst: one source write per burst = one undo entry.
// Mirrors the drag's onPointerUp — same commit callbacks, same failure
// restore, same member teardown.
const commitSession = () => {
const commitSession = (): Promise<unknown> | undefined => {
const session = sessionRef.current;
if (!session) return;
if (!session) return undefined;
sessionRef.current = null;
if (session.timer) clearTimeout(session.timer);
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
selection: member.selection,
next: applyManualOffsetNudgeCommit(member, session.accum),
plainTranslate: member.plainTranslate,
}));
const p = paramsRef.current;
const commit = session.isGroup
? p.onGroupPathOffsetCommitRef.current(updates)
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
plainTranslate: updates[0].plainTranslate,
});
void Promise.resolve(commit)
.catch(() => {
for (const member of session.members) {
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
restoreStudioPathOffset(member.element, member.initialPathOffset);
let saved: Promise<unknown> | undefined;
try {
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
selection: member.selection,
next: applyManualOffsetNudgeCommit(member, session.accum),
plainTranslate: member.plainTranslate,
}));
const commit = session.isGroup
? p.onGroupPathOffsetCommitRef.current(updates)
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
plainTranslate: updates[0].plainTranslate,
});
saved = Promise.resolve(commit)
.catch(() => {
for (const member of session.members) {
if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
restoreStudioPathOffset(member.element, member.initialPathOffset);
}
}
}
})
.finally(() => endManualOffsetDragMembers(session.members));
})
.finally(() => endManualOffsetDragMembers(session.members));
return saved;
} finally {
session.endPendingEdit(saved);
}
};
const commitSessionRef = useRef(commitSession);
commitSessionRef.current = commitSession;
@@ -179,6 +190,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
if (session.timer) clearTimeout(session.timer);
restoreManualOffsetDragMembers(session.members);
endManualOffsetDragMembers(session.members);
session.endPendingEdit();
}, [params.allowCanvasMovement]);
// Build drag members for the current target set — the same member snapshot a
@@ -205,7 +217,9 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
if (members.length === 0) return null;
// Same side effect a drag start has (pauses preview playback).
p.onManualDragStartRef.current?.();
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null };
let endPendingEdit: NudgeSession["endPendingEdit"] = () => {};
trackStudioPendingEdit(new Promise<unknown>((resolve) => (endPendingEdit = resolve)));
return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit };
};
const handleKeyDown = (event: KeyboardEvent) => {
@@ -231,8 +245,12 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
// Capture, like the other app-level key handlers, so a focused panel
// can't swallow the nudge before it reaches us.
window.addEventListener("keydown", listener, true);
const stopCommitOnUndoDrain = addStudioPendingEditFlushListener(() =>
commitSessionRef.current(),
);
return () => {
window.removeEventListener("keydown", listener, true);
stopCommitOnUndoDrain();
commitSessionRef.current();
};
});
@@ -245,7 +263,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
const selectionKey = selectionIdentityKey(params.selection);
const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
useEffect(() => () => void commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
// Claim the arrow keys from the playback frame-step while the selection is
// nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
import { DomEditProvider, useDomEditActionsContext } from "./DomEditContext";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const cleanup: Array<() => void> = [];
afterEach(() => cleanup.splice(0).forEach((step) => step()));
/** A resize whose save is still running, committed through the actions the canvas and panels use. */
function commitResize(save: Promise<void>) {
const handleDomBoxSizeCommit = vi.fn(() => save);
const value = { handleDomBoxSizeCommit } as unknown as Parameters<
typeof DomEditProvider
>[0]["value"];
let actions!: ReturnType<typeof useDomEditActionsContext>;
function Canvas() {
actions = useDomEditActionsContext();
return null;
}
const root = createRoot(document.createElement("div"));
act(() =>
root.render(
<DomEditProvider value={value}>
<Canvas />
</DomEditProvider>,
),
);
cleanup.push(() => act(() => root.unmount()));
const committed = actions.handleDomBoxSizeCommit(
{} as never,
{ width: 300, height: 200 },
undefined,
);
return { committed, handleDomBoxSizeCommit };
}
it("undo waits for an edit committed before its save has written anything", async () => {
let finish!: () => void;
const { committed } = commitResize(new Promise<void>((resolve) => (finish = resolve)));
let drained = false;
const drain = flushStudioPendingEdits().then(() => (drained = true));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(drained).toBe(false);
finish();
await drain;
await committed;
expect(drained).toBe(true);
});
it("an edit that fails is waited out and reported to the drain, like every tracked edit", async () => {
let fail!: (error: Error) => void;
const { committed } = commitResize(new Promise<void>((_, reject) => (fail = reject)));
const drain = flushStudioPendingEdits();
const failure = new Error("blocked");
fail(failure);
await expect(committed).rejects.toThrow("blocked");
await expect(drain).resolves.toEqual({ status: "failed", error: failure });
});
+90 -77
View File
@@ -2,85 +2,87 @@
import type { useDomEditSession } from "../hooks/useDomEditSession";
import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import { createStableContext } from "../utils/hmrStableContext";
import { trackedStudioEdit } from "../utils/studioPendingEdits";
type DomEditValue = ReturnType<typeof useDomEditSession>;
export interface DomEditActionsValue extends Pick<
DomEditValue,
| "handleTimelineElementSelect"
| "handlePreviewCanvasMouseDown"
| "handlePreviewCanvasPointerMove"
| "handlePreviewCanvasPointerLeave"
| "applyDomSelection"
| "clearDomSelection"
| "refreshDomEditSelectionFromPreview"
| "handleDomStyleCommit"
| "handleDomStyleCommitForSelection"
| "handleDomAttributeCommit"
| "handleDomAttributeLiveCommit"
| "handleDomAttributeQuietCommit"
| "handleDomHtmlAttributeCommit"
| "handleDomAttributesCommit"
| "handleDomPathOffsetCommit"
| "handleDomGroupPathOffsetCommit"
| "handleDomZIndexReorderCommit"
| "handleDomBoxSizeCommit"
| "handleDomRotationCommit"
| "handleDomManualEditsReset"
| "handleDomTextCommit"
| "handleDomTextCommitForSelection"
| "handleDomRichTextCommit"
| "handleDomTextFieldStyleCommit"
| "handleDomAddTextField"
| "handleDomRemoveTextField"
| "getGsapAnimationsForSelection"
| "handleAskAgent"
| "handleAgentModalSubmit"
| "handleBlockedDomMove"
| "handleDomManualDragStart"
| "handleDomEditElementDelete"
| "handleGroupSelection"
| "handleUngroupSelection"
| "setActiveGroupElement"
| "buildDomSelectionFromTarget"
| "buildDomSelectionForTimelineElement"
| "updateDomEditHoverSelection"
| "resolveImportedFontAsset"
| "setAgentModalOpen"
| "setAgentPromptSelectionContext"
| "setAgentModalAnchorPoint"
| "handleGsapUpdateProperty"
| "handleGsapUpdateMeta"
| "handleGsapDeleteAnimation"
| "handleGsapDeleteAllForElement"
| "handleGsapAddAnimation"
| "handleGsapAddProperty"
| "handleGsapRemoveProperty"
| "handleGsapUpdateFromProperty"
| "handleGsapAddFromProperty"
| "handleGsapRemoveFromProperty"
| "handleGsapAddKeyframe"
| "handleGsapAddKeyframeBatch"
| "handleGsapRemoveKeyframe"
| "handleGsapMoveKeyframeToPlayhead"
| "handleGsapMoveKeyframe"
| "handleGsapResizeKeyframedTween"
| "handleGsapConvertToKeyframes"
| "handleGsapRemoveAllKeyframes"
| "handleResetSelectedElementKeyframes"
| "commitAnimatedProperty"
| "commitAnimatedProperties"
| "handleSetArcPath"
| "handleUpdateArcSegment"
| "handleUnroll"
| "invalidateGsapCache"
| "previewIframeRef"
| "commitMutation"
| "applyMarqueeSelection"
| "handleUpdateKeyframeEase"
| "handleUpdateSegmentEase"
| "handleSetAllKeyframeEases"
> {}
const SAVES_AN_EDIT = {
handleTimelineElementSelect: false,
handlePreviewCanvasMouseDown: false,
handlePreviewCanvasPointerMove: false,
handlePreviewCanvasPointerLeave: false,
applyDomSelection: false,
clearDomSelection: false,
refreshDomEditSelectionFromPreview: false,
handleDomStyleCommit: true,
handleDomStyleCommitForSelection: true,
handleDomAttributeCommit: true,
handleDomAttributeLiveCommit: false,
handleDomAttributeQuietCommit: true,
handleDomHtmlAttributeCommit: true,
handleDomAttributesCommit: true,
handleDomPathOffsetCommit: true,
handleDomGroupPathOffsetCommit: true,
handleDomZIndexReorderCommit: true,
handleDomBoxSizeCommit: true,
handleDomRotationCommit: true,
handleDomManualEditsReset: true,
handleDomTextCommit: true,
handleDomTextCommitForSelection: true,
handleDomRichTextCommit: true,
handleDomTextFieldStyleCommit: true,
handleDomAddTextField: true,
handleDomRemoveTextField: true,
getGsapAnimationsForSelection: false,
handleAskAgent: false,
handleAgentModalSubmit: false,
handleBlockedDomMove: false,
handleDomManualDragStart: false,
handleDomEditElementDelete: true,
handleGroupSelection: true,
handleUngroupSelection: true,
setActiveGroupElement: false,
buildDomSelectionFromTarget: false,
buildDomSelectionForTimelineElement: false,
updateDomEditHoverSelection: false,
resolveImportedFontAsset: false,
setAgentModalOpen: false,
setAgentPromptSelectionContext: false,
setAgentModalAnchorPoint: false,
handleGsapUpdateProperty: true,
handleGsapUpdateMeta: true,
handleGsapDeleteAnimation: true,
handleGsapDeleteAllForElement: true,
handleGsapAddAnimation: true,
handleGsapAddProperty: true,
handleGsapRemoveProperty: true,
handleGsapUpdateFromProperty: true,
handleGsapAddFromProperty: true,
handleGsapRemoveFromProperty: true,
handleGsapAddKeyframe: true,
handleGsapAddKeyframeBatch: true,
handleGsapRemoveKeyframe: true,
handleGsapMoveKeyframeToPlayhead: true,
handleGsapMoveKeyframe: true,
handleGsapResizeKeyframedTween: true,
handleGsapConvertToKeyframes: true,
handleGsapRemoveAllKeyframes: true,
handleResetSelectedElementKeyframes: true,
commitAnimatedProperty: true,
commitAnimatedProperties: true,
handleSetArcPath: true,
handleUpdateArcSegment: true,
handleUnroll: true,
invalidateGsapCache: false,
previewIframeRef: false,
commitMutation: true,
applyMarqueeSelection: false,
handleUpdateKeyframeEase: true,
handleUpdateSegmentEase: true,
handleSetAllKeyframeEases: true,
} as const satisfies Partial<Record<keyof DomEditValue, boolean>>;
export interface DomEditActionsValue extends Pick<DomEditValue, keyof typeof SAVES_AN_EDIT> {}
export interface DomEditSelectionValue extends Pick<
DomEditValue,
@@ -98,6 +100,15 @@ export interface DomEditSelectionValue extends Pick<
| "agentPromptSelectionContext"
> {}
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);
}
return tracked as unknown as DomEditActionsValue;
}
const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
"DomEditActionsContext",
null,
@@ -242,7 +253,7 @@ export function DomEditProvider({
[],
);
const actions = useMemo<DomEditActionsValue>(
const untrackedActions = useMemo<DomEditActionsValue>(
() => ({
handleTimelineElementSelect,
handlePreviewCanvasMouseDown,
@@ -395,6 +406,8 @@ export function DomEditProvider({
],
);
const actions = useMemo(() => trackEditCommits(untrackedActions), [untrackedActions]);
const selection = useMemo<DomEditSelectionValue>(
() => ({
domEditSelection,
@@ -16,6 +16,7 @@ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
const domDelete = vi.fn(async () => undefined);
const historyUndo = vi.fn(async () => ({ ok: false }));
const historyRedo = vi.fn(async () => ({ ok: false }));
const settle = vi.fn(async () => undefined);
let root: Root | null = null;
let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null;
@@ -49,7 +50,7 @@ function Harness() {
writeProjectFile: vi.fn(async () => undefined),
showToast: vi.fn(),
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
waitForPendingDomEditSaves: vi.fn(async () => undefined),
settlePendingEdits: settle,
handleCopy: vi.fn(() => false),
handlePaste: vi.fn(() => false),
handleCut: vi.fn(() => false),
@@ -69,6 +70,8 @@ afterEach(() => {
domDelete.mockClear();
historyUndo.mockClear();
historyRedo.mockClear();
settle.mockReset();
settle.mockImplementation(async () => undefined);
vi.mocked(trackStudioEvent).mockClear();
});
@@ -122,6 +125,18 @@ describe("preview iframe hotkey forwarding", () => {
expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "undo" });
});
it("settles pending edits, failed ones included, before Cmd+Z steps the history", async () => {
let settled!: () => void;
settle.mockImplementation(
() => new Promise<undefined>((resolve) => (settled = () => resolve(undefined))),
);
await press(mountWithPreview(), { key: "z", ctrlKey: true });
expect(settle).toHaveBeenCalledTimes(1);
expect(historyUndo).not.toHaveBeenCalled();
await act(async () => settled());
expect(historyUndo).toHaveBeenCalledTimes(1);
});
it.each([
["Shift+Cmd+Z", { key: "Z", metaKey: true, shiftKey: true }],
["Ctrl+Y", { key: "y", ctrlKey: true }],
@@ -86,7 +86,7 @@ function Harness() {
writeProjectFile: vi.fn(async () => undefined),
showToast: vi.fn(),
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
waitForPendingDomEditSaves: vi.fn(async () => undefined),
settlePendingEdits: vi.fn(async () => undefined),
handleCopy: vi.fn(() => false),
handlePaste: vi.fn(async () => undefined),
handleCut: vi.fn(async () => false),
+3 -3
View File
@@ -85,7 +85,7 @@ interface UseAppHotkeysParams {
showToast: (message: string, tone?: "error" | "info") => void;
syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
waitForPendingDomEditSaves: () => Promise<void>;
settlePendingEdits: () => Promise<void>;
handleCopy: () => boolean;
handlePaste: () => Promise<void>;
handleCut: () => Promise<boolean>;
@@ -124,7 +124,7 @@ export function useAppHotkeys({
showToast,
syncHistoryPreviewAfterApply,
showHistoryRestoreNow,
waitForPendingDomEditSaves,
settlePendingEdits,
handleCopy,
handlePaste,
handleCut,
@@ -151,7 +151,7 @@ export function useAppHotkeys({
showToast,
syncHistoryPreviewAfterApply,
showHistoryRestoreNow,
waitForPendingDomEditSaves,
waitForPendingDomEditSaves: settlePendingEdits,
onAfterUndoRedo,
activeCompPath,
forceReloadSdkSession,
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { act, createElement } from "react";
import { act, createElement, type ReactElement } from "react";
import { createRoot } from "react-dom/client";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -10,6 +10,11 @@ import {
openProjectHistory,
type StudioApiAdapter,
} from "@hyperframes/studio-server";
import { useDomEditNudge } from "../components/editor/useDomEditNudge";
import { DomEditProvider, useDomEditActionsContext } from "../contexts/DomEditContext";
import { __resetForTests as resetNudgeKeys } from "../utils/canvasNudgeGate";
import { trackedStudioEdit } from "../utils/studioPendingEdits";
import { makeSelection } from "./domSelectionTestHarness";
import { useEditHistoryActions } from "./useEditHistoryActions";
import { usePersistentEditHistory } from "./usePersistentEditHistory";
import { usePreviewPersistence } from "./usePreviewPersistence";
@@ -19,6 +24,7 @@ const page = (left: string, top = "0px") =>
const BEFORE = page("10px");
const AFTER = page("50px");
const OUTSIDE = page("50px", "99px");
const NUDGED = page("51px");
const cleanup: Array<() => unknown> = [];
let scratch = "";
@@ -78,14 +84,15 @@ async function studio() {
showToast: () => {},
syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
showHistoryRestoreNow: persistence.showHistoryRestoreNow,
waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
waitForPendingDomEditSaves: persistence.settlePendingEdits,
});
return null;
}
const root = createRoot(document.createElement("div"));
await act(async () => root.render(createElement(Harness)));
cleanup.push(() => act(() => root.unmount()));
const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
const element = (id: string) => iframe.contentDocument!.getElementById(id)!;
const box = () => element("box").style.left;
const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
const show = (html: string) => {
iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
@@ -105,8 +112,16 @@ async function studio() {
);
await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
};
/** Mounts `ui` beside Studio, as the canvas is, for the rest of the test. */
const mount = (ui: ReactElement) => {
const canvas = createRoot(document.createElement("div"));
act(() => canvas.render(ui));
cleanup.push(() => act(() => canvas.unmount()));
};
return {
history: () => history,
element,
mount,
persistence: () => persistence,
actions: () => actions,
box,
@@ -164,3 +179,107 @@ it("an undo after an outside write ends with the preview showing the file the se
expect(s.other()).toBe("0px");
expect(s.box()).toBe("50px");
});
/** The canvas's arrow-key nudge on the box; `save` stands in for the burst's save once the keys stop. */
function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void> }) {
const selection = makeSelection("Box", target);
const ref = <T,>(current: T) => ({ current });
useDomEditNudge({
selection,
groupSelections: [],
allowCanvasMovement: true,
selectionRef: ref(selection),
overlayRectRef: ref({ left: 0, top: 0, width: 100, height: 40, editScaleX: 1, editScaleY: 1 }),
groupOverlayItemsRef: ref([]),
gestureRef: ref(null),
groupGestureRef: ref(null),
blockedMoveRef: ref(null),
onManualDragStartRef: ref(() => {}),
onBlockedMoveRef: ref(() => {}),
onPathOffsetCommitRef: ref(save),
onGroupPathOffsetCommitRef: ref(async () => {}),
});
return null;
}
it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
const s = await studio();
await s.edit();
resetNudgeKeys();
const save = vi.fn(async () => {
writeFileSync(s.path, NUDGED);
await s.history().recordEdit({
label: "Move layer",
files: { "index.html": { before: AFTER, after: NUDGED } },
});
});
s.mount(createElement(Nudge, { target: s.element("box"), save }));
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
});
const undone = s.actions().undo();
expect(s.box()).toBe("50px");
await act(() => undone);
expect(save).toHaveBeenCalledTimes(1);
expect(s.file()).toBe(AFTER);
expect(s.box()).toBe("50px");
});
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
const s = await studio();
await s.edit();
const box = s.element("box");
let fail!: () => void;
const handleDomStyleCommit = vi.fn(async () => {
box.style.left = "70px";
try {
await s.persistence().queueDomEditSave(async () => {
await new Promise<void>((resolve) => (fail = resolve));
throw new Error("The save failed.");
});
} catch (error) {
box.style.left = "50px";
throw error;
}
});
let actions!: ReturnType<typeof useDomEditActionsContext>;
function Canvas() {
actions = useDomEditActionsContext();
return null;
}
const value = { handleDomStyleCommit } as unknown as Parameters<
typeof DomEditProvider
>[0]["value"];
s.mount(
<DomEditProvider value={value}>
<Canvas />
</DomEditProvider>,
);
const failed = actions.handleDomStyleCommit("left", "70px");
const undone = s.actions().undo();
await vi.waitFor(() => expect(fail).toBeTypeOf("function"));
fail();
await expect(failed).rejects.toThrow("The save failed.");
await act(() => undone);
expect(s.file()).toBe(BEFORE);
expect(s.box()).toBe("10px");
});
it("an undo pressed while a tracked timeline edit fails still undoes the edit before it", async () => {
const s = await studio();
await s.edit();
let fail!: () => void;
const timelineEdit = trackedStudioEdit(
() => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
);
const failed = timelineEdit();
const undone = s.actions().undo();
fail();
await expect(failed).rejects.toThrow("The save failed.");
await act(() => undone);
expect(s.file()).toBe(BEFORE);
expect(s.box()).toBe("10px");
});
@@ -30,6 +30,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { MutationResult } from "./gsapScriptCommitTypes";
import { persistSdkSerialize } from "../utils/sdkCutover";
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
// ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
@@ -426,6 +427,24 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
);
}
describe("a GSAP script commit", () => {
it("counts as a pending edit from its call until it lands, so a quick Cmd+Z waits for it", async () => {
mockFetchResult();
const deps = renderCommitHook();
let committed!: Promise<unknown>;
act(() => {
committed = deps.api.commitMutation(
selection,
{ type: "remove-all-keyframes", animationId: "a" },
{ label: "Remove all keyframes" },
);
});
expect(hasStudioPendingEdits()).toBe(true);
await act(async () => void (await committed));
expect(hasStudioPendingEdits()).toBe(false);
});
});
describe("runCommit — instantPatch wiring", () => {
it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
mockFetchResult({ changed: false });
@@ -7,6 +7,7 @@ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"
import type { SoftReloadResult } from "../utils/gsapSoftReload";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
import { trackedStudioEdit } from "../utils/studioPendingEdits";
import {
getStudioSaveErrorMessage,
isStudioSaveErrorAlreadyToasted,
@@ -416,20 +417,20 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
}
return run();
};
const commit: CommitMutation = (selection, mutation, options) => {
const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
if (!activeProjectId) return Promise.resolve();
const file = selection.sourceFile || activeCompPath || "index.html";
return serializeCommit(file, options.serializeKey, () =>
runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
);
};
commit.batch = (calls, options) => {
}) as CommitMutation;
commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
if (!activeProjectId) return Promise.resolve();
const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
return serializeCommit(file, options.serializeKey, () =>
runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
);
};
});
return commit;
}, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]);
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
@@ -159,6 +159,11 @@ export function usePreviewPersistence({
if (result.status !== "clean") throw result.error;
}, [drainPendingDomEditSaves]);
const settlePendingEdits = useCallback(async (): Promise<void> => {
await flushStudioPendingEdits();
await domEditSaveQueueRef.current?.waitForIdle();
}, []);
const resetDomEditSaveQueueBreaker = useCallback(() => {
domEditSaveQueueRef.current?.reset();
setDomEditSaveQueuePaused(null);
@@ -253,6 +258,7 @@ export function usePreviewPersistence({
queueDomEditSave,
drainPendingDomEditSaves,
waitForPendingDomEditSaves,
settlePendingEdits,
domEditSaveQueuePaused,
resetDomEditSaveQueueBreaker,
applyCurrentStudioManualEditsToPreview,
@@ -1,5 +1,5 @@
import { useCallback, useRef } from "react";
import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
import { trackedStudioEdit } from "../utils/studioPendingEdits";
type AsyncFn = (...args: never[]) => Promise<unknown>;
@@ -11,11 +11,7 @@ export function useTrackPendingTimelineEdit() {
const key = fn as unknown as AsyncFn;
const cached = wrappedRef.current.get(key);
if (cached) return cached as unknown as (...args: Args) => Promise<R>;
const wrapped = (...args: Args): Promise<R> => {
const result = fn(...args);
trackStudioPendingEdit(result);
return result;
};
const wrapped = trackedStudioEdit(fn);
wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
return wrapped;
}, []);
@@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest";
import {
addStudioPendingEditFlushListener,
flushStudioPendingEdits,
hasStudioPendingEdits,
trackStudioPendingEdit,
trackedStudioEdit,
} from "./studioPendingEdits";
import { StudioFileConflictError } from "./studioSaveDiagnostics";
@@ -148,3 +150,48 @@ describe("studio pending edit flush", () => {
expect(steps).toEqual(["persisted", "flushed"]);
});
});
describe("a drain that meets a conflict", () => {
it("still waits for the edits that started while it ran before it reports the conflict", async () => {
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "v2",
currentContent: "external",
attemptedContent: "studio",
});
let finish!: () => void;
let laterSaved = false;
trackStudioPendingEdit(
Promise.resolve().then(() => {
trackStudioPendingEdit(
new Promise<void>((resolve) => (finish = resolve)).then(() => (laterSaved = true)),
);
throw conflict;
}),
);
let drained = false;
const drain = flushStudioPendingEdits().then((result) => ((drained = true), result));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(drained).toBe(false);
finish();
await expect(drain).resolves.toEqual({ status: "conflict", error: conflict });
expect(laterSaved).toBe(true);
});
});
describe("trackedStudioEdit", () => {
it("counts each call as a pending edit until it settles, and reports a failure to the drain", async () => {
let fail!: () => void;
const failure = new Error("The save failed.");
const edit = trackedStudioEdit(
() => new Promise<void>((_, reject) => (fail = () => reject(failure))),
);
const saved = edit();
expect(hasStudioPendingEdits()).toBe(true);
const drain = flushStudioPendingEdits();
fail();
await expect(saved).rejects.toThrow("The save failed.");
await expect(drain).resolves.toEqual({ status: "failed", error: failure });
expect(hasStudioPendingEdits()).toBe(false);
});
});
@@ -73,6 +73,16 @@ export function trackStudioPendingEdit(
return promise;
}
export function trackedStudioEdit<Args extends unknown[], R>(
edit: (...args: Args) => R,
): (...args: Args) => R {
return (...args) => {
const result = edit(...args);
if (result instanceof Promise) trackStudioPendingEdit(result);
return result;
};
}
export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrainResult> {
const active = focusedField();
if (active) {
@@ -86,15 +96,16 @@ export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrain
window.dispatchEvent(
new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
);
let conflict: StudioFileConflictError | undefined;
let firstFailure: PromiseRejectedResult | undefined;
while (detail.promises.length > 0 || pendingEditPromises.size > 0) {
const promises = [...detail.promises, ...pendingEditPromises];
detail.promises = [];
const results = await Promise.allSettled(promises);
const batchFailures = inspectDrainFailures(results);
if (batchFailures.conflict) return { status: "conflict", error: batchFailures.conflict };
const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
conflict ??= batchFailures.conflict;
firstFailure ??= batchFailures.firstFailure;
}
if (conflict) return { status: "conflict", error: conflict };
return firstFailure ? { status: "failed", error: firstFailure.reason } : { status: "clean" };
}
@@ -911,12 +911,12 @@
"seqnudge-none-pct-r0-root-z100": {"pass":true,"tracking":0.05,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":5.56,"frameP95":18.8},
"seqnudge-none-px-r0-nested-z100": {"pass":false,"tracking":0.05,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":1,"controlDropped":0,"work":10.13,"frameP95":17.9},
"seqnudge-none-px-r0-root-z100": {"pass":true,"tracking":0.03,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"work":6.24,"frameP95":18.5},
"seqnudgeundo-none-center-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1.04,"reload":1.04,"render":0.04,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-center-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1.04,"reload":1.04,"render":0.04,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-pct-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1,"reload":1,"render":0.03,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-pct-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1,"reload":1,"render":0.03,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-px-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1.04,"reload":1.04,"render":0.04,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-px-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1.04,"reload":1.04,"render":0.02,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-center-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-center-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-pct-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-pct-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-px-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqnudgeundo-none-px-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null},
"seqrepeat-none-center-r0-nested-z100": {"pass":true,"tracking":0.05,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"work":4.3,"frameP95":17.5},
"seqrepeat-none-center-r0-root-z100": {"pass":true,"tracking":0.05,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"work":4.93,"frameP95":18.4},
"seqrepeat-none-pct-r0-nested-z100": {"pass":true,"tracking":0.03,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":4.68,"frameP95":18.1},