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
+11
View File
@@ -162,6 +162,17 @@
"produceDrawElementFrame",
],
},
// External-conflict persistence is the #2990 stack boundary. The coordinator
// consumes these exports in child PR #2991; keep the primitive independently reviewable.
{
"file": "packages/studio/src/utils/externalConflictStorage.ts",
"exports": [
"persistExternalConflictSnapshot",
"persistExternalFailureSnapshot",
"loadExternalConflictSnapshot",
"deleteExternalConflictSnapshot",
],
},
// CLI command files: every command exports a const `examples` per the
// convention documented in CLAUDE.md. This is a namespace barrel, not a
// collision.
+3
View File
@@ -343,6 +343,7 @@
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.4.0",
"puppeteer-core": "^25.2.1",
"tailwindcss": "^3.4.0",
@@ -1487,6 +1488,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
"fallow": ["fallow@2.75.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@fallow-cli/darwin-arm64": "2.75.0", "@fallow-cli/darwin-x64": "2.75.0", "@fallow-cli/linux-arm64-gnu": "2.75.0", "@fallow-cli/linux-arm64-musl": "2.75.0", "@fallow-cli/linux-x64-gnu": "2.75.0", "@fallow-cli/linux-x64-musl": "2.75.0", "@fallow-cli/win32-arm64-msvc": "2.75.0", "@fallow-cli/win32-x64-msvc": "2.75.0" }, "bin": { "fallow": "bin/fallow", "fallow-lsp": "bin/fallow-lsp", "fallow-mcp": "bin/fallow-mcp" } }, "sha512-0/2cquNI/cDLP/LzcCbkwI4hMzkX4tE0VY3/69n3PBBeqFpbM2oai+2Cb0sB8dXB8MDUGPVoPJjDW5GiUo7a1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+1
View File
@@ -85,6 +85,7 @@
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.4.0",
"puppeteer-core": "^25.2.1",
"tailwindcss": "^3.4.0",
@@ -27,9 +27,13 @@ export function FileManagerProvider({
editingPathRef,
projectIdRef,
saveRafRef,
flushPendingSourceSave,
discardPendingSourceSave,
getPendingSourceCandidate,
importedFontAssetsRef,
readProjectFile,
writeProjectFile,
overwriteExternalConflict,
readOptionalProjectFile,
observeProjectFileVersion,
updateEditingFileContent,
@@ -67,9 +71,13 @@ export function FileManagerProvider({
editingPathRef,
projectIdRef,
saveRafRef,
flushPendingSourceSave,
discardPendingSourceSave,
getPendingSourceCandidate,
importedFontAssetsRef,
readProjectFile,
writeProjectFile,
overwriteExternalConflict,
readOptionalProjectFile,
observeProjectFileVersion,
updateEditingFileContent,
@@ -101,9 +109,13 @@ export function FileManagerProvider({
editingPathRef,
projectIdRef,
saveRafRef,
flushPendingSourceSave,
discardPendingSourceSave,
getPendingSourceCandidate,
importedFontAssetsRef,
readProjectFile,
writeProjectFile,
overwriteExternalConflict,
readOptionalProjectFile,
observeProjectFileVersion,
updateEditingFileContent,
@@ -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,
@@ -0,0 +1,95 @@
import { IDBFactory } from "fake-indexeddb";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMemoryExternalConflictStorage,
deleteExternalConflictSnapshot,
loadExternalConflictSnapshot,
persistExternalConflictSnapshot,
type ExternalConflictSnapshot,
} from "./externalConflictStorage";
import { StudioFileConflictError } from "./studioSaveDiagnostics";
describe("external conflict snapshots", () => {
beforeEach(() => {
vi.stubGlobal("indexedDB", new IDBFactory());
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("durably records both complete file versions before resolution", async () => {
const storage = createMemoryExternalConflictStorage();
const snapshot: ExternalConflictSnapshot = {
kind: "conflict",
projectId: "project-a",
filePath: "index.html",
externalVersion: "v2",
externalContent: "external full file",
studioContent: "studio full file",
createdAt: 100,
};
await storage.set(snapshot);
expect(await storage.get("project-a", "index.html")).toEqual(snapshot);
await storage.delete("project-a", "index.html");
expect(await storage.get("project-a", "index.html")).toBeNull();
});
it("records a failed final Studio candidate for remount recovery", async () => {
const storage = createMemoryExternalConflictStorage();
const snapshot: ExternalConflictSnapshot = {
kind: "failed",
projectId: "project-a",
filePath: "index.html",
externalVersion: null,
externalContent: null,
studioContent: "recover me",
failureMessage: "network unavailable",
createdAt: 101,
};
await storage.set(snapshot);
expect(await storage.get("project-a", "index.html")).toEqual(snapshot);
});
it("persists, loads, and deletes a snapshot through the production IndexedDB adapter", async () => {
vi.spyOn(Date, "now").mockReturnValue(1234);
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "v2",
currentContent: "external full file",
attemptedContent: "studio full file",
});
await persistExternalConflictSnapshot("indexeddb-project", conflict);
await expect(loadExternalConflictSnapshot("indexeddb-project", "index.html")).resolves.toEqual({
kind: "conflict",
projectId: "indexeddb-project",
filePath: "index.html",
externalVersion: "v2",
externalContent: "external full file",
studioContent: "studio full file",
createdAt: 1234,
});
await deleteExternalConflictSnapshot("indexeddb-project", "index.html");
await expect(
loadExternalConflictSnapshot("indexeddb-project", "index.html"),
).resolves.toBeNull();
});
it("rejects persistence when IndexedDB is unavailable", async () => {
vi.stubGlobal("indexedDB", undefined);
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "v2",
currentContent: "external",
attemptedContent: "studio",
});
await expect(persistExternalConflictSnapshot("project-a", conflict)).rejects.toThrow(
"IndexedDB is unavailable; conflict recovery snapshot was not saved",
);
});
});
@@ -0,0 +1,172 @@
import type { StudioFileConflictError } from "./studioSaveDiagnostics";
interface ExternalRecoverySnapshotBase {
projectId: string;
filePath: string;
externalVersion: string | null;
externalContent: string | null;
studioContent: string;
createdAt: number;
}
export type ExternalConflictSnapshot =
| (ExternalRecoverySnapshotBase & { kind: "conflict" })
| (ExternalRecoverySnapshotBase & { kind: "failed"; failureMessage: string });
export interface ExternalConflictStorage {
get(projectId: string, filePath: string): Promise<ExternalConflictSnapshot | null>;
set(snapshot: ExternalConflictSnapshot): Promise<void>;
delete(projectId: string, filePath: string): Promise<void>;
}
const DB_NAME = "hyperframes-studio-external-conflicts";
const DB_VERSION = 1;
const STORE_NAME = "file-conflicts";
function key(projectId: string, filePath: string): string {
return `${projectId}\0${filePath}`;
}
export function createMemoryExternalConflictStorage(): ExternalConflictStorage {
const snapshots = new Map<string, ExternalConflictSnapshot>();
return {
async get(projectId, filePath) {
const snapshot = snapshots.get(key(projectId, filePath));
return snapshot ? structuredClone(snapshot) : null;
},
async set(snapshot) {
snapshots.set(key(snapshot.projectId, snapshot.filePath), structuredClone(snapshot));
},
async delete(projectId, filePath) {
snapshots.delete(key(projectId, filePath));
},
};
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (!globalThis.indexedDB) {
reject(new Error("IndexedDB is unavailable; conflict recovery snapshot was not saved"));
return;
}
const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
};
request.onerror = () => reject(request.error ?? new Error("Failed to open conflict storage"));
request.onsuccess = () => resolve(request.result);
});
}
function withStore<T>(
mode: IDBTransactionMode,
callback: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode);
const request = callback(transaction.objectStore(STORE_NAME));
let result!: T;
request.onerror = () => reject(request.error ?? new Error("Conflict storage failed"));
request.onsuccess = () => {
result = request.result;
};
transaction.oncomplete = () => {
db.close();
resolve(result);
};
transaction.onerror = () => {
db.close();
reject(transaction.error ?? new Error("Conflict storage transaction failed"));
};
transaction.onabort = () => {
db.close();
reject(transaction.error ?? new Error("Conflict storage transaction was aborted"));
};
}),
);
}
function createIndexedDbExternalConflictStorage(): ExternalConflictStorage {
return {
async get(projectId, filePath) {
return (
(await withStore<ExternalConflictSnapshot | undefined>("readonly", (store) =>
store.get(key(projectId, filePath)),
)) ?? null
);
},
async set(snapshot) {
await withStore<IDBValidKey>("readwrite", (store) =>
store.put(snapshot, key(snapshot.projectId, snapshot.filePath)),
);
},
async delete(projectId, filePath) {
await withStore<undefined>("readwrite", (store) => store.delete(key(projectId, filePath)));
},
};
}
const indexedDbStorage = createIndexedDbExternalConflictStorage();
/**
* Persist a conflict before offering destructive recovery actions.
* Callers must await this promise: rejection means the Studio draft was not saved durably and
* must remain available in memory while the storage failure is surfaced to the user.
*/
export async function persistExternalConflictSnapshot(
projectId: string,
conflict: StudioFileConflictError,
): Promise<void> {
await indexedDbStorage.set({
kind: "conflict",
projectId,
filePath: conflict.filePath,
externalVersion: conflict.currentVersion,
externalContent: conflict.currentContent,
studioContent: conflict.attemptedContent,
createdAt: Date.now(),
});
}
/**
* Persist the final Studio candidate after its file write fails.
* Callers must await this promise: rejection means the Studio draft was not saved durably and
* must remain available in memory while the storage failure is surfaced to the user.
*/
export async function persistExternalFailureSnapshot(
projectId: string,
filePath: string,
studioContent: string,
externalVersion: string | null,
externalContent: string | null,
error: unknown,
): Promise<void> {
await indexedDbStorage.set({
kind: "failed",
projectId,
filePath,
externalVersion,
externalContent,
studioContent,
failureMessage: error instanceof Error ? error.message : String(error),
createdAt: Date.now(),
});
}
export async function loadExternalConflictSnapshot(
projectId: string,
filePath: string,
): Promise<ExternalConflictSnapshot | null> {
return indexedDbStorage.get(projectId, filePath);
}
export async function deleteExternalConflictSnapshot(
projectId: string,
filePath: string,
): Promise<void> {
await indexedDbStorage.delete(projectId, filePath);
}