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:
Miguel Ángel
2026-08-04 21:19:42 +00:00
committed by GitHub
parent 4713138544
commit b30a23402e
8 changed files with 465 additions and 10 deletions
@@ -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());
});
});
+32 -3
View File
@@ -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,