Skip to content

Commit 2278c11

Browse files
committed
fix(studio): honor DOM edit failure outcomes
1 parent 6cbbac0 commit 2278c11

9 files changed

Lines changed: 148 additions & 46 deletions

packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// @vitest-environment happy-dom
22

33
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
45
import type { DomEditSelection } from "./domEditing";
56
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
67

@@ -75,7 +76,9 @@ interface CommitCall {
7576
offset: { x: number; y: number } | undefined;
7677
}
7778

78-
function buildHarness() {
79+
function buildHarness(
80+
onBoxSizeCommit?: UseDomEditOverlayGesturesOptions["onBoxSizeCommitRef"]["current"],
81+
) {
7982
const element = document.createElement("div");
8083
document.body.append(element);
8184

@@ -136,9 +139,12 @@ function buildHarness() {
136139
onManualDragStartRef: ref(() => {}),
137140
onPathOffsetCommitRef: ref(() => {}),
138141
onGroupPathOffsetCommitRef: ref(() => {}),
139-
onBoxSizeCommitRef: ref((_s, size, offset) => {
140-
commits.push({ size, offset });
141-
}),
142+
onBoxSizeCommitRef: ref(
143+
onBoxSizeCommit ??
144+
((_s, size, offset) => {
145+
commits.push({ size, offset });
146+
}),
147+
),
142148
onRotationCommitRef: ref(() => {}),
143149
onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
144150
onCanvasMouseDown: () => {},
@@ -172,6 +178,15 @@ function evt(clientX: number, clientY: number) {
172178
} as unknown as React.PointerEvent<HTMLDivElement>;
173179
}
174180

181+
async function finishResize(handlers: ReturnType<typeof createDomEditOverlayGestureHandlers>) {
182+
handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
183+
resizeHandle: "se",
184+
});
185+
handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
186+
handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
187+
await Promise.resolve();
188+
}
189+
175190
afterEach(() => {
176191
document.body.innerHTML = "";
177192
});
@@ -211,4 +226,23 @@ describe("anchored corner resize — the release commit feeds the center-pin off
211226
expect(offset.x).toBeCloseTo(-(size.width - ORIGIN.width) / 2, 0);
212227
expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
213228
});
229+
230+
it("does not log a paused save queue as an ordinary resize failure", async () => {
231+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
232+
const { handlers } = buildHarness(() => Promise.reject(new DomEditSaveQueueOpenError()));
233+
234+
await finishResize(handlers);
235+
236+
expect(consoleError).not.toHaveBeenCalled();
237+
});
238+
239+
it("still logs an ordinary resize failure", async () => {
240+
const failure = new Error("save failed");
241+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
242+
const { handlers } = buildHarness(() => Promise.reject(failure));
243+
244+
await finishResize(handlers);
245+
246+
expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
247+
});
214248
});

packages/studio/src/components/editor/useDomEditOverlayGestures.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ import {
5858
import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDebug";
5959
import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
6060
import { createGroupDragMover } from "./groupDragMove";
61+
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
62+
63+
function logGestureCommitFailure(message: string, error: unknown): void {
64+
if (error instanceof DomEditSaveQueueOpenError) return;
65+
console.error(message, error);
66+
}
6167

6268
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
6369
const setDraftOverlayRect = (next: OverlayRect) => {
@@ -409,7 +415,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
409415
}
410416
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
411417
.catch((error) => {
412-
console.error("rotate commit failed", error);
418+
logGestureCommitFailure("rotate commit failed", error);
413419
if (
414420
g.manualEditDragToken &&
415421
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
@@ -493,7 +499,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
493499
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
494500
)
495501
.catch((error) => {
496-
console.error("resize commit failed", error);
502+
logGestureCommitFailure("resize commit failed", error);
497503
})
498504
.finally(() => {
499505
if (member) endManualOffsetDragMembers([member]);

packages/studio/src/hooks/domEditCommitRunner.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,10 @@ export async function runDomEditCommit(config: DomEditCommitRunnerConfig): Promi
6161
*
6262
* `runDomEditCommit` resolves on persist failure by design (see its contract
6363
* above), so a caller cannot learn whether the write landed by awaiting it — a
64-
* failed commit and a successful one are indistinguishable. The human path does
65-
* not need to ask, because `onError` already put a toast on screen. A
66-
* programmatic caller has no screen, so it has to be told.
64+
* failed persist and a successful one are indistinguishable. Capture and apply
65+
* bugs still reject. The human path does not need to ask about handled persist
66+
* failures, because `onError` already put a toast on screen. A programmatic
67+
* caller has no screen, so it has to be told.
6768
*/
6869
export type DomEditCommitDeclineReason =
6970
| "no-project"

packages/studio/src/hooks/useDomEditWiring.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export interface UseDomEditWiringParams {
107107
resolvedFromValues?: Record<string, number | string>,
108108
) => Promise<void>;
109109
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
110-
handleDomManualEditsReset: (sel: DomEditSelection) => void;
110+
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
111111
}
112112

113113
// fallow-ignore-next-line complexity

packages/studio/src/hooks/useDomGeometryCommits.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe("useDomGeometryCommits rollback", () => {
5252
commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
5353
).rejects.toBe(failure);
5454
await expect(commits!.handleDomRotationCommit(selection, { angle: 45 })).rejects.toBe(failure);
55+
await expect(commits!.handleDomManualEditsReset(selection)).rejects.toBe(failure);
5556

5657
expect(readStudioPathOffset(element)).toEqual({ x: 10, y: 20 });
5758
expect(readStudioBoxSize(element)).toEqual({ width: 100, height: 80 });

packages/studio/src/hooks/useDomGeometryCommits.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ export function useDomGeometryCommits({
130130
const handleDomManualEditsReset = useCallback(
131131
(selection: DomEditSelection) => {
132132
const element = selection.element;
133+
const beforeOffset = captureStudioPathOffset(element);
134+
const beforeSize = captureStudioBoxSize(element);
135+
const beforeRotation = captureStudioRotation(element);
133136
const clearPatches = [
134137
...buildClearPathOffsetPatches(element),
135138
...buildClearBoxSizePatches(element),
@@ -139,11 +142,16 @@ export function useDomGeometryCommits({
139142
clearStudioBoxSize(element);
140143
clearStudioRotation(element);
141144
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
142-
void commitPositionPatchToHtml(selection, clearPatches, {
145+
return commitPositionPatchToHtml(selection, clearPatches, {
143146
label: "Reset layer edits",
144147
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
145148
skipRefresh: false,
146-
}).catch(() => undefined);
149+
}).catch((error) => {
150+
restoreStudioPathOffset(element, beforeOffset);
151+
restoreStudioBoxSize(element, beforeSize);
152+
restoreStudioRotation(element, beforeRotation);
153+
throw error;
154+
});
147155
},
148156
[commitPositionPatchToHtml],
149157
);

packages/studio/src/hooks/useElementLifecycleOps.multiDelete.test.tsx

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,60 @@ import { useElementLifecycleOps } from "./useElementLifecycleOps";
66
import { makeLifecycleOpsParams } from "./elementLifecycleOpsTestUtils";
77
import { mountReactHarness, makeSelection } from "./domSelectionTestHarness";
88

9+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10+
911
function selectionFor(id: string) {
1012
const el = document.createElement("div");
1113
el.id = id;
1214
document.body.append(el);
1315
return { ...makeSelection(id, el), sourceFile: "index.html" };
1416
}
1517

18+
function mountDeleteOps(overrides: Partial<Parameters<typeof useElementLifecycleOps>[0]> = {}) {
19+
const captured: { ops: ReturnType<typeof useElementLifecycleOps> | null } = { ops: null };
20+
function Probe() {
21+
captured.ops = useElementLifecycleOps(
22+
makeLifecycleOpsParams({
23+
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
24+
...overrides,
25+
}),
26+
);
27+
return null;
28+
}
29+
mountReactHarness(<Probe />);
30+
if (!captured.ops) throw new Error("hook did not initialize");
31+
return captured.ops;
32+
}
33+
1634
describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
1735
const removed: string[] = [];
1836
const requests: string[] = [];
1937
let changes = true;
38+
let removeOk = true;
2039

2140
beforeEach(() => {
2241
removed.length = 0;
2342
requests.length = 0;
2443
changes = true;
44+
removeOk = true;
2545
vi.stubGlobal(
2646
"fetch",
2747
vi.fn(async (url: string, init?: RequestInit) => {
28-
requests.push(String(url));
48+
const requestUrl = String(url);
49+
requests.push(requestUrl);
2950
const body = JSON.parse(String(init?.body ?? "{}")) as {
3051
targets?: { id?: string; selector?: string }[];
3152
};
32-
for (const target of body.targets ?? []) {
33-
const key = target.id ?? target.selector;
34-
if (key) removed.push(key);
35-
}
53+
const keys = (body.targets ?? [])
54+
.map((target) => target.id ?? target.selector)
55+
.filter((key): key is string => key !== undefined);
56+
removed.push(...keys);
57+
const isRemove = requestUrl.includes("/file-mutations/remove-elements/");
58+
const status = isRemove && !removeOk ? 500 : 200;
3659
return {
37-
ok: true,
60+
ok: status === 200,
61+
status,
62+
text: async () => (status === 200 ? "" : "server said no"),
3863
json: async () => ({ changed: changes, content: "<html></html>" }),
3964
} as unknown as Response;
4065
}),
@@ -48,28 +73,62 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
4873
it("removes every selected element, not just the first", async () => {
4974
// The reported bug: select several elements on the canvas, press Delete, and
5075
// one disappears while the rest stay — still drawn as selected.
51-
let ops: ReturnType<typeof useElementLifecycleOps> | null = null;
52-
function Probe() {
53-
ops = useElementLifecycleOps(
54-
makeLifecycleOpsParams({
55-
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
56-
projectIdRef: { current: "p1" },
57-
}),
58-
);
59-
return null;
60-
}
61-
mountReactHarness(<Probe />);
76+
const ops = mountDeleteOps({ projectIdRef: { current: "p1" } });
6277

6378
const selections = ["a", "b", "c"].map(selectionFor);
79+
let outcome: unknown;
6480
await act(async () => {
65-
await ops!.handleDomEditElementsDelete(selections);
81+
outcome = await ops.handleDomEditElementsDelete(selections);
6682
});
6783

6884
// The defect: only the first was ever removed.
6985
expect(removed).toEqual(["a", "b", "c"]);
7086
// And one request for the selection, not one per member: a canvas selection
7187
// runs to hundreds, and a round trip each made Delete look like a no-op.
7288
expect(requests.filter((url) => url.includes("remove-elements"))).toHaveLength(1);
89+
expect(outcome).toEqual({ ok: true });
90+
});
91+
92+
it("reports a successful SDK delete as landed", async () => {
93+
const ops = mountDeleteOps({
94+
projectIdRef: { current: "p1" },
95+
onTrySdkDelete: vi.fn(async () => ({ status: "committed", version: "v1" }) as const),
96+
});
97+
98+
const target = { ...selectionFor("a"), hfId: "hf-a" };
99+
let outcome: unknown;
100+
await act(async () => {
101+
outcome = await ops.handleDomEditElementsDelete([target]);
102+
});
103+
104+
expect(outcome).toEqual({ ok: true });
105+
expect(requests.some((url) => url.includes("remove-elements"))).toBe(false);
106+
});
107+
108+
it("reports missing project and selection without starting a request", async () => {
109+
const projectIdRef = { current: null as string | null };
110+
const ops = mountDeleteOps({ projectIdRef });
111+
112+
await expect(ops.handleDomEditElementsDelete([selectionFor("a")])).resolves.toEqual({
113+
ok: false,
114+
reason: "no-project",
115+
});
116+
projectIdRef.current = "p1";
117+
await expect(ops.handleDomEditElementsDelete([])).resolves.toEqual({
118+
ok: false,
119+
reason: "no-selection",
120+
});
121+
expect(requests).toEqual([]);
122+
});
123+
124+
it("reports an HTTP write failure instead of only toasting", async () => {
125+
removeOk = false;
126+
const ops = mountDeleteOps({ projectIdRef: { current: "p1" } });
127+
128+
await expect(ops.handleDomEditElementsDelete([selectionFor("a")])).resolves.toEqual({
129+
ok: false,
130+
reason: "persist-failed",
131+
});
73132
});
74133

75134
it("says so when the preview is stale instead of claiming a delete", async () => {
@@ -78,23 +137,14 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
78137
// nothing at all, with nothing on screen to explain it.
79138
changes = false;
80139
const showToast = vi.fn();
81-
let ops: ReturnType<typeof useElementLifecycleOps> | null = null;
82-
function Probe() {
83-
ops = useElementLifecycleOps(
84-
makeLifecycleOpsParams({
85-
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
86-
projectIdRef: { current: "p1" },
87-
showToast,
88-
}),
89-
);
90-
return null;
91-
}
92-
mountReactHarness(<Probe />);
140+
const ops = mountDeleteOps({ projectIdRef: { current: "p1" }, showToast });
93141

142+
let outcome: unknown;
94143
await act(async () => {
95-
await ops!.handleDomEditElementsDelete([selectionFor("a")]);
144+
outcome = await ops.handleDomEditElementsDelete([selectionFor("a")]);
96145
});
97146

98147
expect(showToast.mock.calls.flat().join(" ")).toContain("out of date");
148+
expect(outcome).toEqual({ ok: false, reason: "persist-failed" });
99149
});
100150
});

packages/studio/src/hooks/useElementLifecycleOps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export function useElementLifecycleOps({
142142
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
143143
"info",
144144
);
145-
return;
145+
return { ok: true } as const;
146146
}
147147
}
148148

packages/studio/src/hooks/useGsapSelectionHandlers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export function useGsapSelectionHandlers({
111111
) => Promise<void>;
112112
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
113113

114-
handleDomManualEditsReset: (sel: DomEditSelection) => void;
114+
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
115115
selectedGsapAnimations: GsapAnimation[];
116116
showToast: (message: string, tone?: "error" | "info") => void;
117117
}) {
@@ -230,7 +230,9 @@ export function useGsapSelectionHandlers({
230230
},
231231
);
232232
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
233-
handleDomManualEditsReset(domEditSelection);
233+
// The reset owns rollback and the position commit already owns user and
234+
// telemetry reporting. This is only the fire-and-forget UI boundary.
235+
void handleDomManualEditsReset(domEditSelection).catch(() => undefined);
234236
}
235237
},
236238
[domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],

0 commit comments

Comments
 (0)