Files
multica/packages/views/settings/components/use-auto-save.test.tsx
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

104 lines
3.2 KiB
TypeScript

import { StrictMode } from "react";
import { act, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoSave } from "./use-auto-save";
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function AutoSaveHarness({
value,
onSave,
onSuccess,
}: {
value: string;
onSave: (value: string) => Promise<void>;
onSuccess: (value: string) => void;
}) {
useAutoSave({
value,
savedValue: "",
onSave,
onSuccess,
delay: 650,
isEqual: (left, right) => left === right,
});
return null;
}
describe("useAutoSave success feedback", () => {
afterEach(() => {
vi.useRealTimers();
});
it("reports success after the persisted value resolves", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const onSave = vi.fn(async () => undefined);
const onSuccess = vi.fn();
render(
<AutoSaveHarness value="saved" onSave={onSave} onSuccess={onSuccess} />,
);
act(() => vi.advanceTimersByTime(650));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith("saved");
expect(onSuccess).toHaveBeenCalledWith("saved");
});
});
it("still reports success under StrictMode's double-invoked mount", async () => {
// StrictMode runs setup/cleanup/setup on mount. A mount effect that only
// clears the mounted flag in cleanup leaves it false for the component's
// life, so a successful save is silently dropped: neither the "saved"
// status nor onSuccess ever fires and the indicator spins forever.
const onSave = vi.fn(async () => undefined);
const onSuccess = vi.fn();
render(
<StrictMode>
<AutoSaveHarness value="saved" onSave={onSave} onSuccess={onSuccess} />
</StrictMode>,
);
await waitFor(() => expect(onSave).toHaveBeenCalledWith("saved"));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith("saved"));
});
it("waits for the latest queued value before reporting success", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const first = deferred<void>();
const second = deferred<void>();
const onSave = vi
.fn<(value: string) => Promise<void>>()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const onSuccess = vi.fn();
const { rerender } = render(
<AutoSaveHarness value="first" onSave={onSave} onSuccess={onSuccess} />,
);
act(() => vi.advanceTimersByTime(650));
await waitFor(() => expect(onSave).toHaveBeenCalledWith("first"));
rerender(
<AutoSaveHarness value="second" onSave={onSave} onSuccess={onSuccess} />,
);
act(() => vi.advanceTimersByTime(650));
await act(async () => first.resolve());
await waitFor(() => expect(onSave).toHaveBeenCalledWith("second"));
expect(onSuccess).not.toHaveBeenCalled();
await act(async () => second.resolve());
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith("second"));
expect(onSuccess).toHaveBeenCalledTimes(1);
});
});