Files
multica/packages/views/settings/components/use-auto-save.ts
YYClaw a0ec1da425 fix(settings): restore mounted flag on remount so auto-save can settle (#5613)
The auto-save mount effect only cleared `mountedRef` in its cleanup and never
re-set it to true on setup. Under React StrictMode the initial
setup/cleanup/setup cycle therefore leaves `mountedRef.current` pinned to false
for the component's whole life. A successful save then never reaches the
terminal branch (`succeeded && mountedRef.current`), so the status is stranded
on "saving" and the success toast never fires — the settings save indicator
spins forever even though the PATCH returned 200.

Set `mountedRef.current = true` on every mount so the flag reflects the live
component. Shared by all auto-saved settings (repositories, GitHub, etc.).

Add a StrictMode regression test asserting onSuccess still fires.
2026-07-20 15:05:06 +08:00

147 lines
4.0 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { SettingsSaveStatus } from "./settings-layout";
interface UseAutoSaveOptions<T> {
value: T;
savedValue: T;
onSave: (value: T) => Promise<void>;
onSuccess?: (value: T) => void;
onError?: (error: unknown) => void;
enabled?: boolean;
delay?: number;
isEqual: (left: T, right: T) => boolean;
}
interface AutoSaveResult<T> {
status: SettingsSaveStatus;
flush: () => void;
saveNow: (value: T) => void;
}
/**
* Debounces text-heavy settings while serializing requests. If a user edits
* again during an in-flight save, only the latest queued value is persisted
* next, so a slower response can never overwrite a newer request.
*/
export function useAutoSave<T>({
value,
savedValue,
onSave,
onSuccess,
onError,
enabled = true,
delay = 650,
isEqual,
}: UseAutoSaveOptions<T>): AutoSaveResult<T> {
const [status, setStatus] = useState<SettingsSaveStatus>("idle");
const mountedRef = useRef(true);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savingRef = useRef(false);
const queuedRef = useRef<T | null>(null);
const latestValueRef = useRef(value);
const persistedRef = useRef(savedValue);
const observedSavedRef = useRef(savedValue);
const enabledRef = useRef(enabled);
const onSaveRef = useRef(onSave);
const onSuccessRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
const isEqualRef = useRef(isEqual);
latestValueRef.current = value;
enabledRef.current = enabled;
onSaveRef.current = onSave;
onSuccessRef.current = onSuccess;
onErrorRef.current = onError;
isEqualRef.current = isEqual;
if (!isEqual(savedValue, observedSavedRef.current)) {
observedSavedRef.current = savedValue;
persistedRef.current = savedValue;
}
const runSave = useCallback(async (next: T) => {
if (!enabledRef.current || isEqualRef.current(next, persistedRef.current)) {
return;
}
if (savingRef.current) {
queuedRef.current = next;
return;
}
savingRef.current = true;
let succeeded = false;
if (mountedRef.current) setStatus("saving");
try {
await onSaveRef.current(next);
persistedRef.current = next;
succeeded = true;
} catch (error) {
if (mountedRef.current) setStatus("error");
onErrorRef.current?.(error);
} finally {
savingRef.current = false;
const queued = queuedRef.current;
queuedRef.current = null;
if (queued && !isEqualRef.current(queued, persistedRef.current)) {
void runSave(queued);
} else if (succeeded && mountedRef.current) {
setStatus("saved");
onSuccessRef.current?.(next);
}
}
}, []);
const saveNow = useCallback(
(next: T) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
void runSave(next);
},
[runSave],
);
const flush = useCallback(() => {
saveNow(latestValueRef.current);
}, [saveNow]);
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (!enabled || isEqual(value, persistedRef.current)) {
timerRef.current = null;
if (!enabled) setStatus("idle");
return;
}
setStatus("saving");
timerRef.current = setTimeout(() => {
timerRef.current = null;
void runSave(latestValueRef.current);
}, delay);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [delay, enabled, isEqual, runSave, value]);
useEffect(() => {
// Restore the invariant on every mount. Under React StrictMode the initial
// setup/cleanup/setup cycle would otherwise leave mountedRef pinned to false
// for the component's whole life, stranding status at "saving".
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return { status, flush, saveNow };
}