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