diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 38d728b393..46d5c04b0b 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -30,6 +30,7 @@ import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions"; import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges"; import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; +import { trackedStudioEdit } from "./utils/studioPendingEdits"; import { useClipboard } from "./hooks/useClipboard"; import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers"; import { useCaptionDetection } from "./hooks/useCaptionDetection"; @@ -256,7 +257,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St showToast, syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply, showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow, - waitForPendingDomEditSaves: previewPersistence.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); diff --git a/packages/studio/src/components/editor/SnapToolbar.test.tsx b/packages/studio/src/components/editor/SnapToolbar.test.tsx index 5435f16dd3..1b508fd229 100644 --- a/packages/studio/src/components/editor/SnapToolbar.test.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.test.tsx @@ -52,7 +52,7 @@ function AppHotkeyHarness() { writeProjectFile: vi.fn(async () => undefined), showToast: vi.fn(), syncHistoryPreviewAfterApply: vi.fn(async () => undefined), - waitForPendingDomEditSaves: vi.fn(async () => undefined), + settlePendingEdits: vi.fn(async () => undefined), handleCopy: vi.fn(() => false), handlePaste: vi.fn(async () => undefined), handleCut: vi.fn(async () => false), diff --git a/packages/studio/src/components/editor/useDomEditNudge.test.tsx b/packages/studio/src/components/editor/useDomEditNudge.test.tsx index 71f28139af..e0cac33e20 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.test.tsx +++ b/packages/studio/src/components/editor/useDomEditNudge.test.tsx @@ -6,6 +6,7 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge"; import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge"; import { __resetForTests } from "../../utils/canvasNudgeGate"; +import { flushStudioPendingEdits, 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; } @@ -291,3 +293,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((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((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()); + }); +}); diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index cdfc7ed3f3..bec39eb473 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -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 | null; + endPendingEdit: (saved?: Promise) => 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 | 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 | 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((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 diff --git a/packages/studio/src/contexts/DomEditContext.test.tsx b/packages/studio/src/contexts/DomEditContext.test.tsx new file mode 100644 index 0000000000..20232af5a8 --- /dev/null +++ b/packages/studio/src/contexts/DomEditContext.test.tsx @@ -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) { + const handleDomBoxSizeCommit = vi.fn(() => save); + const value = { handleDomBoxSizeCommit } as unknown as Parameters< + typeof DomEditProvider + >[0]["value"]; + let actions!: ReturnType; + function Canvas() { + actions = useDomEditActionsContext(); + return null; + } + const root = createRoot(document.createElement("div")); + act(() => + root.render( + + + , + ), + ); + cleanup.push(() => act(() => root.unmount())); + const committed = actions.handleDomBoxSizeCommit( + {} as never, + { width: 300, height: 200 }, + undefined, + ); + return { committed, handleDomBoxSizeCommit }; +} + +it("undo waits for an edit committed before its save has written anything", async () => { + let finish!: () => void; + const { committed } = commitResize(new Promise((resolve) => (finish = resolve))); + let drained = false; + const drain = flushStudioPendingEdits().then(() => (drained = true)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(drained).toBe(false); + finish(); + await drain; + await committed; + expect(drained).toBe(true); +}); + +it("an edit that fails is waited out and reported to the drain, like every tracked edit", async () => { + let fail!: (error: Error) => void; + const { committed } = commitResize(new Promise((_, reject) => (fail = reject))); + const drain = flushStudioPendingEdits(); + const failure = new Error("blocked"); + fail(failure); + await expect(committed).rejects.toThrow("blocked"); + await expect(drain).resolves.toEqual({ status: "failed", error: failure }); +}); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index c39400c0d3..4781410626 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -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; -export interface DomEditActionsValue extends Pick< - DomEditValue, - | "handleTimelineElementSelect" - | "handlePreviewCanvasMouseDown" - | "handlePreviewCanvasPointerMove" - | "handlePreviewCanvasPointerLeave" - | "applyDomSelection" - | "clearDomSelection" - | "refreshDomEditSelectionFromPreview" - | "handleDomStyleCommit" - | "handleDomStyleCommitForSelection" - | "handleDomAttributeCommit" - | "handleDomAttributeLiveCommit" - | "handleDomAttributeQuietCommit" - | "handleDomHtmlAttributeCommit" - | "handleDomAttributesCommit" - | "handleDomPathOffsetCommit" - | "handleDomGroupPathOffsetCommit" - | "handleDomZIndexReorderCommit" - | "handleDomBoxSizeCommit" - | "handleDomRotationCommit" - | "handleDomManualEditsReset" - | "handleDomTextCommit" - | "handleDomTextCommitForSelection" - | "handleDomRichTextCommit" - | "handleDomTextFieldStyleCommit" - | "handleDomAddTextField" - | "handleDomRemoveTextField" - | "getGsapAnimationsForSelection" - | "handleAskAgent" - | "handleAgentModalSubmit" - | "handleBlockedDomMove" - | "handleDomManualDragStart" - | "handleDomEditElementDelete" - | "handleGroupSelection" - | "handleUngroupSelection" - | "setActiveGroupElement" - | "buildDomSelectionFromTarget" - | "buildDomSelectionForTimelineElement" - | "updateDomEditHoverSelection" - | "resolveImportedFontAsset" - | "setAgentModalOpen" - | "setAgentPromptSelectionContext" - | "setAgentModalAnchorPoint" - | "handleGsapUpdateProperty" - | "handleGsapUpdateMeta" - | "handleGsapDeleteAnimation" - | "handleGsapDeleteAllForElement" - | "handleGsapAddAnimation" - | "handleGsapAddProperty" - | "handleGsapRemoveProperty" - | "handleGsapUpdateFromProperty" - | "handleGsapAddFromProperty" - | "handleGsapRemoveFromProperty" - | "handleGsapAddKeyframe" - | "handleGsapAddKeyframeBatch" - | "handleGsapRemoveKeyframe" - | "handleGsapMoveKeyframeToPlayhead" - | "handleGsapMoveKeyframe" - | "handleGsapResizeKeyframedTween" - | "handleGsapConvertToKeyframes" - | "handleGsapRemoveAllKeyframes" - | "handleResetSelectedElementKeyframes" - | "commitAnimatedProperty" - | "commitAnimatedProperties" - | "handleSetArcPath" - | "handleUpdateArcSegment" - | "handleUnroll" - | "invalidateGsapCache" - | "previewIframeRef" - | "commitMutation" - | "applyMarqueeSelection" - | "handleUpdateKeyframeEase" - | "handleUpdateSegmentEase" - | "handleSetAllKeyframeEases" -> {} +const SAVES_AN_EDIT = { + handleTimelineElementSelect: false, + handlePreviewCanvasMouseDown: false, + handlePreviewCanvasPointerMove: false, + handlePreviewCanvasPointerLeave: false, + applyDomSelection: false, + clearDomSelection: false, + refreshDomEditSelectionFromPreview: false, + handleDomStyleCommit: true, + handleDomStyleCommitForSelection: true, + handleDomAttributeCommit: true, + handleDomAttributeLiveCommit: false, + handleDomAttributeQuietCommit: true, + handleDomHtmlAttributeCommit: true, + handleDomAttributesCommit: true, + handleDomPathOffsetCommit: true, + handleDomGroupPathOffsetCommit: true, + handleDomZIndexReorderCommit: true, + handleDomBoxSizeCommit: true, + handleDomRotationCommit: true, + handleDomManualEditsReset: true, + handleDomTextCommit: true, + handleDomTextCommitForSelection: true, + handleDomRichTextCommit: true, + handleDomTextFieldStyleCommit: true, + handleDomAddTextField: true, + handleDomRemoveTextField: true, + getGsapAnimationsForSelection: false, + handleAskAgent: false, + handleAgentModalSubmit: false, + handleBlockedDomMove: false, + handleDomManualDragStart: false, + handleDomEditElementDelete: true, + handleGroupSelection: true, + handleUngroupSelection: true, + setActiveGroupElement: false, + buildDomSelectionFromTarget: false, + buildDomSelectionForTimelineElement: false, + updateDomEditHoverSelection: false, + resolveImportedFontAsset: false, + setAgentModalOpen: false, + setAgentPromptSelectionContext: false, + setAgentModalAnchorPoint: false, + handleGsapUpdateProperty: true, + handleGsapUpdateMeta: true, + handleGsapDeleteAnimation: true, + handleGsapDeleteAllForElement: true, + handleGsapAddAnimation: true, + handleGsapAddProperty: true, + handleGsapRemoveProperty: true, + handleGsapUpdateFromProperty: true, + handleGsapAddFromProperty: true, + handleGsapRemoveFromProperty: true, + handleGsapAddKeyframe: true, + handleGsapAddKeyframeBatch: true, + handleGsapRemoveKeyframe: true, + handleGsapMoveKeyframeToPlayhead: true, + handleGsapMoveKeyframe: true, + handleGsapResizeKeyframedTween: true, + handleGsapConvertToKeyframes: true, + handleGsapRemoveAllKeyframes: true, + handleResetSelectedElementKeyframes: true, + commitAnimatedProperty: true, + commitAnimatedProperties: true, + handleSetArcPath: true, + handleUpdateArcSegment: true, + handleUnroll: true, + invalidateGsapCache: false, + previewIframeRef: false, + commitMutation: true, + applyMarqueeSelection: false, + handleUpdateKeyframeEase: true, + handleUpdateSegmentEase: true, + handleSetAllKeyframeEases: true, +} as const satisfies Partial>; + +export interface DomEditActionsValue extends Pick {} export interface DomEditSelectionValue extends Pick< DomEditValue, @@ -98,6 +100,15 @@ export interface DomEditSelectionValue extends Pick< | "agentPromptSelectionContext" > {} +function trackEditCommits(actions: DomEditActionsValue): DomEditActionsValue { + const tracked: Record = { ...actions }; + for (const key of Object.keys(SAVES_AN_EDIT) as Array) { + if (!SAVES_AN_EDIT[key]) continue; + tracked[key] = trackedStudioEdit(actions[key] as (...args: unknown[]) => unknown); + } + return tracked as unknown as DomEditActionsValue; +} + const DomEditActionsContext = createStableContext( "DomEditActionsContext", null, @@ -242,7 +253,7 @@ export function DomEditProvider({ [], ); - const actions = useMemo( + const untrackedActions = useMemo( () => ({ handleTimelineElementSelect, handlePreviewCanvasMouseDown, @@ -395,6 +406,8 @@ export function DomEditProvider({ ], ); + const actions = useMemo(() => trackEditCommits(untrackedActions), [untrackedActions]); + const selection = useMemo( () => ({ domEditSelection, diff --git a/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx b/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx index 0d2167836a..139c6fbbde 100644 --- a/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx +++ b/packages/studio/src/hooks/useAppHotkeys.previewForwarding.test.tsx @@ -16,6 +16,7 @@ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); const domDelete = vi.fn(async () => undefined); const historyUndo = vi.fn(async () => ({ ok: false })); const historyRedo = vi.fn(async () => ({ ok: false })); +const settle = vi.fn(async () => undefined); let root: Root | null = null; let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null; @@ -49,7 +50,7 @@ function Harness() { writeProjectFile: vi.fn(async () => undefined), showToast: vi.fn(), syncHistoryPreviewAfterApply: vi.fn(async () => undefined), - waitForPendingDomEditSaves: vi.fn(async () => undefined), + settlePendingEdits: settle, handleCopy: vi.fn(() => false), handlePaste: vi.fn(() => false), handleCut: vi.fn(() => false), @@ -69,6 +70,8 @@ afterEach(() => { domDelete.mockClear(); historyUndo.mockClear(); historyRedo.mockClear(); + settle.mockReset(); + settle.mockImplementation(async () => undefined); vi.mocked(trackStudioEvent).mockClear(); }); @@ -122,6 +125,18 @@ describe("preview iframe hotkey forwarding", () => { expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "undo" }); }); + it("settles pending edits, failed ones included, before Cmd+Z steps the history", async () => { + let settled!: () => void; + settle.mockImplementation( + () => new Promise((resolve) => (settled = () => resolve(undefined))), + ); + await press(mountWithPreview(), { key: "z", ctrlKey: true }); + expect(settle).toHaveBeenCalledTimes(1); + expect(historyUndo).not.toHaveBeenCalled(); + await act(async () => settled()); + expect(historyUndo).toHaveBeenCalledTimes(1); + }); + it.each([ ["Shift+Cmd+Z", { key: "Z", metaKey: true, shiftKey: true }], ["Ctrl+Y", { key: "y", ctrlKey: true }], diff --git a/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx b/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx index 2958e28d66..cee5bb8e55 100644 --- a/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx +++ b/packages/studio/src/hooks/useAppHotkeys.textEditing.test.tsx @@ -86,7 +86,7 @@ function Harness() { writeProjectFile: vi.fn(async () => undefined), showToast: vi.fn(), syncHistoryPreviewAfterApply: vi.fn(async () => undefined), - waitForPendingDomEditSaves: vi.fn(async () => undefined), + settlePendingEdits: vi.fn(async () => undefined), handleCopy: vi.fn(() => false), handlePaste: vi.fn(async () => undefined), handleCut: vi.fn(async () => false), diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index eba9d0ecc4..69b07f43ba 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -85,7 +85,7 @@ interface UseAppHotkeysParams { showToast: (message: string, tone?: "error" | "info") => void; syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"]; showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"]; - waitForPendingDomEditSaves: () => Promise; + settlePendingEdits: () => Promise; handleCopy: () => boolean; handlePaste: () => Promise; handleCut: () => Promise; @@ -124,7 +124,7 @@ export function useAppHotkeys({ showToast, syncHistoryPreviewAfterApply, showHistoryRestoreNow, - waitForPendingDomEditSaves, + settlePendingEdits, handleCopy, handlePaste, handleCut, @@ -151,7 +151,7 @@ export function useAppHotkeys({ showToast, syncHistoryPreviewAfterApply, showHistoryRestoreNow, - waitForPendingDomEditSaves, + waitForPendingDomEditSaves: settlePendingEdits, onAfterUndoRedo, activeCompPath, forceReloadSdkSession, diff --git a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx index de1fc622d6..1cb71620c7 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx +++ b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { act, createElement } from "react"; +import { act, createElement, type ReactElement } from "react"; import { createRoot } from "react-dom/client"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -10,6 +10,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 }) { + const selection = makeSelection("Box", target); + const ref = (current: T) => ({ current }); + useDomEditNudge({ + selection, + groupSelections: [], + allowCanvasMovement: true, + selectionRef: ref(selection), + overlayRectRef: ref({ left: 0, top: 0, width: 100, height: 40, editScaleX: 1, editScaleY: 1 }), + groupOverlayItemsRef: ref([]), + gestureRef: ref(null), + groupGestureRef: ref(null), + blockedMoveRef: ref(null), + onManualDragStartRef: ref(() => {}), + onBlockedMoveRef: ref(() => {}), + onPathOffsetCommitRef: ref(save), + onGroupPathOffsetCommitRef: ref(async () => {}), + }); + return null; +} + +it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => { + const s = await studio(); + await s.edit(); + resetNudgeKeys(); + const save = vi.fn(async () => { + writeFileSync(s.path, NUDGED); + await s.history().recordEdit({ + label: "Move layer", + files: { "index.html": { before: AFTER, after: NUDGED } }, + }); + }); + s.mount(createElement(Nudge, { target: s.element("box"), save })); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true })); + }); + + const undone = s.actions().undo(); + expect(s.box()).toBe("50px"); + await act(() => undone); + expect(save).toHaveBeenCalledTimes(1); + expect(s.file()).toBe(AFTER); + expect(s.box()).toBe("50px"); +}); + +it("an undo pressed while 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((resolve) => (fail = resolve)); + throw new Error("The save failed."); + }); + } catch (error) { + box.style.left = "50px"; + throw error; + } + }); + let actions!: ReturnType; + function Canvas() { + actions = useDomEditActionsContext(); + return null; + } + const value = { handleDomStyleCommit } as unknown as Parameters< + typeof DomEditProvider + >[0]["value"]; + s.mount( + + + , + ); + const failed = actions.handleDomStyleCommit("left", "70px"); + + const undone = s.actions().undo(); + 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((_, reject) => (fail = () => reject(new Error("The save failed.")))), + ); + const failed = timelineEdit(); + + const undone = s.actions().undo(); + fail(); + await expect(failed).rejects.toThrow("The save failed."); + await act(() => undone); + expect(s.file()).toBe(BEFORE); + expect(s.box()).toBe("10px"); +}); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index e4ed1a27ca..b6a1cd721a 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -30,6 +30,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { MutationResult } from "./gsapScriptCommitTypes"; import { persistSdkSerialize } from "../utils/sdkCutover"; import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits"; +import { hasStudioPendingEdits } from "../utils/studioPendingEdits"; // ── applyPreviewSync (pure preview-sync decision) ──────────────────────────── @@ -426,6 +427,24 @@ function mockFetchResult(over: Partial = {}): void { ); } +describe("a GSAP script commit", () => { + it("counts as a pending edit from its call until it lands, so a quick Cmd+Z waits for it", async () => { + mockFetchResult(); + const deps = renderCommitHook(); + let committed!: Promise; + act(() => { + committed = deps.api.commitMutation( + selection, + { type: "remove-all-keyframes", animationId: "a" }, + { label: "Remove all keyframes" }, + ); + }); + expect(hasStudioPendingEdits()).toBe(true); + await act(async () => void (await committed)); + expect(hasStudioPendingEdits()).toBe(false); + }); +}); + describe("runCommit — instantPatch wiring", () => { it("explains a deliberate mutation that the server safely rejected as unchanged", async () => { mockFetchResult({ changed: false }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index efdad24d3a..2901b1149e 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -7,6 +7,7 @@ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload" import type { SoftReloadResult } from "../utils/gsapSoftReload"; import { trackStudioEvent } from "../utils/studioTelemetry"; import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator"; +import { trackedStudioEdit } from "../utils/studioPendingEdits"; import { getStudioSaveErrorMessage, isStudioSaveErrorAlreadyToasted, @@ -416,20 +417,20 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra } return run(); }; - const commit: CommitMutation = (selection, mutation, options) => { + const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => { if (!activeProjectId) return Promise.resolve(); const file = selection.sourceFile || activeCompPath || "index.html"; return serializeCommit(file, options.serializeKey, () => runCommit(activeProjectId, activeCompPath, file, selection, mutation, options), ); - }; - commit.batch = (calls, options) => { + }) as CommitMutation; + commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => { if (!activeProjectId) return Promise.resolve(); const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html"; return serializeCommit(file, options.serializeKey, () => runBatchCommit(activeProjectId, activeCompPath, file, calls, options), ); - }; + }); return commit; }, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); diff --git a/packages/studio/src/hooks/usePreviewPersistence.ts b/packages/studio/src/hooks/usePreviewPersistence.ts index 3b6144ab62..cff8f2b62a 100644 --- a/packages/studio/src/hooks/usePreviewPersistence.ts +++ b/packages/studio/src/hooks/usePreviewPersistence.ts @@ -159,6 +159,11 @@ export function usePreviewPersistence({ if (result.status !== "clean") throw result.error; }, [drainPendingDomEditSaves]); + const settlePendingEdits = useCallback(async (): Promise => { + await flushStudioPendingEdits(); + await domEditSaveQueueRef.current?.waitForIdle(); + }, []); + const resetDomEditSaveQueueBreaker = useCallback(() => { domEditSaveQueueRef.current?.reset(); setDomEditSaveQueuePaused(null); @@ -253,6 +258,7 @@ export function usePreviewPersistence({ queueDomEditSave, drainPendingDomEditSaves, waitForPendingDomEditSaves, + settlePendingEdits, domEditSaveQueuePaused, resetDomEditSaveQueueBreaker, applyCurrentStudioManualEditsToPreview, diff --git a/packages/studio/src/hooks/useTrackPendingTimelineEdit.ts b/packages/studio/src/hooks/useTrackPendingTimelineEdit.ts index 9687e671f0..4d38460444 100644 --- a/packages/studio/src/hooks/useTrackPendingTimelineEdit.ts +++ b/packages/studio/src/hooks/useTrackPendingTimelineEdit.ts @@ -1,5 +1,5 @@ import { useCallback, useRef } from "react"; -import { trackStudioPendingEdit } from "../utils/studioPendingEdits"; +import { trackedStudioEdit } from "../utils/studioPendingEdits"; type AsyncFn = (...args: never[]) => Promise; @@ -11,11 +11,7 @@ export function useTrackPendingTimelineEdit() { const key = fn as unknown as AsyncFn; const cached = wrappedRef.current.get(key); if (cached) return cached as unknown as (...args: Args) => Promise; - const wrapped = (...args: Args): Promise => { - const result = fn(...args); - trackStudioPendingEdit(result); - return result; - }; + const wrapped = trackedStudioEdit(fn); wrappedRef.current.set(key, wrapped as unknown as AsyncFn); return wrapped; }, []); diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index d9837a3251..175b1adf63 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest"; import { addStudioPendingEditFlushListener, flushStudioPendingEdits, + hasStudioPendingEdits, trackStudioPendingEdit, + trackedStudioEdit, } from "./studioPendingEdits"; import { StudioFileConflictError } from "./studioSaveDiagnostics"; @@ -148,3 +150,48 @@ describe("studio pending edit flush", () => { expect(steps).toEqual(["persisted", "flushed"]); }); }); + +describe("a drain that meets a conflict", () => { + it("still waits for the edits that started while it ran before it reports the conflict", async () => { + const conflict = new StudioFileConflictError({ + filePath: "index.html", + currentVersion: "v2", + currentContent: "external", + attemptedContent: "studio", + }); + let finish!: () => void; + let laterSaved = false; + trackStudioPendingEdit( + Promise.resolve().then(() => { + trackStudioPendingEdit( + new Promise((resolve) => (finish = resolve)).then(() => (laterSaved = true)), + ); + throw conflict; + }), + ); + let drained = false; + const drain = flushStudioPendingEdits().then((result) => ((drained = true), result)); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(drained).toBe(false); + finish(); + await expect(drain).resolves.toEqual({ status: "conflict", error: conflict }); + expect(laterSaved).toBe(true); + }); +}); + +describe("trackedStudioEdit", () => { + it("counts each call as a pending edit until it settles, and reports a failure to the drain", async () => { + let fail!: () => void; + const failure = new Error("The save failed."); + const edit = trackedStudioEdit( + () => new Promise((_, reject) => (fail = () => reject(failure))), + ); + const saved = edit(); + expect(hasStudioPendingEdits()).toBe(true); + const drain = flushStudioPendingEdits(); + fail(); + await expect(saved).rejects.toThrow("The save failed."); + await expect(drain).resolves.toEqual({ status: "failed", error: failure }); + expect(hasStudioPendingEdits()).toBe(false); + }); +}); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 7d43aa47d9..ec013a2496 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -73,6 +73,16 @@ export function trackStudioPendingEdit( return promise; } +export function trackedStudioEdit( + edit: (...args: Args) => R, +): (...args: Args) => R { + return (...args) => { + const result = edit(...args); + if (result instanceof Promise) trackStudioPendingEdit(result); + return result; + }; +} + export async function flushStudioPendingEdits(): Promise { const active = focusedField(); if (active) { @@ -86,15 +96,16 @@ export async function flushStudioPendingEdits(): Promise(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }), ); + let conflict: StudioFileConflictError | undefined; let firstFailure: PromiseRejectedResult | undefined; while (detail.promises.length > 0 || pendingEditPromises.size > 0) { const promises = [...detail.promises, ...pendingEditPromises]; detail.promises = []; - const results = await Promise.allSettled(promises); - const batchFailures = inspectDrainFailures(results); - if (batchFailures.conflict) return { status: "conflict", error: batchFailures.conflict }; + const batchFailures = inspectDrainFailures(await Promise.allSettled(promises)); + conflict ??= batchFailures.conflict; firstFailure ??= batchFailures.firstFailure; } + if (conflict) return { status: "conflict", error: conflict }; return firstFailure ? { status: "failed", error: firstFailure.reason } : { status: "clean" }; } diff --git a/packages/studio/tests/e2e/edit-accuracy/baseline.json b/packages/studio/tests/e2e/edit-accuracy/baseline.json index 977add4d30..47c49579a7 100644 --- a/packages/studio/tests/e2e/edit-accuracy/baseline.json +++ b/packages/studio/tests/e2e/edit-accuracy/baseline.json @@ -911,12 +911,12 @@ "seqnudge-none-pct-r0-root-z100": {"pass":true,"tracking":0.05,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":5.56,"frameP95":18.8}, "seqnudge-none-px-r0-nested-z100": {"pass":false,"tracking":0.05,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":1,"controlDropped":0,"work":10.13,"frameP95":17.9}, "seqnudge-none-px-r0-root-z100": {"pass":true,"tracking":0.03,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"work":6.24,"frameP95":18.5}, - "seqnudgeundo-none-center-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1.04,"reload":1.04,"render":0.04,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, - "seqnudgeundo-none-center-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1.04,"reload":1.04,"render":0.04,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, - "seqnudgeundo-none-pct-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1,"reload":1,"render":0.03,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, - "seqnudgeundo-none-pct-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1,"reload":1,"render":0.03,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, - "seqnudgeundo-none-px-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1.04,"reload":1.04,"render":0.04,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, - "seqnudgeundo-none-px-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":1.04,"reload":1.04,"render":0.02,"undo":false,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, + "seqnudgeundo-none-center-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, + "seqnudgeundo-none-center-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, + "seqnudgeundo-none-pct-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, + "seqnudgeundo-none-pct-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, + "seqnudgeundo-none-px-r0-nested-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, + "seqnudgeundo-none-px-r0-root-z100": {"pass":false,"tracking":0,"pressJump":null,"teleport":true,"teleportPx":null,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":null,"frameP95":null}, "seqrepeat-none-center-r0-nested-z100": {"pass":true,"tracking":0.05,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"work":4.3,"frameP95":17.5}, "seqrepeat-none-center-r0-root-z100": {"pass":true,"tracking":0.05,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.02,"undo":true,"dropped":0,"controlDropped":0,"work":4.93,"frameP95":18.4}, "seqrepeat-none-pct-r0-nested-z100": {"pass":true,"tracking":0.03,"pressJump":0,"teleport":true,"teleportPx":0.01,"drop":0,"reload":0,"render":0.03,"undo":true,"dropped":0,"controlDropped":0,"work":4.68,"frameP95":18.1},