Skip to content

Commit b30a234

Browse files
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
1 parent 4713138 commit b30a234

8 files changed

Lines changed: 465 additions & 10 deletions

File tree

‎.fallowrc.jsonc‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,17 @@
162162
"produceDrawElementFrame",
163163
],
164164
},
165+
// External-conflict persistence is the #2990 stack boundary. The coordinator
166+
// consumes these exports in child PR #2991; keep the primitive independently reviewable.
167+
{
168+
"file": "packages/studio/src/utils/externalConflictStorage.ts",
169+
"exports": [
170+
"persistExternalConflictSnapshot",
171+
"persistExternalFailureSnapshot",
172+
"loadExternalConflictSnapshot",
173+
"deleteExternalConflictSnapshot",
174+
],
175+
},
165176
// CLI command files: every command exports a const `examples` per the
166177
// convention documented in CLAUDE.md. This is a namespace barrel, not a
167178
// collision.

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/studio/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"@types/react-dom": "19",
8686
"@vitejs/plugin-react": "^4.0.0",
8787
"autoprefixer": "^10.4.0",
88+
"fake-indexeddb": "^6.2.5",
8889
"postcss": "^8.4.0",
8990
"puppeteer-core": "^25.2.1",
9091
"tailwindcss": "^3.4.0",

‎packages/studio/src/contexts/FileManagerContext.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ export function FileManagerProvider({
2727
editingPathRef,
2828
projectIdRef,
2929
saveRafRef,
30+
flushPendingSourceSave,
31+
discardPendingSourceSave,
32+
getPendingSourceCandidate,
3033
importedFontAssetsRef,
3134
readProjectFile,
3235
writeProjectFile,
36+
overwriteExternalConflict,
3337
readOptionalProjectFile,
3438
observeProjectFileVersion,
3539
updateEditingFileContent,
@@ -67,9 +71,13 @@ export function FileManagerProvider({
6771
editingPathRef,
6872
projectIdRef,
6973
saveRafRef,
74+
flushPendingSourceSave,
75+
discardPendingSourceSave,
76+
getPendingSourceCandidate,
7077
importedFontAssetsRef,
7178
readProjectFile,
7279
writeProjectFile,
80+
overwriteExternalConflict,
7381
readOptionalProjectFile,
7482
observeProjectFileVersion,
7583
updateEditingFileContent,
@@ -101,9 +109,13 @@ export function FileManagerProvider({
101109
editingPathRef,
102110
projectIdRef,
103111
saveRafRef,
112+
flushPendingSourceSave,
113+
discardPendingSourceSave,
114+
getPendingSourceCandidate,
104115
importedFontAssetsRef,
105116
readProjectFile,
106117
writeProjectFile,
118+
overwriteExternalConflict,
107119
readOptionalProjectFile,
108120
observeProjectFileVersion,
109121
updateEditingFileContent,

‎packages/studio/src/hooks/useFileManager.projectOwnership.test.tsx‎

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,65 @@ vi.mock("./useEditorSave", () => ({
2020
useEditorSave: () => ({
2121
saveRafRef: { current: null },
2222
handleContentChange: vi.fn(),
23+
getPendingCandidate: vi.fn(() => null),
24+
flushPendingSave: vi.fn(async () => ({ status: "clean" as const })),
25+
discardPendingSave: vi.fn(),
2326
}),
2427
}));
2528

2629
import { useFileManager } from "./useFileManager";
30+
import { resetStudioWriteTokens, studioFileContentVersion } from "../utils/studioFileVersion";
31+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
2732

2833
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
2934

35+
function useTestFileManager(projectId: string) {
36+
return useFileManager({
37+
projectId,
38+
showToast: vi.fn(),
39+
recordEdit: vi.fn(async () => {}),
40+
domEditSaveTimestampRef: { current: 0 },
41+
setRefreshKey: vi.fn(),
42+
});
43+
}
44+
45+
async function mountTestFileManager(projectId = "project-a") {
46+
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
47+
function Probe() {
48+
captured.manager = useTestFileManager(projectId);
49+
return null;
50+
}
51+
const root = createRoot(document.createElement("div"));
52+
await act(async () => root.render(<Probe />));
53+
const manager = captured.manager;
54+
if (!manager) throw new Error("file manager did not render");
55+
return { manager, root };
56+
}
57+
58+
async function mountOverwriteRequest(response: Response) {
59+
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
60+
if (init?.method !== "PUT") {
61+
throw new Error("overwrite unexpectedly performed a preflight read");
62+
}
63+
return Promise.resolve(response);
64+
});
65+
vi.stubGlobal("fetch", fetchMock);
66+
return { ...(await mountTestFileManager()), fetchMock };
67+
}
68+
69+
function createOverwriteConflict(currentVersion: string | null, currentContent: string | null) {
70+
return new StudioFileConflictError({
71+
filePath: "index.html",
72+
currentVersion,
73+
currentContent,
74+
attemptedContent: "STUDIO",
75+
});
76+
}
77+
3078
describe("useFileManager project ownership", () => {
3179
afterEach(() => {
80+
resetStudioWriteTokens();
81+
vi.useRealTimers();
3282
vi.unstubAllGlobals();
3383
});
3484

@@ -54,13 +104,7 @@ describe("useFileManager project ownership", () => {
54104

55105
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
56106
function Probe({ projectId }: { projectId: string }) {
57-
captured.manager = useFileManager({
58-
projectId,
59-
showToast: vi.fn(),
60-
recordEdit: vi.fn(async () => {}),
61-
domEditSaveTimestampRef: { current: 0 },
62-
setRefreshKey: vi.fn(),
63-
});
107+
captured.manager = useTestFileManager(projectId);
64108
return null;
65109
}
66110

@@ -106,4 +150,92 @@ describe("useFileManager project ownership", () => {
106150

107151
await act(async () => root.unmount());
108152
});
153+
154+
it("uses a fresh write token when a lost response retries a committed save", async () => {
155+
vi.useFakeTimers();
156+
let putAttempt = 0;
157+
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
158+
if (!init?.method) {
159+
return Promise.resolve({
160+
ok: true,
161+
json: async () => ({ content: "BEFORE", version: "v1" }),
162+
} as Response);
163+
}
164+
putAttempt += 1;
165+
if (putAttempt === 1) return Promise.reject(new TypeError("response lost"));
166+
return Promise.resolve({ ok: true, json: async () => ({ version: "v2" }) } as Response);
167+
});
168+
vi.stubGlobal("fetch", fetchMock);
169+
170+
const { manager, root } = await mountTestFileManager();
171+
await manager.readProjectFile("index.html");
172+
173+
const write = manager.writeProjectFile("index.html", "AFTER");
174+
await vi.runAllTimersAsync();
175+
await write;
176+
177+
const writeTokens = fetchMock.mock.calls
178+
.filter(([, init]) => init?.method === "PUT")
179+
.map(([, init]) => new Headers(init?.headers).get("X-Hyperframes-Write-Token"));
180+
expect(writeTokens).toHaveLength(2);
181+
expect(writeTokens[0]).toBeTruthy();
182+
expect(writeTokens[1]).not.toBe(writeTokens[0]);
183+
184+
await act(async () => root.unmount());
185+
});
186+
187+
it("overwrites the exact external content version with an If-Match precondition", async () => {
188+
const { manager, root, fetchMock } = await mountOverwriteRequest({
189+
ok: true,
190+
json: async () => ({ version: "v3" }),
191+
} as Response);
192+
const conflict = createOverwriteConflict("v2", "EXTERNAL");
193+
194+
await manager.overwriteExternalConflict(conflict);
195+
196+
const [, init] = fetchMock.mock.calls[0] ?? [];
197+
const headers = new Headers(init?.headers);
198+
expect(init).toMatchObject({ method: "PUT", body: "STUDIO" });
199+
expect(headers.get("If-Match")).toBe(await studioFileContentVersion("EXTERNAL"));
200+
expect(headers.get("If-None-Match")).toBeNull();
201+
await act(async () => root.unmount());
202+
});
203+
204+
it("preserves a newer third-party edit when a content-less conflict version is stale", async () => {
205+
const { manager, root, fetchMock } = await mountOverwriteRequest({
206+
ok: false,
207+
status: 409,
208+
json: async () => ({ currentVersion: "v3", currentContent: "THIRD PARTY" }),
209+
} as Response);
210+
const conflict = createOverwriteConflict("v2", null);
211+
212+
await expect(manager.overwriteExternalConflict(conflict)).rejects.toMatchObject({
213+
name: "StudioFileConflictError",
214+
currentVersion: "v3",
215+
currentContent: "THIRD PARTY",
216+
attemptedContent: "STUDIO",
217+
});
218+
219+
const [, init] = fetchMock.mock.calls[0] ?? [];
220+
const headers = new Headers(init?.headers);
221+
expect(headers.get("If-Match")).toBe("v2");
222+
expect(headers.get("If-None-Match")).toBeNull();
223+
await act(async () => root.unmount());
224+
});
225+
226+
it("uses create-only semantics when the conflicted file was deleted", async () => {
227+
const { manager, root, fetchMock } = await mountOverwriteRequest({
228+
ok: true,
229+
json: async () => ({ version: "v1" }),
230+
} as Response);
231+
const conflict = createOverwriteConflict(null, null);
232+
233+
await manager.overwriteExternalConflict(conflict);
234+
235+
const [, init] = fetchMock.mock.calls[0] ?? [];
236+
const headers = new Headers(init?.headers);
237+
expect(headers.get("If-Match")).toBeNull();
238+
expect(headers.get("If-None-Match")).toBe("*");
239+
await act(async () => root.unmount());
240+
});
109241
});

‎packages/studio/src/hooks/useFileManager.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
StudioFileConflictError,
1111
StudioSaveNetworkError,
1212
} from "../utils/studioSaveDiagnostics";
13-
import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
13+
import {
14+
createStudioWriteToken,
15+
markStudioWriteToken,
16+
studioExpectedFileVersion,
17+
} from "../utils/studioFileVersion";
1418
import { useFileTree } from "./useFileTree";
1519
import { useEditorSave } from "./useEditorSave";
1620

@@ -117,8 +121,11 @@ export function useFileManager({
117121
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
118122
}
119123
}
120-
const writeToken = createStudioWriteToken();
121124
await retryStudioSave(async () => {
125+
// Each request gets its own receipt identity. If a committed request loses its response,
126+
// the retry can produce a second filesystem receipt that must be suppressed independently.
127+
const writeToken = createStudioWriteToken();
128+
markStudioWriteToken(writeToken);
122129
let response: Response;
123130
try {
124131
response = await fetch(
@@ -191,7 +198,7 @@ export function useFileManager({
191198

192199
// ── Editor save (debounced content change) ──
193200

194-
const { saveRafRef, handleContentChange } = useEditorSave({
201+
const editorSave = useEditorSave({
195202
editingPathRef,
196203
projectIdRef,
197204
readProjectFile,
@@ -201,6 +208,24 @@ export function useFileManager({
201208
setRefreshKey,
202209
showToast,
203210
});
211+
const { saveRafRef, handleContentChange } = editorSave;
212+
213+
const overwriteExternalConflict = useCallback(
214+
async (conflict: StudioFileConflictError) => {
215+
if (conflict.currentContent != null) {
216+
await writeProjectFile(
217+
conflict.filePath,
218+
conflict.attemptedContent,
219+
conflict.currentContent,
220+
);
221+
} else {
222+
fileVersions.set(conflict.filePath, conflict.currentVersion);
223+
await writeProjectFile(conflict.filePath, conflict.attemptedContent);
224+
}
225+
updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
226+
},
227+
[fileVersions, updateEditingFileContent, writeProjectFile],
228+
);
204229

205230
// ── File select ──
206231

@@ -491,11 +516,15 @@ export function useFileManager({
491516
editingPathRef,
492517
projectIdRef,
493518
saveRafRef,
519+
flushPendingSourceSave: editorSave.flushPendingSave,
520+
discardPendingSourceSave: editorSave.discardPendingSave,
521+
getPendingSourceCandidate: editorSave.getPendingCandidate,
494522
importedFontAssetsRef,
495523

496524
// Core I/O
497525
readProjectFile,
498526
writeProjectFile,
527+
overwriteExternalConflict,
499528
readOptionalProjectFile,
500529
observeProjectFileVersion,
501530
updateEditingFileContent,

0 commit comments

Comments
 (0)