mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): let a failed DOM edit report that it failed (#3510)
* fix(studio): let a failed text or style commit report itself `runDomEditCommit` catches a persist failure, reverts, fires `onError` and then resolves. That contract is deliberate and its docstring says so: the human path learns the write failed from the toast `onError` puts on screen, so a rejection would be redundant. It also means a caller awaiting `handleDomTextCommit` or `handleDomStyleCommit` cannot tell a landed write from a reverted one, because both resolve with `undefined`. The runner already offers `onSettled` as the way out. Text and style were the two commits that never got it wired. Add `runReportedDomEditCommit`, which owns `onSettled` (forwarding to a caller-supplied one rather than dropping it) and returns whether the write landed. Both handlers now return a tagged outcome, so the three preconditions that previously returned early and silently are each distinguishable: no selection, a manual-geometry property the style path refuses, and a selection that cannot edit styles. Same for text: no selection versus not text-editable. Human-facing behaviour is unchanged and the tests assert that: the toast still fires and the optimistic DOM change is still reverted. The callback props that carry these handlers ignore the result, so their declared type widens from `Promise<void>` to `Promise<unknown>`. That type is hand-copied in fourteen places; consolidating it is worth its own change. `useDomEditTextCommits.ts` is now 593 lines against the 600-line cap. The next change to it needs a split. * fix(studio): stop a paused save queue reporting a position edit as saved Two more commits that could not tell a caller they had failed. `useDomEditPositionPatchCommit` swallowed `DomEditSaveQueueOpenError` and resolved. The intent was right, a paused save queue already puts a banner on screen and one toast per blocked edit is noise, but swallowing it also skipped the caller's revert: `useDomGeometryCommits` only restores the optimistic offset, size or rotation from its `.catch`. So once the breaker opened, a drag left the element where the user dropped it while nothing reached the file, and the next reload snapped it back. It now rejects without toasting. The banner still does the telling; the caller gets to revert. `handleDomEditElementsDelete` caught everything and only toasted, so an unpatchable target and a completed delete were indistinguishable to a caller. It now returns an outcome, with `no-project` and `no-selection` separated from a failed write rather than all three sharing an early `return`. Adds the first test for `useDomEditPositionPatchCommit`, covering the paused queue, an ordinary failure, and success. * fix(studio): honor DOM edit failure outcomes * fix(studio): classify stale delete previews * fix(studio): enforce DOM edit outcome types
This commit is contained in:
@@ -35,7 +35,7 @@ function makeEl(id: string, clip: string): HTMLElement {
|
||||
|
||||
function render(
|
||||
el: HTMLElement,
|
||||
onStyleCommit: (property: string, value: string) => Promise<void> | void = () => undefined,
|
||||
onStyleCommit: (property: string, value: string) => Promise<unknown> | void = () => undefined,
|
||||
): { root: Root; rerender: (next: HTMLElement) => void } {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
|
||||
@@ -28,7 +28,7 @@ interface CropGestureState {
|
||||
interface DomEditCropHandlesProps {
|
||||
selection: DomEditSelection;
|
||||
overlayRect: OverlayRect;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
||||
}
|
||||
|
||||
// Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from
|
||||
|
||||
@@ -83,7 +83,7 @@ interface DomEditOverlayProps {
|
||||
restore?: () => void,
|
||||
) => Promise<void> | void;
|
||||
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
||||
gridVisible?: boolean;
|
||||
gridSpacing?: number;
|
||||
recordingState?: GestureRecordingState;
|
||||
|
||||
@@ -123,7 +123,7 @@ interface DomEditSelectionChromeProps {
|
||||
groupSelectionCount: number;
|
||||
blockedMoveRef: RefObject<BlockedMoveState | null>;
|
||||
gestures: GestureHandlers;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
||||
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
||||
onBoxMouseDown: (e: React.MouseEvent) => void;
|
||||
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
/** The canvas' text-editing session: what opens one, and whether one is open. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
|
||||
|
||||
@@ -75,7 +76,9 @@ interface CommitCall {
|
||||
offset: { x: number; y: number } | undefined;
|
||||
}
|
||||
|
||||
function buildHarness() {
|
||||
function buildHarness(
|
||||
onBoxSizeCommit?: UseDomEditOverlayGesturesOptions["onBoxSizeCommitRef"]["current"],
|
||||
) {
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
|
||||
@@ -136,9 +139,12 @@ function buildHarness() {
|
||||
onManualDragStartRef: ref(() => {}),
|
||||
onPathOffsetCommitRef: ref(() => {}),
|
||||
onGroupPathOffsetCommitRef: ref(() => {}),
|
||||
onBoxSizeCommitRef: ref((_s, size, offset) => {
|
||||
commits.push({ size, offset });
|
||||
}),
|
||||
onBoxSizeCommitRef: ref(
|
||||
onBoxSizeCommit ??
|
||||
((_s, size, offset) => {
|
||||
commits.push({ size, offset });
|
||||
}),
|
||||
),
|
||||
onRotationCommitRef: ref(() => {}),
|
||||
onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
|
||||
onCanvasMouseDown: () => {},
|
||||
@@ -172,6 +178,15 @@ function evt(clientX: number, clientY: number) {
|
||||
} as unknown as React.PointerEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
async function finishResize(handlers: ReturnType<typeof createDomEditOverlayGestureHandlers>) {
|
||||
handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
|
||||
resizeHandle: "se",
|
||||
});
|
||||
handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
|
||||
handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
@@ -211,4 +226,23 @@ describe("anchored corner resize — the release commit feeds the center-pin off
|
||||
expect(offset.x).toBeCloseTo(-(size.width - ORIGIN.width) / 2, 0);
|
||||
expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
|
||||
});
|
||||
|
||||
it("does not log a paused save queue as an ordinary resize failure", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { handlers } = buildHarness(() => Promise.reject(new DomEditSaveQueueOpenError()));
|
||||
|
||||
await finishResize(handlers);
|
||||
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still logs an ordinary resize failure", async () => {
|
||||
const failure = new Error("save failed");
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { handlers } = buildHarness(() => Promise.reject(failure));
|
||||
|
||||
await finishResize(handlers);
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ export function CommitField({
|
||||
liveCommit?: boolean;
|
||||
align?: "left" | "right";
|
||||
onPreview?: (nextValue: string) => void;
|
||||
onCommit: (nextValue: string) => void | Promise<void>;
|
||||
onCommit: (nextValue: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
const valueRef = useRef(value);
|
||||
|
||||
@@ -181,7 +181,7 @@ export function LayoutZIndexRow({
|
||||
onSetStyle,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const zIndex = String(parseInt(styles["z-index"] || "auto", 10) || 0);
|
||||
return (
|
||||
@@ -200,7 +200,7 @@ export function LayoutFlexBlock({
|
||||
disabled,
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
||||
@@ -337,7 +337,7 @@ interface FlatLayoutSectionProps
|
||||
> {
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function FlatMaskInsetRows({
|
||||
clipPathValue: string;
|
||||
radiusValue: number;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const clipPathPreset = inferClipPathPreset(clipPathValue);
|
||||
const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
|
||||
|
||||
@@ -38,7 +38,7 @@ export function FlatMediaSection({
|
||||
projectDir: string | null;
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
||||
/** A volume lane in the timeline drives the level; the slider cannot. */
|
||||
|
||||
@@ -35,7 +35,7 @@ export function FlatRow({
|
||||
/** Renders a trailing 10px caret-down, for select-backed rows. */
|
||||
dropdown?: boolean;
|
||||
onPreview?: (nextValue: string) => void;
|
||||
onCommit: (nextValue: string) => void | Promise<void>;
|
||||
onCommit: (nextValue: string) => void | Promise<unknown>;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
|
||||
@@ -50,7 +50,7 @@ function FlatFillFields({
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
assets: string[];
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
onPreviewStyle?: (prop: string, value: string) => void;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
}) {
|
||||
@@ -156,7 +156,7 @@ function FlatStrokeRow({
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const borderWidthValue =
|
||||
parsePxMetricValue(styles["border-width"] ?? "") ??
|
||||
@@ -236,7 +236,7 @@ function FlatRadiusRow({
|
||||
styles: Record<string, string>;
|
||||
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
||||
const radiusTL =
|
||||
@@ -286,7 +286,7 @@ function FlatShadowBlendRows({
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
|
||||
const blendValue = styles["mix-blend-mode"] || "normal";
|
||||
@@ -332,7 +332,7 @@ function FlatBlurSliders({
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
|
||||
const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
|
||||
@@ -378,7 +378,7 @@ function FlatOverflowMaskRows({
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
||||
const clipPathValue = styles["clip-path"] || "none";
|
||||
@@ -432,7 +432,7 @@ function FlatOpacitySlider({
|
||||
}: {
|
||||
styles: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
|
||||
|
||||
@@ -464,7 +464,7 @@ export function FlatStyleSection({
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
assets: string[];
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
onPreviewStyle?: (prop: string, value: string) => void;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
|
||||
@@ -35,7 +35,7 @@ export function MediaSection({
|
||||
projectDir: string | null;
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
||||
onRemoveBackground?: (
|
||||
|
||||
@@ -29,7 +29,7 @@ export function MetricField({
|
||||
scrub?: boolean;
|
||||
suffix?: string;
|
||||
tooltip?: string;
|
||||
onCommit: (nextValue: string) => void | Promise<void>;
|
||||
onCommit: (nextValue: string) => void | Promise<unknown>;
|
||||
}) {
|
||||
const track = useTrackDesignInput();
|
||||
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
|
||||
|
||||
@@ -53,7 +53,7 @@ export function StyleSections({
|
||||
element: DomEditSelection;
|
||||
styles: Record<string, string>;
|
||||
assets: string[];
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
||||
// When true, the Flex `Section` is suppressed. The flat inspector renders
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface PropertyPanelProps {
|
||||
copiedAgentPrompt: boolean;
|
||||
onClearSelection: () => void;
|
||||
onUngroup?: () => void;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<void>;
|
||||
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
||||
onPreviewStyle?: (prop: string, value: string) => void;
|
||||
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
||||
/** Commits several data-* attributes on the SAME element in ONE atomic
|
||||
|
||||
@@ -58,6 +58,12 @@ import {
|
||||
import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDebug";
|
||||
import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
|
||||
import { createGroupDragMover } from "./groupDragMove";
|
||||
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
||||
|
||||
function logGestureCommitFailure(message: string, error: unknown): void {
|
||||
if (error instanceof DomEditSaveQueueOpenError) return;
|
||||
console.error(message, error);
|
||||
}
|
||||
|
||||
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
|
||||
const setDraftOverlayRect = (next: OverlayRect) => {
|
||||
@@ -409,7 +415,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
}
|
||||
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
|
||||
.catch((error) => {
|
||||
console.error("rotate commit failed", error);
|
||||
logGestureCommitFailure("rotate commit failed", error);
|
||||
if (
|
||||
g.manualEditDragToken &&
|
||||
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
|
||||
@@ -493,7 +499,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
||||
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("resize commit failed", error);
|
||||
logGestureCommitFailure("resize commit failed", error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (member) endManualOffsetDragMembers([member]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
function isPromiseCommit(result: void | Promise<void>): result is Promise<void> {
|
||||
function isPromiseCommit(result: void | Promise<unknown>): result is Promise<unknown> {
|
||||
return Boolean(result && typeof result.then === "function");
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useInspectorGestureTransaction<T>({
|
||||
}: {
|
||||
sourceValue: T;
|
||||
onPreview: (value: T) => void;
|
||||
onCommit: (value: T) => void | Promise<void>;
|
||||
onCommit: (value: T) => void | Promise<unknown>;
|
||||
}) {
|
||||
const sourceRef = useRef(sourceValue);
|
||||
const activeRef = useRef<{ before: T; latest: T } | null>(null);
|
||||
@@ -122,7 +122,7 @@ export function useInspectorGestureDraft<T>({
|
||||
}: {
|
||||
sourceValue: T;
|
||||
onPreview: (value: T) => void;
|
||||
onCommit: (value: T) => void | Promise<void>;
|
||||
onCommit: (value: T) => void | Promise<unknown>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(sourceValue);
|
||||
const transaction = useInspectorGestureTransaction({
|
||||
|
||||
@@ -55,3 +55,50 @@ export async function runDomEditCommit(config: DomEditCommitRunnerConfig): Promi
|
||||
if (!config.shouldResync()) return;
|
||||
await config.resync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a DOM edit commit did not change the file.
|
||||
*
|
||||
* `runDomEditCommit` resolves on persist failure by design (see its contract
|
||||
* above), so a caller cannot learn whether the write landed by awaiting it — a
|
||||
* failed persist and a successful one are indistinguishable. Capture and apply
|
||||
* bugs still reject. The human path does not need to ask about handled persist
|
||||
* failures, because `onError` already put a toast on screen. A programmatic
|
||||
* caller has no screen, so it has to be told.
|
||||
*/
|
||||
export type DomEditCommitDeclineReason =
|
||||
| "no-project"
|
||||
| "no-selection"
|
||||
| "geometry-property"
|
||||
| "styles-not-editable"
|
||||
| "not-text-editable"
|
||||
| "preview-stale"
|
||||
| "persist-failed";
|
||||
|
||||
export type DomEditCommitOutcome = { ok: true } | { ok: false; reason: DomEditCommitDeclineReason };
|
||||
|
||||
export function domEditCommitDeclined(reason: DomEditCommitDeclineReason): DomEditCommitOutcome {
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* `runDomEditCommit`, reporting whether the write actually landed.
|
||||
*
|
||||
* Owns `onSettled` to do it, and forwards to a caller-supplied one rather than
|
||||
* dropping it. `runDomEditCommit` calls `onSettled` exactly once on both the
|
||||
* success and the failure path, so the flag is always set by the time it
|
||||
* resolves.
|
||||
*/
|
||||
export async function runReportedDomEditCommit(
|
||||
config: DomEditCommitRunnerConfig,
|
||||
): Promise<DomEditCommitOutcome> {
|
||||
let landed = false;
|
||||
await runDomEditCommit({
|
||||
...config,
|
||||
onSettled: (ok) => {
|
||||
landed = ok;
|
||||
config.onSettled?.(ok);
|
||||
},
|
||||
});
|
||||
return landed ? { ok: true } : domEditCommitDeclined("persist-failed");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
|
||||
import { mountReactHarness } from "./domSelectionTestHarness";
|
||||
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
||||
|
||||
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
function selectionStub(): DomEditSelection {
|
||||
const element = document.createElement("div");
|
||||
element.id = "card";
|
||||
return {
|
||||
id: "card",
|
||||
element,
|
||||
label: "Card",
|
||||
tagName: "div",
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
|
||||
textContent: null,
|
||||
dataAttributes: {},
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
textFields: [],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canCrop: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderCommit(params: Parameters<typeof useDomEditPositionPatchCommit>[0]) {
|
||||
const captured: { commit: ReturnType<typeof useDomEditPositionPatchCommit> | null } = {
|
||||
commit: null,
|
||||
};
|
||||
function Probe() {
|
||||
captured.commit = useDomEditPositionPatchCommit(params);
|
||||
return null;
|
||||
}
|
||||
const root = mountReactHarness(<Probe />);
|
||||
cleanup = () => act(() => root.unmount());
|
||||
if (!captured.commit) throw new Error("hook did not initialize");
|
||||
return captured.commit;
|
||||
}
|
||||
|
||||
function paramsWith(queueDomEditSave: (save: () => Promise<void>) => Promise<void>) {
|
||||
const showToast = vi.fn();
|
||||
return {
|
||||
showToast,
|
||||
params: {
|
||||
activeCompPath: "index.html",
|
||||
persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
|
||||
queueDomEditSave,
|
||||
showToast,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const options = { label: "Move layer", coalesceKey: "path-offset:card" };
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useDomEditPositionPatchCommit", () => {
|
||||
it("rejects when the save queue is paused, so the caller can revert its optimistic change", async () => {
|
||||
const { showToast, params } = paramsWith(() => Promise.reject(new DomEditSaveQueueOpenError()));
|
||||
const commit = renderCommit(params);
|
||||
|
||||
await act(async () => {
|
||||
await expect(commit(selectionStub(), [], options)).rejects.toBeInstanceOf(
|
||||
DomEditSaveQueueOpenError,
|
||||
);
|
||||
});
|
||||
|
||||
// No toast: the paused-save banner already tells the human, and one toast per
|
||||
// blocked edit is what the original swallow existed to prevent.
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts and rejects on an ordinary save failure", async () => {
|
||||
const { showToast, params } = paramsWith(() => Promise.reject(new Error("server said no")));
|
||||
const commit = renderCommit(params);
|
||||
|
||||
await act(async () => {
|
||||
await expect(commit(selectionStub(), [], options)).rejects.toThrow("server said no");
|
||||
});
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith("server said no");
|
||||
});
|
||||
|
||||
it("resolves when the write lands", async () => {
|
||||
const { showToast, params } = paramsWith((save) => save());
|
||||
const commit = renderCommit(params);
|
||||
|
||||
await act(async () => {
|
||||
await expect(commit(selectionStub(), [], options)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
expect(showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,12 @@ export function useDomEditPositionPatchCommit({
|
||||
skipRefresh: options.skipRefresh ?? true,
|
||||
});
|
||||
}).catch((error) => {
|
||||
if (error instanceof DomEditSaveQueueOpenError) return;
|
||||
// A paused save queue is not worth a toast: the paused-save banner is
|
||||
// already on screen, and one toast per blocked edit is what this branch
|
||||
// exists to prevent. It still has to REJECT, though. Swallowing it
|
||||
// resolved the commit, which skipped the caller's revert, so the element
|
||||
// stayed where the drag put it while nothing reached the file.
|
||||
if (error instanceof DomEditSaveQueueOpenError) throw error;
|
||||
showToast(error instanceof Error ? error.message : "Failed to save position");
|
||||
trackStudioSaveFailure({
|
||||
source: "dom_edit",
|
||||
|
||||
@@ -66,6 +66,42 @@ function selectionFor(element: HTMLElement): DomEditSelection {
|
||||
};
|
||||
}
|
||||
|
||||
/** A preview element inside a real iframe, which is where Studio's chrome expects to find it. */
|
||||
function previewElement(
|
||||
html: string,
|
||||
id: string,
|
||||
): { iframe: HTMLIFrameElement; element: HTMLElement } {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("expected iframe document");
|
||||
doc.body.innerHTML = html;
|
||||
const element = doc.getElementById(id);
|
||||
const HTMLElementCtor = doc.defaultView?.HTMLElement;
|
||||
if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
|
||||
throw new Error("expected preview element");
|
||||
}
|
||||
return { iframe, element };
|
||||
}
|
||||
|
||||
/** Hook params with nothing selected and a writer that succeeds; override what the test is about. */
|
||||
function commitParams(
|
||||
overrides: Partial<UseDomEditTextCommitsParams> = {},
|
||||
): UseDomEditTextCommitsParams {
|
||||
return {
|
||||
activeCompPath: "index.html",
|
||||
previewIframeRef: { current: null },
|
||||
showToast: vi.fn(),
|
||||
domEditSelection: null,
|
||||
applyDomSelection: vi.fn(),
|
||||
refreshDomEditSelectionFromPreview: vi.fn(),
|
||||
buildDomSelectionFromTarget: vi.fn(async () => null),
|
||||
persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
|
||||
resolveImportedFontAsset: () => null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
function renderTextCommitHook(params: UseDomEditTextCommitsParams) {
|
||||
@@ -89,16 +125,7 @@ afterEach(() => {
|
||||
|
||||
describe("useDomEditTextCommits", () => {
|
||||
it("does not let a stale failed fields commit revert newer text", async () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) throw new Error("expected iframe document");
|
||||
doc.body.innerHTML = '<div id="card">Original</div>';
|
||||
const element = doc.getElementById("card");
|
||||
const HTMLElementCtor = doc.defaultView?.HTMLElement;
|
||||
if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
|
||||
throw new Error("expected preview element");
|
||||
}
|
||||
const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const selection = selectionFor(element);
|
||||
const stalePersist = createDeferred<void>();
|
||||
@@ -106,17 +133,13 @@ describe("useDomEditTextCommits", () => {
|
||||
.fn()
|
||||
.mockImplementationOnce(() => stalePersist.promise)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const hook = renderTextCommitHook({
|
||||
activeCompPath: "index.html",
|
||||
previewIframeRef: { current: iframe },
|
||||
showToast: vi.fn(),
|
||||
domEditSelection: selection,
|
||||
applyDomSelection: vi.fn(),
|
||||
refreshDomEditSelectionFromPreview: vi.fn(),
|
||||
buildDomSelectionFromTarget: vi.fn(async () => null),
|
||||
persistDomEditOperations,
|
||||
resolveImportedFontAsset: () => null,
|
||||
});
|
||||
const hook = renderTextCommitHook(
|
||||
commitParams({
|
||||
previewIframeRef: { current: iframe },
|
||||
domEditSelection: selection,
|
||||
persistDomEditOperations,
|
||||
}),
|
||||
);
|
||||
|
||||
let staleCommit: Promise<void> | undefined;
|
||||
act(() => {
|
||||
@@ -132,4 +155,135 @@ describe("useDomEditTextCommits", () => {
|
||||
|
||||
expect(element.innerHTML).toBe("Newest");
|
||||
});
|
||||
|
||||
it("reports persist failure from a style commit instead of resolving silently", async () => {
|
||||
const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
|
||||
const selection = selectionFor(element);
|
||||
const showToast = vi.fn();
|
||||
const hook = renderTextCommitHook(
|
||||
commitParams({
|
||||
previewIframeRef: { current: iframe },
|
||||
showToast,
|
||||
domEditSelection: selection,
|
||||
persistDomEditOperations: vi.fn().mockRejectedValue(new Error("server said no")),
|
||||
}),
|
||||
);
|
||||
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await hook.handleDomStyleCommit("color", "red");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: false, reason: "persist-failed" });
|
||||
// The human-facing behaviour must be unchanged: still toasts, still reverts.
|
||||
expect(showToast).toHaveBeenCalled();
|
||||
expect(element.style.getPropertyValue("color")).toBe("");
|
||||
});
|
||||
|
||||
it("reports a successful style commit", async () => {
|
||||
const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
|
||||
const selection = selectionFor(element);
|
||||
const hook = renderTextCommitHook(
|
||||
commitParams({ previewIframeRef: { current: iframe }, domEditSelection: selection }),
|
||||
);
|
||||
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await hook.handleDomStyleCommit("color", "red");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("declines a style commit with no selection, without reaching the writer", async () => {
|
||||
const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
|
||||
const hook = renderTextCommitHook(
|
||||
commitParams({
|
||||
domEditSelection: null,
|
||||
persistDomEditOperations,
|
||||
}),
|
||||
);
|
||||
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await hook.handleDomStyleCommit("color", "red");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: false, reason: "no-selection" });
|
||||
expect(persistDomEditOperations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declines a style commit for a manual-geometry property", async () => {
|
||||
const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
|
||||
const { element } = previewElement("<div id='card'>Original</div>", "card");
|
||||
const hook = renderTextCommitHook(
|
||||
commitParams({
|
||||
domEditSelection: selectionFor(element),
|
||||
persistDomEditOperations,
|
||||
}),
|
||||
);
|
||||
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
// `left` is a manual-geometry property the style path deliberately refuses.
|
||||
outcome = await hook.handleDomStyleCommit("left", "10px");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: false, reason: "geometry-property" });
|
||||
expect(persistDomEditOperations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declines a style commit when the selection cannot edit styles", async () => {
|
||||
const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
|
||||
const { element } = previewElement("<div id='card'>Original</div>", "card");
|
||||
const locked = selectionFor(element);
|
||||
locked.capabilities = { ...locked.capabilities, canEditStyles: false };
|
||||
const hook = renderTextCommitHook(
|
||||
commitParams({
|
||||
domEditSelection: locked,
|
||||
persistDomEditOperations,
|
||||
}),
|
||||
);
|
||||
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await hook.handleDomStyleCommit("color", "red");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: false, reason: "styles-not-editable" });
|
||||
expect(persistDomEditOperations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports persist failure from a text commit instead of resolving silently", async () => {
|
||||
const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
|
||||
const selection = selectionFor(element);
|
||||
const hook = renderTextCommitHook(
|
||||
commitParams({
|
||||
previewIframeRef: { current: iframe },
|
||||
domEditSelection: selection,
|
||||
persistDomEditOperations: vi.fn().mockRejectedValue(new Error("server said no")),
|
||||
}),
|
||||
);
|
||||
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await hook.handleDomTextCommit("Updated");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: false, reason: "persist-failed" });
|
||||
expect(element.innerHTML).toBe("Original");
|
||||
});
|
||||
|
||||
it("reports a text commit declined for an unselected target", async () => {
|
||||
const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
|
||||
const hook = renderTextCommitHook(commitParams({ persistDomEditOperations }));
|
||||
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await hook.handleDomTextCommit("Updated");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: false, reason: "no-selection" });
|
||||
expect(persistDomEditOperations).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,10 @@ import { reportDomEditPersistFailure } from "./domEditPersistFailure";
|
||||
import {
|
||||
bumpDomEditCommitMapVersion,
|
||||
bumpDomEditCommitVersion,
|
||||
domEditCommitDeclined,
|
||||
runDomEditCommit,
|
||||
runReportedDomEditCommit,
|
||||
type DomEditCommitOutcome,
|
||||
} from "./domEditCommitRunner";
|
||||
import { useDomEditAttributeCommits } from "./useDomEditAttributeCommits";
|
||||
import type { InlineTextEditCommit } from "./useInlineTextEdit";
|
||||
@@ -186,10 +189,13 @@ export function useDomEditTextCommits({
|
||||
});
|
||||
|
||||
const handleDomStyleCommit = useCallback(
|
||||
async (property: string, value: string) => {
|
||||
if (!domEditSelection) return;
|
||||
if (isManualGeometryStyleProperty(property)) return;
|
||||
if (!domEditSelection.capabilities.canEditStyles) return;
|
||||
async (property: string, value: string): Promise<DomEditCommitOutcome> => {
|
||||
if (!domEditSelection) return domEditCommitDeclined("no-selection");
|
||||
if (isManualGeometryStyleProperty(property))
|
||||
return domEditCommitDeclined("geometry-property");
|
||||
if (!domEditSelection.capabilities.canEditStyles) {
|
||||
return domEditCommitDeclined("styles-not-editable");
|
||||
}
|
||||
const styleCommitKey = `${getDomEditTargetKey(domEditSelection)}:${property}`;
|
||||
const isLatestStyleCommit = bumpDomEditCommitMapVersion(
|
||||
domStyleCommitVersionRef.current,
|
||||
@@ -210,7 +216,7 @@ export function useDomEditTextCommits({
|
||||
// element in-browser immediately, so a reload would only cost a black blink.
|
||||
const skipRefresh = true;
|
||||
|
||||
await runDomEditCommit({
|
||||
return runReportedDomEditCommit({
|
||||
capture: () => {
|
||||
if (!doc) return;
|
||||
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
||||
@@ -267,9 +273,11 @@ export function useDomEditTextCommits({
|
||||
);
|
||||
|
||||
const handleDomTextCommit = useCallback(
|
||||
async (value: string, fieldKey?: string) => {
|
||||
if (!domEditSelection) return;
|
||||
if (!isTextEditableSelection(domEditSelection)) return;
|
||||
async (value: string, fieldKey?: string): Promise<DomEditCommitOutcome> => {
|
||||
if (!domEditSelection) return domEditCommitDeclined("no-selection");
|
||||
if (!isTextEditableSelection(domEditSelection)) {
|
||||
return domEditCommitDeclined("not-text-editable");
|
||||
}
|
||||
const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
|
||||
const nextTextFields = buildNextDomTextFields(domEditSelection.textFields, value, fieldKey);
|
||||
const textCommit = planDomTextCommit(domEditSelection.textFields, nextTextFields, value);
|
||||
@@ -278,7 +286,7 @@ export function useDomEditTextCommits({
|
||||
let editedElement: HTMLElement | null = null;
|
||||
let previousInnerHtml: string | null = null;
|
||||
|
||||
await runDomEditCommit({
|
||||
return runReportedDomEditCommit({
|
||||
capture: () => {
|
||||
if (!doc) return;
|
||||
const el = findElementForSelection(doc, domEditSelection, activeCompPath);
|
||||
|
||||
@@ -107,7 +107,7 @@ export interface UseDomEditWiringParams {
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => Promise<void>;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("useDomGeometryCommits rollback", () => {
|
||||
commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
|
||||
).rejects.toBe(failure);
|
||||
await expect(commits!.handleDomRotationCommit(selection, { angle: 45 })).rejects.toBe(failure);
|
||||
await expect(commits!.handleDomManualEditsReset(selection)).rejects.toBe(failure);
|
||||
|
||||
expect(readStudioPathOffset(element)).toEqual({ x: 10, y: 20 });
|
||||
expect(readStudioBoxSize(element)).toEqual({ width: 100, height: 80 });
|
||||
|
||||
@@ -130,6 +130,9 @@ export function useDomGeometryCommits({
|
||||
const handleDomManualEditsReset = useCallback(
|
||||
(selection: DomEditSelection) => {
|
||||
const element = selection.element;
|
||||
const beforeOffset = captureStudioPathOffset(element);
|
||||
const beforeSize = captureStudioBoxSize(element);
|
||||
const beforeRotation = captureStudioRotation(element);
|
||||
const clearPatches = [
|
||||
...buildClearPathOffsetPatches(element),
|
||||
...buildClearBoxSizePatches(element),
|
||||
@@ -139,11 +142,16 @@ export function useDomGeometryCommits({
|
||||
clearStudioBoxSize(element);
|
||||
clearStudioRotation(element);
|
||||
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
|
||||
void commitPositionPatchToHtml(selection, clearPatches, {
|
||||
return commitPositionPatchToHtml(selection, clearPatches, {
|
||||
label: "Reset layer edits",
|
||||
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
||||
skipRefresh: false,
|
||||
}).catch(() => undefined);
|
||||
}).catch((error) => {
|
||||
restoreStudioPathOffset(element, beforeOffset);
|
||||
restoreStudioBoxSize(element, beforeSize);
|
||||
restoreStudioRotation(element, beforeRotation);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
||||
import { makeLifecycleOpsParams } from "./elementLifecycleOpsTestUtils";
|
||||
import { mountReactHarness, makeSelection } from "./domSelectionTestHarness";
|
||||
|
||||
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
function selectionFor(id: string) {
|
||||
const el = document.createElement("div");
|
||||
el.id = id;
|
||||
@@ -13,28 +15,51 @@ function selectionFor(id: string) {
|
||||
return { ...makeSelection(id, el), sourceFile: "index.html" };
|
||||
}
|
||||
|
||||
function mountDeleteOps(overrides: Partial<Parameters<typeof useElementLifecycleOps>[0]> = {}) {
|
||||
const captured: { ops: ReturnType<typeof useElementLifecycleOps> | null } = { ops: null };
|
||||
function Probe() {
|
||||
captured.ops = useElementLifecycleOps(
|
||||
makeLifecycleOpsParams({
|
||||
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
mountReactHarness(<Probe />);
|
||||
if (!captured.ops) throw new Error("hook did not initialize");
|
||||
return captured.ops;
|
||||
}
|
||||
|
||||
describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
||||
const removed: string[] = [];
|
||||
const requests: string[] = [];
|
||||
let changes = true;
|
||||
let removeOk = true;
|
||||
|
||||
beforeEach(() => {
|
||||
removed.length = 0;
|
||||
requests.length = 0;
|
||||
changes = true;
|
||||
removeOk = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
requests.push(String(url));
|
||||
const requestUrl = String(url);
|
||||
requests.push(requestUrl);
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
||||
targets?: { id?: string; selector?: string }[];
|
||||
};
|
||||
for (const target of body.targets ?? []) {
|
||||
const key = target.id ?? target.selector;
|
||||
if (key) removed.push(key);
|
||||
}
|
||||
const keys = (body.targets ?? [])
|
||||
.map((target) => target.id ?? target.selector)
|
||||
.filter((key): key is string => key !== undefined);
|
||||
removed.push(...keys);
|
||||
const isRemove = requestUrl.includes("/file-mutations/remove-elements/");
|
||||
const status = isRemove && !removeOk ? 500 : 200;
|
||||
return {
|
||||
ok: true,
|
||||
ok: status === 200,
|
||||
status,
|
||||
text: async () => (status === 200 ? "" : "server said no"),
|
||||
json: async () => ({ changed: changes, content: "<html></html>" }),
|
||||
} as unknown as Response;
|
||||
}),
|
||||
@@ -48,21 +73,12 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
||||
it("removes every selected element, not just the first", async () => {
|
||||
// The reported bug: select several elements on the canvas, press Delete, and
|
||||
// one disappears while the rest stay — still drawn as selected.
|
||||
let ops: ReturnType<typeof useElementLifecycleOps> | null = null;
|
||||
function Probe() {
|
||||
ops = useElementLifecycleOps(
|
||||
makeLifecycleOpsParams({
|
||||
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
|
||||
projectIdRef: { current: "p1" },
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
mountReactHarness(<Probe />);
|
||||
const ops = mountDeleteOps({ projectIdRef: { current: "p1" } });
|
||||
|
||||
const selections = ["a", "b", "c"].map(selectionFor);
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
await ops!.handleDomEditElementsDelete(selections);
|
||||
outcome = await ops.handleDomEditElementsDelete(selections);
|
||||
});
|
||||
|
||||
// The defect: only the first was ever removed.
|
||||
@@ -70,6 +86,49 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
||||
// And one request for the selection, not one per member: a canvas selection
|
||||
// runs to hundreds, and a round trip each made Delete look like a no-op.
|
||||
expect(requests.filter((url) => url.includes("remove-elements"))).toHaveLength(1);
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("reports a successful SDK delete as landed", async () => {
|
||||
const ops = mountDeleteOps({
|
||||
projectIdRef: { current: "p1" },
|
||||
onTrySdkDelete: vi.fn(async () => ({ status: "committed", version: "v1" }) as const),
|
||||
});
|
||||
|
||||
const target = { ...selectionFor("a"), hfId: "hf-a" };
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
outcome = await ops.handleDomEditElementsDelete([target]);
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
expect(requests.some((url) => url.includes("remove-elements"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports missing project and selection without starting a request", async () => {
|
||||
const projectIdRef = { current: null as string | null };
|
||||
const ops = mountDeleteOps({ projectIdRef });
|
||||
|
||||
await expect(ops.handleDomEditElementsDelete([selectionFor("a")])).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "no-project",
|
||||
});
|
||||
projectIdRef.current = "p1";
|
||||
await expect(ops.handleDomEditElementsDelete([])).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "no-selection",
|
||||
});
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an HTTP write failure instead of only toasting", async () => {
|
||||
removeOk = false;
|
||||
const ops = mountDeleteOps({ projectIdRef: { current: "p1" } });
|
||||
|
||||
await expect(ops.handleDomEditElementsDelete([selectionFor("a")])).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "persist-failed",
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
// nothing at all, with nothing on screen to explain it.
|
||||
changes = false;
|
||||
const showToast = vi.fn();
|
||||
let ops: ReturnType<typeof useElementLifecycleOps> | null = null;
|
||||
function Probe() {
|
||||
ops = useElementLifecycleOps(
|
||||
makeLifecycleOpsParams({
|
||||
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
|
||||
projectIdRef: { current: "p1" },
|
||||
showToast,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
mountReactHarness(<Probe />);
|
||||
const ops = mountDeleteOps({ projectIdRef: { current: "p1" }, showToast });
|
||||
|
||||
let outcome: unknown;
|
||||
await act(async () => {
|
||||
await ops!.handleDomEditElementsDelete([selectionFor("a")]);
|
||||
outcome = await ops.handleDomEditElementsDelete([selectionFor("a")]);
|
||||
});
|
||||
|
||||
expect(showToast.mock.calls.flat().join(" ")).toContain("out of date");
|
||||
expect(outcome).toEqual({ ok: false, reason: "preview-stale" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type LayerRevealCommitOwnership,
|
||||
} from "../components/editor/useLayerRevealOverride";
|
||||
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
|
||||
import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
|
||||
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
@@ -87,11 +88,11 @@ export function useElementLifecycleOps({
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleDomEditElementsDelete = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selections: DomEditSelection[]) => {
|
||||
async (selections: DomEditSelection[]): Promise<DomEditCommitOutcome> => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
if (!pid) return domEditCommitDeclined("no-project");
|
||||
const [selection] = selections;
|
||||
if (!selection) return;
|
||||
if (!selection) return domEditCommitDeclined("no-selection");
|
||||
const label =
|
||||
selections.length === 1
|
||||
? selection.label || selection.id || selection.selector || selection.tagName
|
||||
@@ -141,7 +142,7 @@ export function useElementLifecycleOps({
|
||||
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
return { ok: true } as const;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +175,8 @@ export function useElementLifecycleOps({
|
||||
// matching at all means the preview is describing a document the file
|
||||
// does not have — say so rather than reporting a delete that happened.
|
||||
reloadPreview();
|
||||
throw new Error("Nothing to delete — the preview was out of date. Try again.");
|
||||
showToast("Nothing to delete, the preview was out of date. Try again.");
|
||||
return domEditCommitDeclined("preview-stale");
|
||||
}
|
||||
const patchedContent =
|
||||
typeof removeData.content === "string" ? removeData.content : originalContent;
|
||||
@@ -208,9 +210,13 @@ export function useElementLifecycleOps({
|
||||
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
||||
"info",
|
||||
);
|
||||
return { ok: true } as const;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete element";
|
||||
showToast(message);
|
||||
// The toast is what tells the human. The returned outcome is what tells
|
||||
// a caller that has no screen to read.
|
||||
return domEditCommitDeclined("persist-failed");
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -111,7 +111,7 @@ export function useGsapSelectionHandlers({
|
||||
) => Promise<void>;
|
||||
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
|
||||
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => void;
|
||||
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
}) {
|
||||
@@ -230,7 +230,9 @@ export function useGsapSelectionHandlers({
|
||||
},
|
||||
);
|
||||
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
|
||||
handleDomManualEditsReset(domEditSelection);
|
||||
// The reset owns rollback and the position commit already owns user and
|
||||
// telemetry reporting. This is only the fire-and-forget UI boundary.
|
||||
void handleDomManualEditsReset(domEditSelection).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],
|
||||
|
||||
Reference in New Issue
Block a user