mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
feat(studio): preserve external file conflicts (#2990)
* 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 * feat(studio): preserve external file conflicts * fix(studio): isolate retry write receipts * test(studio): cover external conflict recovery safety
This commit is contained in:
@@ -20,15 +20,65 @@ vi.mock("./useEditorSave", () => ({
|
||||
useEditorSave: () => ({
|
||||
saveRafRef: { current: null },
|
||||
handleContentChange: vi.fn(),
|
||||
getPendingCandidate: vi.fn(() => null),
|
||||
flushPendingSave: vi.fn(async () => ({ status: "clean" as const })),
|
||||
discardPendingSave: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useFileManager } from "./useFileManager";
|
||||
import { resetStudioWriteTokens, studioFileContentVersion } from "../utils/studioFileVersion";
|
||||
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function useTestFileManager(projectId: string) {
|
||||
return useFileManager({
|
||||
projectId,
|
||||
showToast: vi.fn(),
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
setRefreshKey: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
async function mountTestFileManager(projectId = "project-a") {
|
||||
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
|
||||
function Probe() {
|
||||
captured.manager = useTestFileManager(projectId);
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<Probe />));
|
||||
const manager = captured.manager;
|
||||
if (!manager) throw new Error("file manager did not render");
|
||||
return { manager, root };
|
||||
}
|
||||
|
||||
async function mountOverwriteRequest(response: Response) {
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (init?.method !== "PUT") {
|
||||
throw new Error("overwrite unexpectedly performed a preflight read");
|
||||
}
|
||||
return Promise.resolve(response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return { ...(await mountTestFileManager()), fetchMock };
|
||||
}
|
||||
|
||||
function createOverwriteConflict(currentVersion: string | null, currentContent: string | null) {
|
||||
return new StudioFileConflictError({
|
||||
filePath: "index.html",
|
||||
currentVersion,
|
||||
currentContent,
|
||||
attemptedContent: "STUDIO",
|
||||
});
|
||||
}
|
||||
|
||||
describe("useFileManager project ownership", () => {
|
||||
afterEach(() => {
|
||||
resetStudioWriteTokens();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -54,13 +104,7 @@ describe("useFileManager project ownership", () => {
|
||||
|
||||
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
|
||||
function Probe({ projectId }: { projectId: string }) {
|
||||
captured.manager = useFileManager({
|
||||
projectId,
|
||||
showToast: vi.fn(),
|
||||
recordEdit: vi.fn(async () => {}),
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
setRefreshKey: vi.fn(),
|
||||
});
|
||||
captured.manager = useTestFileManager(projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -106,4 +150,92 @@ describe("useFileManager project ownership", () => {
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("uses a fresh write token when a lost response retries a committed save", async () => {
|
||||
vi.useFakeTimers();
|
||||
let putAttempt = 0;
|
||||
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
if (!init?.method) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ content: "BEFORE", version: "v1" }),
|
||||
} as Response);
|
||||
}
|
||||
putAttempt += 1;
|
||||
if (putAttempt === 1) return Promise.reject(new TypeError("response lost"));
|
||||
return Promise.resolve({ ok: true, json: async () => ({ version: "v2" }) } as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { manager, root } = await mountTestFileManager();
|
||||
await manager.readProjectFile("index.html");
|
||||
|
||||
const write = manager.writeProjectFile("index.html", "AFTER");
|
||||
await vi.runAllTimersAsync();
|
||||
await write;
|
||||
|
||||
const writeTokens = fetchMock.mock.calls
|
||||
.filter(([, init]) => init?.method === "PUT")
|
||||
.map(([, init]) => new Headers(init?.headers).get("X-Hyperframes-Write-Token"));
|
||||
expect(writeTokens).toHaveLength(2);
|
||||
expect(writeTokens[0]).toBeTruthy();
|
||||
expect(writeTokens[1]).not.toBe(writeTokens[0]);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("overwrites the exact external content version with an If-Match precondition", async () => {
|
||||
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
||||
ok: true,
|
||||
json: async () => ({ version: "v3" }),
|
||||
} as Response);
|
||||
const conflict = createOverwriteConflict("v2", "EXTERNAL");
|
||||
|
||||
await manager.overwriteExternalConflict(conflict);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(init).toMatchObject({ method: "PUT", body: "STUDIO" });
|
||||
expect(headers.get("If-Match")).toBe(await studioFileContentVersion("EXTERNAL"));
|
||||
expect(headers.get("If-None-Match")).toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("preserves a newer third-party edit when a content-less conflict version is stale", async () => {
|
||||
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ currentVersion: "v3", currentContent: "THIRD PARTY" }),
|
||||
} as Response);
|
||||
const conflict = createOverwriteConflict("v2", null);
|
||||
|
||||
await expect(manager.overwriteExternalConflict(conflict)).rejects.toMatchObject({
|
||||
name: "StudioFileConflictError",
|
||||
currentVersion: "v3",
|
||||
currentContent: "THIRD PARTY",
|
||||
attemptedContent: "STUDIO",
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("If-Match")).toBe("v2");
|
||||
expect(headers.get("If-None-Match")).toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("uses create-only semantics when the conflicted file was deleted", async () => {
|
||||
const { manager, root, fetchMock } = await mountOverwriteRequest({
|
||||
ok: true,
|
||||
json: async () => ({ version: "v1" }),
|
||||
} as Response);
|
||||
const conflict = createOverwriteConflict(null, null);
|
||||
|
||||
await manager.overwriteExternalConflict(conflict);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const headers = new Headers(init?.headers);
|
||||
expect(headers.get("If-Match")).toBeNull();
|
||||
expect(headers.get("If-None-Match")).toBe("*");
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
StudioFileConflictError,
|
||||
StudioSaveNetworkError,
|
||||
} from "../utils/studioSaveDiagnostics";
|
||||
import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
|
||||
import {
|
||||
createStudioWriteToken,
|
||||
markStudioWriteToken,
|
||||
studioExpectedFileVersion,
|
||||
} from "../utils/studioFileVersion";
|
||||
import { useFileTree } from "./useFileTree";
|
||||
import { useEditorSave } from "./useEditorSave";
|
||||
|
||||
@@ -117,8 +121,11 @@ export function useFileManager({
|
||||
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
|
||||
}
|
||||
}
|
||||
const writeToken = createStudioWriteToken();
|
||||
await retryStudioSave(async () => {
|
||||
// Each request gets its own receipt identity. If a committed request loses its response,
|
||||
// the retry can produce a second filesystem receipt that must be suppressed independently.
|
||||
const writeToken = createStudioWriteToken();
|
||||
markStudioWriteToken(writeToken);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
@@ -191,7 +198,7 @@ export function useFileManager({
|
||||
|
||||
// ── Editor save (debounced content change) ──
|
||||
|
||||
const { saveRafRef, handleContentChange } = useEditorSave({
|
||||
const editorSave = useEditorSave({
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
readProjectFile,
|
||||
@@ -201,6 +208,24 @@ export function useFileManager({
|
||||
setRefreshKey,
|
||||
showToast,
|
||||
});
|
||||
const { saveRafRef, handleContentChange } = editorSave;
|
||||
|
||||
const overwriteExternalConflict = useCallback(
|
||||
async (conflict: StudioFileConflictError) => {
|
||||
if (conflict.currentContent != null) {
|
||||
await writeProjectFile(
|
||||
conflict.filePath,
|
||||
conflict.attemptedContent,
|
||||
conflict.currentContent,
|
||||
);
|
||||
} else {
|
||||
fileVersions.set(conflict.filePath, conflict.currentVersion);
|
||||
await writeProjectFile(conflict.filePath, conflict.attemptedContent);
|
||||
}
|
||||
updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
|
||||
},
|
||||
[fileVersions, updateEditingFileContent, writeProjectFile],
|
||||
);
|
||||
|
||||
// ── File select ──
|
||||
|
||||
@@ -491,11 +516,15 @@ export function useFileManager({
|
||||
editingPathRef,
|
||||
projectIdRef,
|
||||
saveRafRef,
|
||||
flushPendingSourceSave: editorSave.flushPendingSave,
|
||||
discardPendingSourceSave: editorSave.discardPendingSave,
|
||||
getPendingSourceCandidate: editorSave.getPendingCandidate,
|
||||
importedFontAssetsRef,
|
||||
|
||||
// Core I/O
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
overwriteExternalConflict,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
|
||||
Reference in New Issue
Block a user