Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions packages/studio/src/hooks/useEditorSave.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// @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;

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 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 />));
act(() => captured.handle?.handleContentChange("studio candidate"));

expect(captured.handle?.getPendingCandidate()).toEqual({
projectId: "project-a",
path: "index.html",
content: "studio candidate",
});
await expect(captured.handle?.flushPendingSave()).resolves.toEqual({ status: "clean" });
expect(writeProjectFile).toHaveBeenCalledWith("index.html", "studio candidate", "before");

await act(async () => root.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 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 />));
act(() => captured.handle?.handleContentChange("candidate"));
act(() => frame?.(0));
await vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledOnce());

const drained = captured.handle?.flushPendingSave();
expect(writeProjectFile).toHaveBeenCalledOnce();
finishWrite();
await expect(drained).resolves.toEqual({ status: "clean" });
expect(writeProjectFile).toHaveBeenCalledOnce();
await act(async () => root.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 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: vi.fn(async () => {
throw conflict;
}),
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 />));
act(() => captured.handle?.handleContentChange("studio candidate"));

await expect(captured.handle?.flushPendingSave()).resolves.toEqual({
status: "conflict",
error: conflict,
});

await act(async () => root.unmount());
});

it("discards an rAF-buffered candidate without persisting it", async () => {
const writeProjectFile = vi.fn(async () => undefined);
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 />));
act(() => captured.handle?.handleContentChange("discard me"));
act(() => captured.handle?.discardPendingSave());

expect(captured.handle?.getPendingCandidate()).toBeNull();
await expect(captured.handle?.flushPendingSave()).resolves.toEqual({ status: "clean" });
expect(writeProjectFile).not.toHaveBeenCalled();
expect(cancelAnimationFrame).toHaveBeenCalledWith(41);

await act(async () => root.unmount());
});
});
152 changes: 113 additions & 39 deletions packages/studio/src/hooks/useEditorSave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { useCallback, useRef } from "react";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
import { trackStudioEvent } from "../utils/studioTelemetry";
import {
StudioFileConflictError,
type StudioSaveDrainResult,
} from "../utils/studioSaveDiagnostics";

interface RecordEditInput {
label: string;
Expand All @@ -21,6 +25,25 @@ interface UseEditorSaveOptions {
showToast: (message: string, tone?: "error" | "info") => void;
}

export interface EditorSaveCandidate {
projectId: string;
path: string;
content: string;
}

export type EditorSaveDrainResult = StudioSaveDrainResult;

export interface EditorSaveHandle {
saveRafRef: React.MutableRefObject<number | null>;
handleContentChange: (content: string) => void;
/** Read by the external-reload reconciliation introduced in stack PR #2993. */
getPendingCandidate: () => EditorSaveCandidate | null;
/** Wired into the external-reload drain by stack PR #2993. */
flushPendingSave: () => Promise<EditorSaveDrainResult>;
/** Used by PR #2993 when the external version wins. */
discardPendingSave: () => void;
}

export function useEditorSave({
editingPathRef,
projectIdRef,
Expand All @@ -30,12 +53,70 @@ export function useEditorSave({
domEditSaveTimestampRef,
setRefreshKey,
showToast,
}: UseEditorSaveOptions) {
}: UseEditorSaveOptions): EditorSaveHandle {
const saveRafRef = useRef<number | null>(null);
const refreshRafRef = useRef<number | null>(null);
// One error toast per burst of failures — every keystroke retries the save,
// and error toasts persist until dismissed, so don't stack duplicates.
const lastFailureToastAtRef = useRef(0);
const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);

const reportFailure = useCallback(
(path: string, error: unknown) => {
trackStudioEvent("save_failure", {
source: "code_editor",
error_message: error instanceof Error ? error.message : "unknown",
});
const now = Date.now();
if (now - lastFailureToastAtRef.current > 5000) {
lastFailureToastAtRef.current = now;
showToast(
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
"error",
);
}
},
[showToast],
);

const persistCandidate = useCallback(
(candidate: EditorSaveCandidate): Promise<EditorSaveDrainResult> => {
const task = saveProjectFilesWithHistory({
projectId: candidate.projectId,
label: "Edit source",
kind: "source",
coalesceKey: `source:${candidate.path}`,
files: { [candidate.path]: candidate.content },
readFile: readProjectFile,
writeFile: writeProjectFile,
recordEdit,
})
.then<EditorSaveDrainResult>(() => {
if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
return { status: "clean" };
})
.catch<EditorSaveDrainResult>((error: unknown) => {
reportFailure(candidate.path, error);
return error instanceof StudioFileConflictError
? { status: "conflict", error }
: { status: "failed", error };
})
.finally(() => {
if (inFlightRef.current === task) {
inFlightRef.current = null;
inFlightCandidateRef.current = null;
}
});
inFlightRef.current = task;
inFlightCandidateRef.current = candidate;
return task;
},
[readProjectFile, recordEdit, reportFailure, setRefreshKey, writeProjectFile],
);

const handleContentChange = useCallback(
(content: string) => {
Expand All @@ -44,53 +125,46 @@ export function useEditorSave({
const path = editingPathRef.current;
if (!path) return;

const candidate = { projectId: pid, path, content };
pendingCandidateRef.current = candidate;

if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
saveRafRef.current = requestAnimationFrame(() => {
saveRafRef.current = null;
domEditSaveTimestampRef.current = Date.now();
saveProjectFilesWithHistory({
projectId: pid,
label: "Edit source",
kind: "source",
coalesceKey: `source:${path}`,
files: { [path]: content },
readFile: readProjectFile,
writeFile: writeProjectFile,
recordEdit,
})
.then(() => {
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
})
.catch((error) => {
trackStudioEvent("save_failure", {
source: "code_editor",
error_message: error instanceof Error ? error.message : "unknown",
});
const now = Date.now();
if (now - lastFailureToastAtRef.current > 5000) {
lastFailureToastAtRef.current = now;
showToast(
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
"error",
);
}
});
void persistCandidate(candidate);
});
},
[
domEditSaveTimestampRef,
editingPathRef,
projectIdRef,
readProjectFile,
recordEdit,
setRefreshKey,
showToast,
writeProjectFile,
],
[domEditSaveTimestampRef, editingPathRef, projectIdRef, persistCandidate],
);

const flushPendingSave = useCallback(async (): Promise<EditorSaveDrainResult> => {
if (saveRafRef.current != null) {
cancelAnimationFrame(saveRafRef.current);
saveRafRef.current = null;
}
const candidate = pendingCandidateRef.current;
if (candidate && candidate === inFlightCandidateRef.current && inFlightRef.current) {
return inFlightRef.current;
}
if (candidate) {
domEditSaveTimestampRef.current = Date.now();
return persistCandidate(candidate);
}
return (await inFlightRef.current) ?? { status: "clean" };
}, [domEditSaveTimestampRef, persistCandidate]);

const discardPendingSave = useCallback(() => {
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
saveRafRef.current = null;
pendingCandidateRef.current = null;
}, []);

return {
saveRafRef,
handleContentChange,
getPendingCandidate: () => pendingCandidateRef.current,
flushPendingSave,
discardPendingSave,
};
}
Loading
Loading