mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): drain pending edits before reload (#2989)
* fix(studio): drain pending edits before reload * fix(studio): address drain review feedback (#2989) - prioritize conflicts and clear recovered DOM queue errors - cover delayed blur effects and missing drain branches - document stacked consumers and extend write-token retention * test(studio): satisfy drain audit gate (#2989) - share the editor-save hook harness across drain regressions - extract settled failure inspection from the drain loop
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
|
||||
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type WriteProjectFile = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
|
||||
async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
||||
const captured: { handle: EditorSaveHandle | null } = { handle: null };
|
||||
|
||||
function Probe() {
|
||||
captured.handle = useEditorSave({
|
||||
editingPathRef: { current: "index.html" },
|
||||
projectIdRef: { current: "project-a" },
|
||||
readProjectFile: vi.fn(async () => "before"),
|
||||
writeProjectFile,
|
||||
recordEdit: vi.fn(async () => undefined),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
setRefreshKey: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<Probe />));
|
||||
if (!captured.handle) throw new Error("Editor save handle was not mounted");
|
||||
|
||||
return {
|
||||
handle: captured.handle,
|
||||
unmount: () => act(async () => root.unmount()),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useEditorSave pending work", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn(() => 41),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("exposes and flushes the latest rAF-buffered source candidate", async () => {
|
||||
const writeProjectFile = vi.fn(async () => undefined);
|
||||
const mounted = await mountEditorSave(writeProjectFile);
|
||||
act(() => mounted.handle.handleContentChange("studio candidate"));
|
||||
|
||||
expect(mounted.handle.getPendingCandidate()).toEqual({
|
||||
projectId: "project-a",
|
||||
path: "index.html",
|
||||
content: "studio candidate",
|
||||
});
|
||||
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({ status: "clean" });
|
||||
expect(writeProjectFile).toHaveBeenCalledWith("index.html", "studio candidate", "before");
|
||||
|
||||
await mounted.unmount();
|
||||
});
|
||||
|
||||
it("joins an in-flight source save instead of writing the frozen candidate twice", async () => {
|
||||
let frame: FrameRequestCallback | null = null;
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
frame = callback;
|
||||
return 42;
|
||||
}),
|
||||
);
|
||||
let finishWrite!: () => void;
|
||||
const writeProjectFile = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishWrite = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
const mounted = await mountEditorSave(writeProjectFile);
|
||||
act(() => mounted.handle.handleContentChange("candidate"));
|
||||
act(() => frame?.(0));
|
||||
await vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledOnce());
|
||||
|
||||
const drained = mounted.handle.flushPendingSave();
|
||||
expect(writeProjectFile).toHaveBeenCalledOnce();
|
||||
finishWrite();
|
||||
await expect(drained).resolves.toEqual({ status: "clean" });
|
||||
expect(writeProjectFile).toHaveBeenCalledOnce();
|
||||
await mounted.unmount();
|
||||
});
|
||||
|
||||
it("preserves conflict details when flushing a buffered source candidate", async () => {
|
||||
const conflict = new StudioFileConflictError({
|
||||
filePath: "index.html",
|
||||
currentVersion: "external-v2",
|
||||
currentContent: "external",
|
||||
attemptedContent: "studio candidate",
|
||||
});
|
||||
const mounted = await mountEditorSave(async () => {
|
||||
throw conflict;
|
||||
});
|
||||
act(() => mounted.handle.handleContentChange("studio candidate"));
|
||||
|
||||
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({
|
||||
status: "conflict",
|
||||
error: conflict,
|
||||
});
|
||||
|
||||
await mounted.unmount();
|
||||
});
|
||||
|
||||
it("discards an rAF-buffered candidate without persisting it", async () => {
|
||||
const writeProjectFile = vi.fn(async () => undefined);
|
||||
const mounted = await mountEditorSave(writeProjectFile);
|
||||
act(() => mounted.handle.handleContentChange("discard me"));
|
||||
act(() => mounted.handle.discardPendingSave());
|
||||
|
||||
expect(mounted.handle.getPendingCandidate()).toBeNull();
|
||||
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({ status: "clean" });
|
||||
expect(writeProjectFile).not.toHaveBeenCalled();
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(41);
|
||||
|
||||
await mounted.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user