diff --git a/packages/studio/src/components/editor/dragUndoPaint.test.ts b/packages/studio/src/components/editor/dragUndoPaint.test.ts new file mode 100644 index 0000000000..b758109ba9 --- /dev/null +++ b/packages/studio/src/components/editor/dragUndoPaint.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it, vi } from "vitest"; +import type { DomEditSelection } from "./domEditing"; +import type { GestureState } from "./domEditOverlayGestures"; +import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures"; +import { + hasStudioPendingEdits, + revertNewestStudioPendingEdit, +} from "../../utils/studioPendingEdits"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +const ref = (current: T) => ({ current }); +const pointer = (x: number, y: number) => ({ + clientX: x, + clientY: y, + pointerId: 1, + button: 0, + preventDefault() {}, + stopPropagation() {}, + currentTarget: { setPointerCapture() {}, releasePointerCapture() {} }, +}); + +/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */ +function dragWithSaveRunning(save: Promise) { + const element = document.createElement("div"); + element.style.setProperty("translate", "40px 30px"); + document.body.append(element); + const selection = { element, capabilities: { canApplyManualOffset: true } }; + const handlers = createDomEditOverlayGestureHandlers({ + selectionRef: ref(selection as unknown as DomEditSelection), + overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }), + boxRef: ref(document.createElement("div")), + overlayRef: ref(null), + iframeRef: ref(null), + gestureRef: ref(null), + rafPausedRef: ref(false), + onManualDragStartRef: ref(vi.fn()), + onBlockedMoveRef: ref(vi.fn()), + onPathOffsetCommitRef: ref(vi.fn(() => save)), + snapGuidesRef: ref(null), + groupGestureRef: ref(null), + blockedMoveRef: ref(null), + setOverlayRect: vi.fn(), + suppressNextBoxClickRef: ref(false), + hoverSelectionRef: ref(null), + onCanvasMouseDown: vi.fn(), + } as never); + expect(handlers.startGesture("drag", pointer(10, 10) as never)).toBe(true); + handlers.onPointerUp(pointer(110, 70) as never); + return element; +} + +it("a drag whose save is still running can be painted back at once, and shown again", async () => { + let saved!: () => void; + const element = dragWithSaveRunning(new Promise((resolve) => (saved = resolve))); + const moved = element.style.getPropertyValue("translate"); + expect(moved).not.toBe("40px 30px"); + + const showAgain = revertNewestStudioPendingEdit(); + expect(element.style.getPropertyValue("translate")).toBe("40px 30px"); + expect(revertNewestStudioPendingEdit()).toBeNull(); + showAgain!(); + expect(element.style.getPropertyValue("translate")).toBe(moved); + + saved(); + await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false)); + expect(revertNewestStudioPendingEdit()).toBeNull(); +}); diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 9a85b2089a..54fd975ede 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -13,6 +13,7 @@ import { import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition"; import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes"; import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate"; +import type { StudioEditRevert } from "../../utils/studioPendingEdits"; interface OffsetDragGsap { set: (el: Element, vars: Record) => void; @@ -510,6 +511,16 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void { endStudioManualEditGesture(member.element, member.gestureToken); } +/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */ +export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null { + if (!members.every((member) => member.plainTranslate)) return null; + return () => { + const shown = members.map((member) => captureStudioPathOffset(member.element)); + for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset); + return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!)); + }; +} + /** Roll back a FAILED drag to the exact gesture-start state. */ export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void { for (const member of members) { diff --git a/packages/studio/src/components/editor/useDomEditNudge.ts b/packages/studio/src/components/editor/useDomEditNudge.ts index 4e7f8946f9..49129d5fc5 100644 --- a/packages/studio/src/components/editor/useDomEditNudge.ts +++ b/packages/studio/src/components/editor/useDomEditNudge.ts @@ -11,7 +11,7 @@ import { ownsPlainKeys } from "../../utils/typingTarget"; import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate"; import { addStudioPendingEditFlushListener, - trackStudioPendingEdit, + beginStudioPendingEdit, } from "../../utils/studioPendingEdits"; import type { DomEditSelection } from "./domEditing"; import { @@ -31,6 +31,7 @@ import { applyManualOffsetNudgeDraft, createManualOffsetDragMember, endManualOffsetDragMembers, + manualOffsetMoveRevert, restoreManualOffsetDragMembers, type ManualOffsetDragMember, } from "./manualOffsetDrag"; @@ -47,7 +48,7 @@ interface NudgeSession { /** Accumulated delta of the burst, in composition px. */ accum: { x: number; y: number }; timer: ReturnType | null; - endPendingEdit: (saved?: Promise) => void; + edit: ReturnType; } export interface UseDomEditNudgeParams { @@ -161,11 +162,16 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () 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, - }); + const commit = session.edit.adopt(() => + session.isGroup + ? p.onGroupPathOffsetCommitRef.current(updates) + : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, { + plainTranslate: updates[0].plainTranslate, + }), + ); + if (session.edit.reverted()) + for (const member of session.members) + restoreStudioPathOffset(member.element, member.initialPathOffset); saved = Promise.resolve(commit) .catch(() => { for (const member of session.members) { @@ -177,7 +183,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () .finally(() => endManualOffsetDragMembers(session.members)); return saved; } finally { - session.endPendingEdit(saved); + session.edit.settle(saved); } }; const commitSessionRef = useRef(commitSession); @@ -190,7 +196,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () if (session.timer) clearTimeout(session.timer); restoreManualOffsetDragMembers(session.members); endManualOffsetDragMembers(session.members); - session.endPendingEdit(); + session.edit.settle(); }, [params.allowCanvasMovement]); // Build drag members for the current target set — the same member snapshot a @@ -217,9 +223,8 @@ 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?.(); - let endPendingEdit: NudgeSession["endPendingEdit"] = () => {}; - trackStudioPendingEdit(new Promise((resolve) => (endPendingEdit = resolve))); - return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, endPendingEdit }; + const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members)); + return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit }; }; const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index ff734b188f..08d41490ea 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -11,6 +11,7 @@ import { applyManualOffsetDragDraft, endManualOffsetDragMembers, restoreManualOffsetDragMembers, + manualOffsetMoveRevert, } from "./manualOffsetDrag"; import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft"; import { @@ -57,6 +58,7 @@ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDeb import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug"; import { createGroupDragMover } from "./groupDragMove"; import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue"; +import { beginStudioPendingEdit } from "../../utils/studioPendingEdits"; function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) { return ( @@ -344,7 +346,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu ), at: readDragPositions(groupG.members), }); - void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates)) + const groupEdit = beginStudioPendingEdit(manualOffsetMoveRevert(groupG.members)); + const groupSaved = Promise.resolve( + groupEdit.adopt(() => opts.onGroupPathOffsetCommitRef.current(updates)), + ) .catch(() => { for (const member of groupG.members) { if ( @@ -363,6 +368,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu // so this is where a snap-back would show. logDragSettle("settle", groupG.members); }); + groupEdit.settle(groupSaved); return; } @@ -469,11 +475,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu box.style.left = `${nextBoxLeft}px`; box.style.top = `${nextBoxTop}px`; } - void Promise.resolve( - opts.onPathOffsetCommitRef.current(sel, finalOffset, { - altKey: e.altKey, - plainTranslate: g.pathOffsetMember.plainTranslate, - }), + const member = g.pathOffsetMember; + const edit = beginStudioPendingEdit(manualOffsetMoveRevert([member])); + const saved = Promise.resolve( + edit.adopt(() => + opts.onPathOffsetCommitRef.current(sel, finalOffset, { + altKey: e.altKey, + plainTranslate: member.plainTranslate, + }), + ), ) .catch(() => { if ( @@ -485,6 +495,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu .finally(() => { if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]); }); + edit.settle(saved); } else { opts.suppressNextBoxClickRef.current = true; const finalSize = readStudioBoxSize(sel.element); diff --git a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx index 1cb71620c7..fd83d9b8f3 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx +++ b/packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx @@ -206,7 +206,9 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor const s = await studio(); await s.edit(); resetNudgeKeys(); + let finish!: () => void; const save = vi.fn(async () => { + await new Promise((resolve) => (finish = resolve)); writeFileSync(s.path, NUDGED); await s.history().recordEdit({ label: "Move layer", @@ -218,12 +220,51 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true })); }); + const nudge = () => s.element("box").style.getPropertyValue("translate"); + expect(nudge()).not.toBe(""); + const undone = s.actions().undo(); expect(s.box()).toBe("50px"); + expect(nudge()).toBe(""); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + expect(nudge()).toBe(""); + finish(); await act(() => undone); expect(save).toHaveBeenCalledTimes(1); expect(s.file()).toBe(AFTER); expect(s.box()).toBe("50px"); + expect(nudge()).toBe(""); +}); + +it("an undo pressed while a nudge's save is queued shows the nudge undone in the key's own task", async () => { + const s = await studio(); + await s.edit(); + resetNudgeKeys(); + let finish!: () => void; + const save = vi.fn(async () => { + await new Promise((resolve) => (finish = resolve)); + 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 })); + }); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + const nudge = () => s.element("box").style.getPropertyValue("translate"); + expect(nudge()).not.toBe(""); + + const undone = s.actions().undo(); + expect(nudge()).toBe(""); + expect(s.box()).toBe("50px"); + finish(); + await act(() => undone); + expect(s.file()).toBe(AFTER); + expect(nudge()).toBe(""); + expect(s.box()).toBe("50px"); }); it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => { diff --git a/packages/studio/src/hooks/useEditHistoryActions.ts b/packages/studio/src/hooks/useEditHistoryActions.ts index d2a82c3609..9f75d41f63 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.ts +++ b/packages/studio/src/hooks/useEditHistoryActions.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo } from "react"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator"; import type { RestoreFiles } from "../utils/gsapUndoRestore"; +import { revertNewestStudioPendingEdit } from "../utils/studioPendingEdits"; interface HistoryResult { ok: boolean; @@ -74,7 +75,10 @@ export function useEditHistoryActions({ const noun = direction === "undo" ? "Undo" : "Redo"; // Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects. const predicted = editHistory.predict?.(direction) ?? null; - const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null; + const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null; + const pendingEditShown = + !predictedShown && direction === "undo" ? revertNewestStudioPendingEdit() : null; + const putBack = predictedShown ?? pendingEditShown; let result: HistoryResult = { ok: false, reason: "failed" }; let serverSteppedShown = false; try { @@ -83,8 +87,10 @@ export function useEditHistoryActions({ readFile: readHistoryFile, serialize: serializeHistoryFiles, }); - serverSteppedShown = - Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id; + const stepped = Boolean(result.ok && result.label); + serverSteppedShown = predictedShown + ? stepped && result.undoes === predicted?.id + : stepped && Boolean(pendingEditShown); } finally { if (putBack && !serverSteppedShown) putBack(); } @@ -100,7 +106,10 @@ export function useEditHistoryActions({ return; } if (result.ok && result.label) { - const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files; + const files = + serverSteppedShown && predictedShown + ? fromShown(result.files, predicted!.files) + : result.files; const restore = { paths: result.paths, files }; onAfterUndoRedo?.(restore); if (activeCompPath && result.paths?.includes(activeCompPath)) { diff --git a/packages/studio/src/hooks/usePersistentEditHistory.test.ts b/packages/studio/src/hooks/usePersistentEditHistory.test.ts index 2dda96b983..a8a618ba81 100644 --- a/packages/studio/src/hooks/usePersistentEditHistory.test.ts +++ b/packages/studio/src/hooks/usePersistentEditHistory.test.ts @@ -243,8 +243,14 @@ it("a step that cannot reach the server says so", async () => { }); }); -it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => { +it("predicts a step from what this tab wrote as soon as its save ends, and not while a claim or step may have moved the history", async () => { const { hook, save, readFile } = await studio(); + const real = globalThis.fetch; + const slowView = (url: string, init?: RequestInit) => + url.endsWith("/history") + ? new Promise((r) => setTimeout(r, 50)).then(() => real(url, init)) + : real(url, init); + vi.stubGlobal("fetch", slowView); save("B"); const claim = hook().recordEdit({ label: "Moved Title", @@ -252,11 +258,8 @@ it("predicts a step from what this tab wrote, and not while a claim or step may }); expect(hook().predict("undo")).toBeNull(); await act(() => claim); - const predicted = await vi.waitFor(() => { - const next = hook().predict("undo"); - expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } }); - return next!; - }); + const predicted = hook().predict("undo")!; + expect(predicted?.files).toEqual({ "index.html": { previous: "B", restored: "A" } }); hook().noteOutsideChange(); expect(hook().predict("undo")).toBeNull(); diff --git a/packages/studio/src/hooks/usePersistentEditHistory.ts b/packages/studio/src/hooks/usePersistentEditHistory.ts index 8675b5c0bd..417212cb25 100644 --- a/packages/studio/src/hooks/usePersistentEditHistory.ts +++ b/packages/studio/src/hooks/usePersistentEditHistory.ts @@ -223,7 +223,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory const claimed = claimHeld(reply, label); if (claimed) own.remember(claimed, files); heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null; - void refresh(); + await refresh(); }, [projectId, refresh, own], ); diff --git a/packages/studio/src/utils/studioPendingEdits.test.ts b/packages/studio/src/utils/studioPendingEdits.test.ts index 95a4519ce5..08f26f0f79 100644 --- a/packages/studio/src/utils/studioPendingEdits.test.ts +++ b/packages/studio/src/utils/studioPendingEdits.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { addStudioPendingEditFlushListener, + beginStudioPendingEdit, flushStudioPendingEdits, hasStudioPendingEdits, + revertNewestStudioPendingEdit, trackStudioPendingEdit, trackedStudioEdit, } from "./studioPendingEdits"; @@ -211,3 +213,67 @@ describe("trackedStudioEdit", () => { expect(hasStudioPendingEdits()).toBe(false); }); }); + +describe("a pending edit undo can paint back", () => { + const shown: string[] = []; + const edit = (name: string) => + beginStudioPendingEdit(() => { + shown.push(`${name} undone`); + return () => shown.push(`${name} again`); + }); + + it("paints back only the newest edit, once, and shows it again on request", () => { + shown.length = 0; + const first = edit("first"); + const second = edit("second"); + const again = revertNewestStudioPendingEdit(); + expect(shown).toEqual(["second undone"]); + expect(second.reverted()).toBe(true); + expect(first.reverted()).toBe(false); + expect(revertNewestStudioPendingEdit()).toBeNull(); + again!(); + expect(shown).toEqual(["second undone", "second again"]); + first.settle(); + second.settle(); + }); + + it("paints nothing when the newest edit has no revert", async () => { + shown.length = 0; + const move = edit("move"); + let saved!: () => void; + trackStudioPendingEdit(new Promise((resolve) => (saved = resolve))); + expect(revertNewestStudioPendingEdit()).toBeNull(); + expect(shown).toEqual([]); + saved(); + move.settle(); + await flushStudioPendingEdits(); + }); + + it("counts what the edit starts inside adopt as that edit, not a newer one", async () => { + shown.length = 0; + const move = edit("move"); + let saved!: () => void; + const save = move.adopt(() => + trackStudioPendingEdit(new Promise((resolve) => (saved = resolve))), + ); + move.settle(save); + revertNewestStudioPendingEdit(); + expect(shown).toEqual(["move undone"]); + saved(); + await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" }); + expect(hasStudioPendingEdits()).toBe(false); + }); +}); + +describe("a pending edit whose start throws", () => { + it("ends, so undo and export never wait on it", async () => { + const edit = beginStudioPendingEdit(null); + expect(() => + edit.adopt(() => { + throw new Error("The commit threw."); + }), + ).toThrow("The commit threw."); + await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" }); + expect(hasStudioPendingEdits()).toBe(false); + }); +}); diff --git a/packages/studio/src/utils/studioPendingEdits.ts b/packages/studio/src/utils/studioPendingEdits.ts index 202e5c2d6b..69369805d1 100644 --- a/packages/studio/src/utils/studioPendingEdits.ts +++ b/packages/studio/src/utils/studioPendingEdits.ts @@ -9,7 +9,10 @@ interface StudioFlushPendingEditsDetail { export type StudioPendingEditsDrainResult = StudioSaveDrainResult; -const pendingEditPromises = new Set>(); +export type StudioEditRevert = () => () => void; + +const pendingEdits = new Map, { revert: StudioEditRevert | null }>(); +let adopting = false; function waitForPostBlurEffects(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); @@ -34,11 +37,11 @@ function focusedField(): HTMLElement | null { } export function hasStudioPendingEdits(): boolean { - return pendingEditPromises.size > 0 || focusedField() !== null; + return pendingEdits.size > 0 || focusedField() !== null; } export function isStudioEditSaving(): boolean { - return pendingEditPromises.size > 0; + return pendingEdits.size > 0; } export function afterStudioPendingEdits(run: () => void): () => void { @@ -46,7 +49,7 @@ export function afterStudioPendingEdits(run: () => void): () => void { const check = () => { if (!waiting) return; if (isStudioEditSaving()) { - void Promise.allSettled([...pendingEditPromises]).then(check); + void Promise.allSettled([...pendingEdits.keys()]).then(check); return; } waiting = false; @@ -63,10 +66,11 @@ export function trackStudioPendingEdit( ): Promise | undefined { if (!result) return undefined; const promise = Promise.resolve(result); - pendingEditPromises.add(promise); + if (adopting) return promise; + pendingEdits.set(promise, { revert: null }); promise.then( - () => pendingEditPromises.delete(promise), - () => pendingEditPromises.delete(promise), + () => pendingEdits.delete(promise), + () => pendingEdits.delete(promise), ); return promise; } @@ -81,12 +85,42 @@ export function trackedStudioEdit( }; } +export function beginStudioPendingEdit(revert: StudioEditRevert | null) { + let settle!: (saved?: Promise) => void; + const promise = trackStudioPendingEdit(new Promise((resolve) => (settle = resolve)))!; + const entry = pendingEdits.get(promise)!; + entry.revert = revert; + return { + settle, + reverted: () => entry.revert === null && revert !== null, + // Only what `start` registers synchronously is adopted; a registration after an await is a newer edit. + adopt(start: () => T): T { + adopting = true; + try { + return start(); + } catch (error) { + settle(); + throw error; + } finally { + adopting = false; + } + }, + }; +} + +export function revertNewestStudioPendingEdit(): (() => void) | null { + const newest = [...pendingEdits.values()].at(-1); + const revert = newest?.revert; + if (!newest || !revert) return null; + newest.revert = null; + return revert(); +} + export async function flushStudioPendingEdits(): Promise { const active = focusedField(); if (active) { active.blur(); - // ponytail: Preserve synchronous/microtask blur commits, then cross one task boundary - // so React effects triggered by the blur can register their flush listener. + // ponytail: keep blur commits, then cross one task so effects the blur runs can add their listener. await Promise.resolve(); await waitForPostBlurEffects(); } @@ -96,8 +130,8 @@ export async function flushStudioPendingEdits(): Promise 0 || pendingEditPromises.size > 0) { - const promises = [...detail.promises, ...pendingEditPromises]; + while (detail.promises.length > 0 || pendingEdits.size > 0) { + const promises = [...detail.promises, ...pendingEdits.keys()]; detail.promises = []; const batchFailures = inspectDrainFailures(await Promise.allSettled(promises)); conflict ??= batchFailures.conflict; diff --git a/packages/studio/tests/e2e/edit-accuracy/ratchet.mjs b/packages/studio/tests/e2e/edit-accuracy/ratchet.mjs index 25a03d3ab8..e9d6f61443 100644 --- a/packages/studio/tests/e2e/edit-accuracy/ratchet.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/ratchet.mjs @@ -49,10 +49,7 @@ export function bankable(runs) { * Cases a real Studio race flips run to run, with the PR fixing it: measured and listed every run, never gated. * The fixing PR deletes its own ids here and re-banks them in the same PR. */ -export const QUARANTINED = { - "sequndo-none-px-r0-root-z100": "part C (#4807 stack)", - "seqrepeat-none-px-r0-nested-z100": "part C (#4807 stack)", -}; +export const QUARANTINED = {}; /** Every run of every case: each shard's run plus the re-runs of the cases it flipped. */ // fallow-ignore-next-line complexity diff --git a/packages/studio/tests/e2e/edit-accuracy/sequences.mjs b/packages/studio/tests/e2e/edit-accuracy/sequences.mjs index b0c891c173..1ccdf94c03 100644 --- a/packages/studio/tests/e2e/edit-accuracy/sequences.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/sequences.mjs @@ -150,6 +150,8 @@ async function driveStep(ctx, step, state) { if (step.do === "undo") { await blurPreview(page); await chord(page, "Control+z"); + // A key's DOM change reaches the CDP quads only after a frame; the next step reads from them. + await nextFrame(page); state.depth -= 1; // Undone back to the start, the box belongs where it began; any other undo leaves it unknown here. state.intended = state.depth === 0 ? state.start : null; @@ -255,6 +257,7 @@ async function editText(c, step, state) { if (opened) { await page.keyboard.type(step.word); await page.keyboard.press("Enter"); + await nextFrame(page); } state.smooth.push(smoothness(await recording(page, false))); state.depth += 1;