Files
Miguel Ángel 4713138544 fix(studio): drain pending edits before reload (#2989)
* 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
2026-08-04 20:58:19 +00:00

171 lines
6.0 KiB
TypeScript

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;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
interface UseEditorSaveOptions {
editingPathRef: React.RefObject<string | undefined>;
projectIdRef: React.RefObject<string | null>;
readProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
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,
readProjectFile,
writeProjectFile,
recordEdit,
domEditSaveTimestampRef,
setRefreshKey,
showToast,
}: 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) => {
const pid = projectIdRef.current;
if (!pid) return;
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();
void persistCandidate(candidate);
});
},
[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,
};
}