From 3b9e033aa211a661dbb5f55a878c115b8f124c90 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 15:35:32 -0700 Subject: [PATCH 1/8] fix(studio): undo right after an edit undoes that edit, even before its save lands --- .../src/contexts/DomEditContext.test.tsx | 61 ++++++++++++++++ .../studio/src/contexts/DomEditContext.tsx | 72 ++++++++++++++++++- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/contexts/DomEditContext.test.tsx diff --git a/packages/studio/src/contexts/DomEditContext.test.tsx b/packages/studio/src/contexts/DomEditContext.test.tsx new file mode 100644 index 0000000000..cf8921ea88 --- /dev/null +++ b/packages/studio/src/contexts/DomEditContext.test.tsx @@ -0,0 +1,61 @@ +// @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) { + const handleDomBoxSizeCommit = vi.fn(() => save); + const value = { handleDomBoxSizeCommit } as unknown as Parameters< + typeof DomEditProvider + >[0]["value"]; + let actions!: ReturnType; + function Canvas() { + actions = useDomEditActionsContext(); + return null; + } + const root = createRoot(document.createElement("div")); + act(() => + root.render( + + + , + ), + ); + 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((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 does not fail the undo that waited for it", async () => { + let fail!: (error: Error) => void; + const { committed } = commitResize(new Promise((_, reject) => (fail = reject))); + const drain = flushStudioPendingEdits(); + fail(new Error("blocked")); + await expect(committed).rejects.toThrow("blocked"); + await expect(drain).resolves.toEqual({ status: "clean" }); +}); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index c39400c0d3..70561afdc5 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -2,6 +2,7 @@ import type { useDomEditSession } from "../hooks/useDomEditSession"; import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react"; import { createStableContext } from "../utils/hmrStableContext"; +import { trackStudioPendingEdit } from "../utils/studioPendingEdits"; type DomEditValue = ReturnType; @@ -98,6 +99,73 @@ export interface DomEditSelectionValue extends Pick< | "agentPromptSelectionContext" > {} +/** Every action that saves an edit. Undo drains them, so each counts from its call, not from its first write. */ +const EDIT_COMMITS = [ + "handleDomStyleCommit", + "handleDomStyleCommitForSelection", + "handleDomAttributeCommit", + "handleDomAttributeQuietCommit", + "handleDomHtmlAttributeCommit", + "handleDomAttributesCommit", + "handleDomPathOffsetCommit", + "handleDomGroupPathOffsetCommit", + "handleDomZIndexReorderCommit", + "handleDomBoxSizeCommit", + "handleDomRotationCommit", + "handleDomManualEditsReset", + "handleDomTextCommit", + "handleDomTextCommitForSelection", + "handleDomRichTextCommit", + "handleDomTextFieldStyleCommit", + "handleDomAddTextField", + "handleDomRemoveTextField", + "handleDomEditElementDelete", + "handleGroupSelection", + "handleUngroupSelection", + "handleGsapUpdateProperty", + "handleGsapUpdateMeta", + "handleGsapDeleteAnimation", + "handleGsapDeleteAllForElement", + "handleGsapAddAnimation", + "handleGsapAddProperty", + "handleGsapRemoveProperty", + "handleGsapUpdateFromProperty", + "handleGsapAddFromProperty", + "handleGsapRemoveFromProperty", + "handleGsapAddKeyframe", + "handleGsapAddKeyframeBatch", + "handleGsapRemoveKeyframe", + "handleGsapMoveKeyframeToPlayhead", + "handleGsapMoveKeyframe", + "handleGsapResizeKeyframedTween", + "handleGsapConvertToKeyframes", + "handleGsapRemoveAllKeyframes", + "handleResetSelectedElementKeyframes", + "commitAnimatedProperty", + "commitAnimatedProperties", + "handleSetArcPath", + "handleUpdateArcSegment", + "handleUnroll", + "commitMutation", + "handleUpdateKeyframeEase", + "handleUpdateSegmentEase", + "handleSetAllKeyframeEases", +] as const satisfies ReadonlyArray; + +// A failed edit rolls itself back and reports itself; undo only has to wait for it to settle. +function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue { + const tracked: Record = { ...actions }; + for (const key of EDIT_COMMITS) { + const commit = actions[key] as (...args: unknown[]) => unknown; + tracked[key] = (...args: unknown[]) => { + const result = commit(...args); + if (result instanceof Promise) trackStudioPendingEdit(result.catch(() => undefined)); + return result; + }; + } + return tracked as unknown as DomEditActionsValue; +} + const DomEditActionsContext = createStableContext( "DomEditActionsContext", null, @@ -242,7 +310,7 @@ export function DomEditProvider({ [], ); - const actions = useMemo( + const untrackedActions = useMemo( () => ({ handleTimelineElementSelect, handlePreviewCanvasMouseDown, @@ -395,6 +463,8 @@ export function DomEditProvider({ ], ); + const actions = useMemo(() => trackEditCommits(untrackedActions), [untrackedActions]); + const selection = useMemo( () => ({ domEditSelection, From fa118b2f69685762b7d8ee3b7a98950330f9ebbe Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 30 Sep 2026 16:04:14 -0700 Subject: [PATCH 2/8] fix(studio): undo right after a nudge undoes the nudge --- .../editor/useDomEditNudge.test.tsx | 30 +++++++++++++++++++ .../src/components/editor/useDomEditNudge.ts | 13 +++++--- .../studio/src/contexts/DomEditContext.tsx | 1 - 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index 71f28139af..9713aae828 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -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 } from "../../utils/studioPendingEdits"; import type { DomEditSelection } from "./domEditing"; import type { OverlayRect } from "./domEditOverlayGeometry"; @@ -291,3 +292,32 @@ 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(); + 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, + }), + ); + }); + act(() => dispatchArrowRight()); + + let drained = false; + const drain = flushStudioPendingEdits().then(() => (drained = true)); + await vi.waitFor(() => expect(commit).toHaveBeenCalledTimes(1)); + expect(drained).toBe(false); + saved(); + await drain; + expect(drained).toBe(true); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index cdfc7ed3f3..f348f93b25 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -9,6 +9,7 @@ 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 } from "../../utils/studioPendingEdits"; import type { DomEditSelection } from "./domEditing"; import { type GroupOverlayItem, @@ -143,9 +144,9 @@ 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 | 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) => ({ @@ -159,7 +160,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, { plainTranslate: updates[0].plainTranslate, }); - void Promise.resolve(commit) + return Promise.resolve(commit) .catch(() => { for (const member of session.members) { if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) { @@ -231,8 +232,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 +250,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 diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 70561afdc5..7425b754f8 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -152,7 +152,6 @@ const EDIT_COMMITS = [ "handleSetAllKeyframeEases", ] as const satisfies ReadonlyArray; -// A failed edit rolls itself back and reports itself; undo only has to wait for it to settle. function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue { const tracked: Record = { ...actions }; for (const key of EDIT_COMMITS) { From fa8fd4cc54dd6384ce6aa68b7f4ff576cbd9e3fa Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 07:18:43 -0700 Subject: [PATCH 3/8] fix(studio): undo during a nudge's wait for more keys never paints the edit before it --- .../editor/useDomEditNudge.test.tsx | 30 ++- .../src/components/editor/useDomEditNudge.ts | 15 +- .../studio/src/contexts/DomEditContext.tsx | 209 +++++++----------- .../useEditHistoryActions.paint.test.tsx | 98 +++++++- 4 files changed, 216 insertions(+), 136 deletions(-) diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index 9713aae828..a1643f93fc 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -6,7 +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 } from "../../utils/studioPendingEdits"; +import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits"; import type { DomEditSelection } from "./domEditing"; import type { OverlayRect } from "./domEditOverlayGeometry"; @@ -320,4 +320,32 @@ describe("useDomEditNudge — undo right after a burst", () => { 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((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()); + }); }); diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index f348f93b25..164591a158 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -9,7 +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 } from "../../utils/studioPendingEdits"; +import { + addStudioPendingEditFlushListener, + trackStudioPendingEdit, +} from "../../utils/studioPendingEdits"; import type { DomEditSelection } from "./domEditing"; import { type GroupOverlayItem, @@ -44,6 +47,7 @@ interface NudgeSession { /** Accumulated delta of the burst, in composition px. */ accum: { x: number; y: number }; timer: ReturnType | null; + endPendingEdit: (saved?: Promise) => void; } export interface UseDomEditNudgeParams { @@ -160,7 +164,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, { plainTranslate: updates[0].plainTranslate, }); - return Promise.resolve(commit) + const saved = Promise.resolve(commit) .catch(() => { for (const member of session.members) { if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) { @@ -169,6 +173,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () } }) .finally(() => endManualOffsetDragMembers(session.members)); + session.endPendingEdit(saved); + return saved; }; const commitSessionRef = useRef(commitSession); commitSessionRef.current = commitSession; @@ -180,6 +186,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 @@ -206,7 +213,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((resolve) => (endPendingEdit = resolve))); + return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit }; }; const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 7425b754f8..1cb54523f6 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -6,82 +6,83 @@ import { trackStudioPendingEdit } from "../utils/studioPendingEdits"; type DomEditValue = ReturnType; -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>; + +export interface DomEditActionsValue extends Pick {} export interface DomEditSelectionValue extends Pick< DomEditValue, @@ -99,62 +100,10 @@ export interface DomEditSelectionValue extends Pick< | "agentPromptSelectionContext" > {} -/** Every action that saves an edit. Undo drains them, so each counts from its call, not from its first write. */ -const EDIT_COMMITS = [ - "handleDomStyleCommit", - "handleDomStyleCommitForSelection", - "handleDomAttributeCommit", - "handleDomAttributeQuietCommit", - "handleDomHtmlAttributeCommit", - "handleDomAttributesCommit", - "handleDomPathOffsetCommit", - "handleDomGroupPathOffsetCommit", - "handleDomZIndexReorderCommit", - "handleDomBoxSizeCommit", - "handleDomRotationCommit", - "handleDomManualEditsReset", - "handleDomTextCommit", - "handleDomTextCommitForSelection", - "handleDomRichTextCommit", - "handleDomTextFieldStyleCommit", - "handleDomAddTextField", - "handleDomRemoveTextField", - "handleDomEditElementDelete", - "handleGroupSelection", - "handleUngroupSelection", - "handleGsapUpdateProperty", - "handleGsapUpdateMeta", - "handleGsapDeleteAnimation", - "handleGsapDeleteAllForElement", - "handleGsapAddAnimation", - "handleGsapAddProperty", - "handleGsapRemoveProperty", - "handleGsapUpdateFromProperty", - "handleGsapAddFromProperty", - "handleGsapRemoveFromProperty", - "handleGsapAddKeyframe", - "handleGsapAddKeyframeBatch", - "handleGsapRemoveKeyframe", - "handleGsapMoveKeyframeToPlayhead", - "handleGsapMoveKeyframe", - "handleGsapResizeKeyframedTween", - "handleGsapConvertToKeyframes", - "handleGsapRemoveAllKeyframes", - "handleResetSelectedElementKeyframes", - "commitAnimatedProperty", - "commitAnimatedProperties", - "handleSetArcPath", - "handleUpdateArcSegment", - "handleUnroll", - "commitMutation", - "handleUpdateKeyframeEase", - "handleUpdateSegmentEase", - "handleSetAllKeyframeEases", -] as const satisfies ReadonlyArray; - function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue { const tracked: Record = { ...actions }; - for (const key of EDIT_COMMITS) { + for (const key of Object.keys(SAVES_AN_EDIT) as Array) { + if (!SAVES_AN_EDIT[key]) continue; const commit = actions[key] as (...args: unknown[]) => unknown; tracked[key] = (...args: unknown[]) => { const result = commit(...args); diff --git a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx index de1fc622d6..51109bd37f 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx +++ b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx @@ -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,10 @@ 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 { makeSelection } from "./domSelectionTestHarness"; import { useEditHistoryActions } from "./useEditHistoryActions"; import { usePersistentEditHistory } from "./usePersistentEditHistory"; import { usePreviewPersistence } from "./usePreviewPersistence"; @@ -19,6 +23,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 = ""; @@ -85,7 +90,8 @@ async function studio() { 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 +111,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 +178,83 @@ 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 }) { + const selection = makeSelection("Box", target); + const ref = (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 an edit's 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"; + await new Promise((resolve) => (fail = resolve)); + box.style.left = "50px"; + throw new Error("The save failed."); + }); + let actions!: ReturnType; + function Canvas() { + actions = useDomEditActionsContext(); + return null; + } + const value = { handleDomStyleCommit } as unknown as Parameters< + typeof DomEditProvider + >[0]["value"]; + s.mount( + + + , + ); + const failed = actions.handleDomStyleCommit("left", "70px"); + + 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"); +}); From bfe338d24e1efeef88d63f8b38c8e2d407475f2f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 09:33:18 -0700 Subject: [PATCH 4/8] fix(studio): a failed save never blocks undo, and a nudge burst always ends its pending edit --- packages/studio/src/App.tsx | 2 +- .../editor/useDomEditNudge.test.tsx | 35 +++++++++++++++++-- .../src/components/editor/useDomEditNudge.ts | 34 ++++++++++-------- .../useEditHistoryActions.paint.test.tsx | 17 ++++++--- .../studio/src/hooks/usePreviewPersistence.ts | 6 ++++ 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 38d728b393..0a2f66e4bb 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -256,7 +256,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St showToast, syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply, showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow, - waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, + waitForPendingDomEditSaves: previewPersistence.settlePendingEdits, handleCopy, handlePaste, handleCut, diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index a1643f93fc..e0cac33e20 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -28,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, @@ -36,7 +37,7 @@ function Harness({ selection: DomEditSelection | null; onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"]; }) { - useDomEditNudge({ + flushNudge = useDomEditNudge({ selection, groupSelections: EMPTY_GROUP_SELECTIONS, allowCanvasMovement: true, @@ -50,7 +51,7 @@ function Harness({ onBlockedMoveRef: makeRef(() => {}), onPathOffsetCommitRef: makeRef(onPathOffsetCommit), onGroupPathOffsetCommitRef: makeRef(async () => {}), - }); + }).flushNudge; return null; } @@ -296,6 +297,7 @@ describe("useDomEditNudge carries the route its press chose", () => { 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"; @@ -313,7 +315,9 @@ describe("useDomEditNudge — undo right after a burst", () => { let drained = false; const drain = flushStudioPendingEdits().then(() => (drained = true)); - await vi.waitFor(() => expect(commit).toHaveBeenCalledTimes(1)); + expect(commit).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + await new Promise((resolve) => setTimeout(resolve, 20)); expect(drained).toBe(false); saved(); await drain; @@ -349,3 +353,28 @@ describe("useDomEditNudge — undo right after a burst", () => { 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()); + }); +}); diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index 164591a158..8e6ee1150a 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -159,22 +159,26 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () 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, - }); - const saved = Promise.resolve(commit) - .catch(() => { - for (const member of session.members) { - if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) { - restoreStudioPathOffset(member.element, member.initialPathOffset); + let saved: Promise | undefined; + try { + 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)); - session.endPendingEdit(saved); - return saved; + }) + .finally(() => endManualOffsetDragMembers(session.members)); + return saved; + } finally { + session.endPendingEdit(saved); + } }; const commitSessionRef = useRef(commitSession); commitSessionRef.current = commitSession; diff --git a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx index 51109bd37f..acd517d074 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx +++ b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx @@ -83,7 +83,7 @@ async function studio() { showToast: () => {}, syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply, showHistoryRestoreNow: persistence.showHistoryRestoreNow, - waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves, + waitForPendingDomEditSaves: persistence.settlePendingEdits, }); return null; } @@ -225,16 +225,22 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor expect(s.box()).toBe("50px"); }); -it("an undo pressed while an edit's save fails undoes the edit before it, file and box alike", async () => { +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"; - await new Promise((resolve) => (fail = resolve)); - box.style.left = "50px"; - throw new Error("The save failed."); + try { + await s.persistence().queueDomEditSave(async () => { + await new Promise((resolve) => (fail = resolve)); + throw new Error("The save failed."); + }); + } catch (error) { + box.style.left = "50px"; + throw error; + } }); let actions!: ReturnType; function Canvas() { @@ -252,6 +258,7 @@ it("an undo pressed while an edit's save fails undoes the edit before it, file a 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); diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index 3b6144ab62..cff8f2b62a 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -159,6 +159,11 @@ export function usePreviewPersistence({ if (result.status !== "clean") throw result.error; }, [drainPendingDomEditSaves]); + const settlePendingEdits = useCallback(async (): Promise => { + 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, From 6283eab719dcd91d44dfecf639c847bf7f6595b1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 10:28:05 -0700 Subject: [PATCH 5/8] fix(studio): undo waits out every failed or conflicting edit, and Delete counts as pending --- packages/studio/src/App.tsx | 11 +++-- .../components/editor/SnapToolbar.test.tsx | 2 +- .../src/components/editor/useDomEditNudge.ts | 10 ++-- .../src/contexts/DomEditContext.test.tsx | 7 +-- .../studio/src/contexts/DomEditContext.tsx | 9 +--- .../useAppHotkeys.previewForwarding.test.tsx | 17 ++++++- .../hooks/useAppHotkeys.textEditing.test.tsx | 2 +- packages/studio/src/hooks/useAppHotkeys.ts | 6 +-- .../useEditHistoryActions.paint.test.tsx | 18 +++++++ .../src/hooks/useTrackPendingTimelineEdit.ts | 8 +--- .../src/utils/studioPendingEdits.test.ts | 47 +++++++++++++++++++ .../studio/src/utils/studioPendingEdits.ts | 17 +++++-- 12 files changed, 121 insertions(+), 33 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 0a2f66e4bb..db88431151 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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.settlePendingEdits, + settlePendingEdits: previewPersistence.settlePendingEdits, handleCopy, handlePaste, handleCut, @@ -315,9 +316,13 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St readOnlyPreview, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; - handleDomZIndexReorderCommitRef.current = domEditSession.handleDomZIndexReorderCommit; + handleDomZIndexReorderCommitRef.current = trackedStudioEdit( + domEditSession.handleDomZIndexReorderCommit, + ); clearDomSelectionRef.current = domEditSession.clearDomSelection; - handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; + handleDomEditElementDeleteRef.current = trackedStudioEdit( + domEditSession.handleDomEditElementDelete, + ); resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession); diff --git a/packages/studio/src/components/editor/SnapToolbar.test.tsx b/packages/studio/src/components/editor/SnapToolbar.test.tsx index 5435f16dd3..1b508fd229 100644 --- a/packages/studio/src/components/editor/SnapToolbar.test.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.test.tsx @@ -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), diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index 8e6ee1150a..bec39eb473 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -153,14 +153,14 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () 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; let saved: Promise | 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, { diff --git a/packages/studio/src/contexts/DomEditContext.test.tsx b/packages/studio/src/contexts/DomEditContext.test.tsx index cf8921ea88..20232af5a8 100644 --- a/packages/studio/src/contexts/DomEditContext.test.tsx +++ b/packages/studio/src/contexts/DomEditContext.test.tsx @@ -51,11 +51,12 @@ it("undo waits for an edit committed before its save has written anything", asyn expect(drained).toBe(true); }); -it("an edit that fails does not fail the undo that waited for it", async () => { +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((_, reject) => (fail = reject))); const drain = flushStudioPendingEdits(); - fail(new Error("blocked")); + const failure = new Error("blocked"); + fail(failure); await expect(committed).rejects.toThrow("blocked"); - await expect(drain).resolves.toEqual({ status: "clean" }); + await expect(drain).resolves.toEqual({ status: "failed", error: failure }); }); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 1cb54523f6..4781410626 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -2,7 +2,7 @@ import type { useDomEditSession } from "../hooks/useDomEditSession"; import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react"; import { createStableContext } from "../utils/hmrStableContext"; -import { trackStudioPendingEdit } from "../utils/studioPendingEdits"; +import { trackedStudioEdit } from "../utils/studioPendingEdits"; type DomEditValue = ReturnType; @@ -104,12 +104,7 @@ 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; - const commit = actions[key] as (...args: unknown[]) => unknown; - tracked[key] = (...args: unknown[]) => { - const result = commit(...args); - if (result instanceof Promise) trackStudioPendingEdit(result.catch(() => undefined)); - return result; - }; + tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown); } return tracked as unknown as DomEditActionsValue; } diff --git a/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx b/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx index 0d2167836a..139c6fbbde 100644 --- a/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx +++ b/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx @@ -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((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 }], diff --git a/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx b/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx index 2958e28d66..cee5bb8e55 100644 --- a/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx +++ b/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx @@ -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), diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index eba9d0ecc4..69b07f43ba 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -85,7 +85,7 @@ interface UseAppHotkeysParams { showToast: (message: string, tone?: "error" | "info") => void; syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"]; showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"]; - waitForPendingDomEditSaves: () => Promise; + settlePendingEdits: () => Promise; handleCopy: () => boolean; handlePaste: () => Promise; handleCut: () => Promise; @@ -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, diff --git a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx index acd517d074..1cb71620c7 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx +++ b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx @@ -13,6 +13,7 @@ import { 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"; @@ -265,3 +266,20 @@ it("an undo pressed while a queued save fails undoes the edit before it, file an 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((_, 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"); +}); diff --git a/packages/studio/src/hooks/useTrackPendingTimelineEdit.ts b/packages/studio/src/hooks/useTrackPendingTimelineEdit.ts index 9687e671f0..4d38460444 100644 --- a/packages/studio/src/hooks/useTrackPendingTimelineEdit.ts +++ b/packages/studio/src/hooks/useTrackPendingTimelineEdit.ts @@ -1,5 +1,5 @@ import { useCallback, useRef } from "react"; -import { trackStudioPendingEdit } from "../utils/studioPendingEdits"; +import { trackedStudioEdit } from "../utils/studioPendingEdits"; type AsyncFn = (...args: never[]) => Promise; @@ -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; - const wrapped = (...args: Args): Promise => { - const result = fn(...args); - trackStudioPendingEdit(result); - return result; - }; + const wrapped = trackedStudioEdit(fn); wrappedRef.current.set(key, wrapped as unknown as AsyncFn); return wrapped; }, []); diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index d9837a3251..175b1adf63 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -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((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((_, 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); + }); +}); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 7d43aa47d9..ec013a2496 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -73,6 +73,16 @@ export function trackStudioPendingEdit( return promise; } +export function trackedStudioEdit( + 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 { const active = focusedField(); if (active) { @@ -86,15 +96,16 @@ export async function flushStudioPendingEdits(): Promise(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" }; } From 00a18446b9323ad3e11d77b98d26b085827333e9 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 10:40:30 -0700 Subject: [PATCH 6/8] fix(studio): every GSAP script commit counts as a pending edit, so undo waits for it --- .../src/hooks/useGsapScriptCommits.test.tsx | 19 +++++++++++++++++++ .../studio/src/hooks/useGsapScriptCommits.ts | 9 +++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index e4ed1a27ca..b6a1cd721a 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -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 = {}): 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; + 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 }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index efdad24d3a..2901b1149e 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -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); From 0532755df46a27bd4bbd47dea4da43e4863a6027 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 12:15:52 -0700 Subject: [PATCH 7/8] fix(studio): keep App under the file size limit --- packages/studio/src/App.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index db88431151..46d5c04b0b 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -316,13 +316,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St readOnlyPreview, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; - handleDomZIndexReorderCommitRef.current = trackedStudioEdit( - domEditSession.handleDomZIndexReorderCommit, - ); + const { handleDomZIndexReorderCommit: zCommit, handleDomEditElementDelete: del } = domEditSession; + handleDomZIndexReorderCommitRef.current = trackedStudioEdit(zCommit); clearDomSelectionRef.current = domEditSession.clearDomSelection; - handleDomEditElementDeleteRef.current = trackedStudioEdit( - domEditSession.handleDomEditElementDelete, - ); + handleDomEditElementDeleteRef.current = trackedStudioEdit(del); resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes; invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache; deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession); From 0ee95b25af4ad20acbfc4207eee6919dc259bd17 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 14:47:46 -0700 Subject: [PATCH 8/8] test(studio): bank the nudge-then-undo sequences the gate now passes --- .../studio/tests/e2e/edit-accuracy/baseline.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/studio/tests/e2e/edit-accuracy/baseline.json b/packages/studio/tests/e2e/edit-accuracy/baseline.json index 977add4d30..47c49579a7 100644 --- a/packages/studio/tests/e2e/edit-accuracy/baseline.json +++ b/packages/studio/tests/e2e/edit-accuracy/baseline.json @@ -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},