mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
feat(web): keep unsaved drafts on the device and deepen undo
Failed saves report Offline or Not saved with a retry, keep the draft in localStorage per resume, replay it when the connection returns and restore it when the editor opens again. Undo keeps 200 steps as shared immer trees, merges typing in one field within a second and keeps structural actions as steps of their own.
This commit is contained in:
@@ -10,6 +10,8 @@ import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import {
|
||||
isEditableElementFocused,
|
||||
readUnsavedResumeData,
|
||||
shouldCoalesceEdit,
|
||||
useBuilderResumeUpdateSubscription,
|
||||
useResumeCleanup,
|
||||
useResumeStore,
|
||||
@@ -420,9 +422,9 @@ describe("builder resume autosave", () => {
|
||||
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data });
|
||||
});
|
||||
|
||||
it("keeps the latest draft data and shows a persistent toast when saving fails", async () => {
|
||||
it("keeps the latest draft on this device and reports Not saved when saving fails", async () => {
|
||||
const initial = makeResume("resume-failure");
|
||||
orpcMocks.updateResume.mockRejectedValue(new Error("network down"));
|
||||
orpcMocks.updateResume.mockRejectedValue(new Error("server down"));
|
||||
useResumeStore.getState().initialize(initial);
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
@@ -433,11 +435,65 @@ describe("builder resume autosave", () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Unsaved Name");
|
||||
expect(toastMocks.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "error", description: "Your latest changes could not be saved.", timeout: 0 }),
|
||||
);
|
||||
expect(useResumeStore.getState().saveStatus).toBe("error");
|
||||
expect(readUnsavedResumeData("resume-failure")?.basics.name).toBe("Unsaved Name");
|
||||
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports offline, keeps the draft on this device and sends it when the connection returns", async () => {
|
||||
const initial = makeResume("resume-offline");
|
||||
const onLine = vi.spyOn(navigator, "onLine", "get").mockReturnValue(false);
|
||||
orpcMocks.updateResume.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||
useResumeStore.getState().initialize(initial);
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Written offline";
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useResumeStore.getState().saveStatus).toBe("offline");
|
||||
expect(readUnsavedResumeData("resume-offline")?.basics.name).toBe("Written offline");
|
||||
|
||||
onLine.mockReturnValue(true);
|
||||
orpcMocks.updateResume.mockImplementation((input: { id: string; data: ResumeData }) =>
|
||||
Promise.resolve({ ...makeResume(input.id), data: input.data }),
|
||||
);
|
||||
window.dispatchEvent(new Event("online"));
|
||||
await flushMicrotasks();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(orpcMocks.updateResume).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: "resume-offline" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(useResumeStore.getState().saveStatus).toBe("saved");
|
||||
expect(readUnsavedResumeData("resume-offline")).toBeUndefined();
|
||||
onLine.mockRestore();
|
||||
});
|
||||
|
||||
it("restores changes kept on this device when the editor opens again", async () => {
|
||||
const initial = makeResume("resume-restore");
|
||||
window.localStorage.setItem(
|
||||
"reactive-resume:unsaved:resume-restore",
|
||||
JSON.stringify({ data: withBasicsName(initial, "Kept locally").data, storedAt: 1 }),
|
||||
);
|
||||
orpcMocks.updateResume.mockImplementation((input: { id: string; data: ResumeData }) =>
|
||||
Promise.resolve({ ...makeResume(input.id), data: input.data }),
|
||||
);
|
||||
|
||||
useResumeStore.getState().initialize(initial);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Kept locally");
|
||||
expect(orpcMocks.updateResume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "resume-restore" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(toastMocks.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: "Restored changes that hadn't been saved yet." }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editable focus detection", () => {
|
||||
@@ -538,6 +594,86 @@ describe("builder resume undo/redo", () => {
|
||||
expect(store().canRedo).toBe(false);
|
||||
});
|
||||
|
||||
it("merges typing in one field into one step, but not edits to different fields", () => {
|
||||
const store = useResumeStore.getState;
|
||||
store().initialize(makeResume("undo-fields"));
|
||||
|
||||
store().updateResumeData(
|
||||
(draft) => {
|
||||
draft.basics.name = "J";
|
||||
},
|
||||
{ coalesceKey: "basics.name" },
|
||||
);
|
||||
store().updateResumeData(
|
||||
(draft) => {
|
||||
draft.basics.name = "Jo";
|
||||
},
|
||||
{ coalesceKey: "basics.name" },
|
||||
);
|
||||
store().updateResumeData(
|
||||
(draft) => {
|
||||
draft.basics.headline = "Designer";
|
||||
},
|
||||
{ coalesceKey: "basics.headline" },
|
||||
);
|
||||
|
||||
expect(store().undoStack.length).toBe(2);
|
||||
store().undo();
|
||||
expect(store().resume?.data.basics.headline).toBe(defaultResumeData.basics.headline);
|
||||
expect(store().resume?.data.basics.name).toBe("Jo");
|
||||
store().undo();
|
||||
expect(store().resume?.data.basics.name).toBe(defaultResumeData.basics.name);
|
||||
});
|
||||
|
||||
it("keeps structural actions as steps of their own", () => {
|
||||
const store = useResumeStore.getState;
|
||||
store().initialize(makeResume("undo-structural"));
|
||||
|
||||
store().updateResumeData((draft) => {
|
||||
draft.basics.name = "A";
|
||||
});
|
||||
store().updateResumeData(
|
||||
(draft) => {
|
||||
draft.sections.skills.hidden = true;
|
||||
},
|
||||
{ newStep: true },
|
||||
);
|
||||
store().updateResumeData((draft) => {
|
||||
draft.basics.name = "B";
|
||||
});
|
||||
|
||||
expect(store().undoStack.length).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps 200 steps and drops the oldest beyond that", () => {
|
||||
const store = useResumeStore.getState;
|
||||
store().initialize(makeResume("undo-depth"));
|
||||
|
||||
for (let index = 0; index < 205; index++) {
|
||||
store().updateResumeData(
|
||||
(draft) => {
|
||||
draft.basics.name = `Name ${index}`;
|
||||
},
|
||||
{ newStep: true },
|
||||
);
|
||||
}
|
||||
|
||||
expect(store().undoStack.length).toBe(200);
|
||||
});
|
||||
|
||||
it("stores steps as shared references instead of deep copies", () => {
|
||||
const store = useResumeStore.getState;
|
||||
store().initialize(makeResume("undo-shared"));
|
||||
const before = store().resume?.data;
|
||||
|
||||
store().updateResumeData((draft) => {
|
||||
draft.basics.name = "Changed";
|
||||
});
|
||||
|
||||
expect(store().undoStack[0]).toBe(before);
|
||||
expect(store().resume?.data.sections).toBe(before?.sections);
|
||||
});
|
||||
|
||||
it("restores the exact authored Experience order with one undo after a one-shot sort", () => {
|
||||
const store = useResumeStore.getState;
|
||||
const initial = makeResume("sort-undo");
|
||||
@@ -805,3 +941,26 @@ describe("resume update stream subscription", () => {
|
||||
expect(useResumeStore.getState().resume?.data.metadata.stylesheet).toEqual(remote.data.metadata.stylesheet);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldCoalesceEdit", () => {
|
||||
const previous = { at: 1_000, key: "basics.name", canCoalesce: true };
|
||||
|
||||
it("merges the same field within a second", () => {
|
||||
expect(shouldCoalesceEdit(previous, { at: 1_900, key: "basics.name", newStep: false })).toBe(true);
|
||||
expect(shouldCoalesceEdit(previous, { at: 2_100, key: "basics.name", newStep: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("never merges across fields, into a new step, or after a structural action", () => {
|
||||
expect(shouldCoalesceEdit(previous, { at: 1_100, key: "basics.headline", newStep: false })).toBe(false);
|
||||
expect(shouldCoalesceEdit(previous, { at: 1_100, key: "basics.name", newStep: true })).toBe(false);
|
||||
expect(
|
||||
shouldCoalesceEdit({ ...previous, canCoalesce: false }, { at: 1_100, key: "basics.name", newStep: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the shorter window for edits without a field key", () => {
|
||||
const keyless = { at: 1_000, key: undefined, canCoalesce: true };
|
||||
expect(shouldCoalesceEdit(keyless, { at: 1_400, key: undefined, newStep: false })).toBe(true);
|
||||
expect(shouldCoalesceEdit(keyless, { at: 1_600, key: undefined, newStep: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,14 +29,30 @@ export type Resume = {
|
||||
type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
|
||||
type ResumeUpdateEvent = { mutation: ResumeUpdateMutation };
|
||||
|
||||
type SaveStatus = "idle" | "saving" | "saved" | "error";
|
||||
/**
|
||||
* `offline`: the browser is offline; changes are kept on this device and sent when it reconnects.
|
||||
* `error`: the server rejected or couldn't be reached while online; changes are kept on this device
|
||||
* and the editor offers Retry.
|
||||
*/
|
||||
type SaveStatus = "idle" | "saving" | "saved" | "offline" | "error";
|
||||
|
||||
type UpdateResumeDataOptions = {
|
||||
/**
|
||||
* Identifies the field being edited. Consecutive edits to the same field within a second merge into
|
||||
* one undo step, so typing a sentence undoes as a whole.
|
||||
*/
|
||||
coalesceKey?: string;
|
||||
/** Always a step of its own (structural actions such as adding, moving or deleting). */
|
||||
newStep?: boolean;
|
||||
};
|
||||
|
||||
type ResumeStoreState = {
|
||||
resume: Resume | null;
|
||||
resumeId?: string;
|
||||
isReady: boolean;
|
||||
saveStatus: SaveStatus;
|
||||
// Client-side undo/redo stacks holding whole-`ResumeData` snapshots (see recordHistory helpers below).
|
||||
// Undo/redo stacks of `ResumeData` references. Immer's immutable updates share structure, so a
|
||||
// step costs only the parts that changed.
|
||||
undoStack: ResumeData[];
|
||||
redoStack: ResumeData[];
|
||||
canUndo: boolean;
|
||||
@@ -48,12 +64,14 @@ type ResumeStoreActions = {
|
||||
reset: () => void;
|
||||
replaceResumeDraft: (resume: Resume) => void;
|
||||
replaceResumeFromServer: (resume: Resume) => void;
|
||||
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
|
||||
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void, options?: UpdateResumeDataOptions) => void;
|
||||
patchResume: (fn: (draft: WritableDraft<Resume>) => void) => void;
|
||||
mergeResumeMetadata: (resume: Resume) => void;
|
||||
setSaveStatus: (status: SaveStatus) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
/** Sends the latest unsaved changes again, e.g. from the "Not saved · Retry" status. */
|
||||
retrySave: () => void;
|
||||
};
|
||||
|
||||
type ResumeStore = ResumeStoreState & ResumeStoreActions;
|
||||
@@ -64,10 +82,12 @@ type Runtime = {
|
||||
hasPendingLocalChanges: boolean;
|
||||
isSaving: boolean;
|
||||
pendingResume?: Resume;
|
||||
syncErrorToastId?: string;
|
||||
/** The last save failed; don't loop on it. Cleared by the next edit, Retry, reconnecting or success. */
|
||||
saveFailed: boolean;
|
||||
slowSaveToastId?: string;
|
||||
syncResume: ReturnType<typeof debounce<(resume: Resume) => void>>;
|
||||
beforeUnloadHandler?: () => void;
|
||||
onlineHandler?: () => void;
|
||||
deferredRemoteResume?: Resume;
|
||||
deferredFocusHandler?: () => void;
|
||||
};
|
||||
@@ -80,21 +100,71 @@ type ResumeUpdateSubscriptionOptions = {
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
const NAVIGATION_SAVE_WAIT_MS = 10_000;
|
||||
// Rapid edits within this window coalesce into a single undo step (e.g. typing a word / dragging).
|
||||
// Edits without a field key coalesce within this window (e.g. dragging); keyed edits to the same field
|
||||
// coalesce within FIELD_COALESCE_MS (typing a sentence).
|
||||
const HISTORY_COALESCE_MS = 500;
|
||||
// Bounded stacks: keep undo/redo memory (whole-resume snapshots) predictable during a long session.
|
||||
const MAX_HISTORY_ENTRIES = 50;
|
||||
const FIELD_COALESCE_MS = 1000;
|
||||
// Bounded stacks. Entries are shared-structure references, so 200 steps stay cheap.
|
||||
const MAX_HISTORY_ENTRIES = 200;
|
||||
const UNSAVED_STORAGE_PREFIX = "reactive-resume:unsaved:";
|
||||
const runtimes = new Map<string, Runtime>();
|
||||
|
||||
// Coalescing bookkeeping. Not reactive — only decides whether the next edit opens a new undo step.
|
||||
let historyLastEditAt = 0;
|
||||
let historyLastKey: string | undefined;
|
||||
let historyCanCoalesce = false;
|
||||
|
||||
function resetHistoryRuntime() {
|
||||
historyLastEditAt = 0;
|
||||
historyLastKey = undefined;
|
||||
historyCanCoalesce = false;
|
||||
}
|
||||
|
||||
/** Whether the next edit merges into the current undo step. Exported for tests. */
|
||||
export function shouldCoalesceEdit(
|
||||
previous: { at: number; key: string | undefined; canCoalesce: boolean },
|
||||
next: { at: number; key: string | undefined; newStep: boolean },
|
||||
): boolean {
|
||||
if (next.newStep || !previous.canCoalesce || previous.key !== next.key) return false;
|
||||
const window = next.key === undefined ? HISTORY_COALESCE_MS : FIELD_COALESCE_MS;
|
||||
return next.at - previous.at < window;
|
||||
}
|
||||
|
||||
// Unsaved changes survive a reload or a closed tab: they're kept on this device until a save succeeds.
|
||||
function storeUnsavedResume(resume: Resume) {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${UNSAVED_STORAGE_PREFIX}${resume.id}`,
|
||||
JSON.stringify({ data: resume.data, storedAt: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// Storage can be full or blocked; the in-memory copy still retries.
|
||||
}
|
||||
}
|
||||
|
||||
function clearUnsavedResume(id: string) {
|
||||
try {
|
||||
window.localStorage.removeItem(`${UNSAVED_STORAGE_PREFIX}${id}`);
|
||||
} catch {
|
||||
// Nothing to clear.
|
||||
}
|
||||
}
|
||||
|
||||
export function readUnsavedResumeData(id: string): ResumeData | undefined {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${UNSAVED_STORAGE_PREFIX}${id}`);
|
||||
if (!raw) return undefined;
|
||||
const parsed = JSON.parse(raw) as { data?: ResumeData };
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isBrowserOffline() {
|
||||
return typeof navigator !== "undefined" && navigator.onLine === false;
|
||||
}
|
||||
|
||||
let lockedToastId: string | undefined;
|
||||
|
||||
function getResumeQueryKey(id: string): QueryKey {
|
||||
@@ -216,29 +286,24 @@ async function flushResumeSave(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
if (runtime.syncErrorToastId !== undefined) {
|
||||
toast.close(runtime.syncErrorToastId);
|
||||
runtime.syncErrorToastId = undefined;
|
||||
}
|
||||
runtime.saveFailed = false;
|
||||
if (!runtime.pendingResume && !runtime.hasPendingLocalChanges) clearUnsavedResume(submitted.id);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
|
||||
runtime.pendingResume ??= submitted;
|
||||
runtime.hasPendingLocalChanges = true;
|
||||
useResumeStore.getState().setSaveStatus("error");
|
||||
runtime.syncErrorToastId = toast.add({
|
||||
type: "error",
|
||||
description: t`Your latest changes could not be saved.`,
|
||||
id: runtime.syncErrorToastId,
|
||||
timeout: 0,
|
||||
});
|
||||
runtime.saveFailed = true;
|
||||
storeUnsavedResume(useResumeStore.getState().resume ?? submitted);
|
||||
// The editor bar shows the state ("Offline · saved on this device" or "Not saved · Retry").
|
||||
useResumeStore.getState().setSaveStatus(isBrowserOffline() ? "offline" : "error");
|
||||
} finally {
|
||||
if (runtime.slowSaveToastId !== undefined) {
|
||||
toast.close(runtime.slowSaveToastId);
|
||||
runtime.slowSaveToastId = undefined;
|
||||
}
|
||||
runtime.isSaving = false;
|
||||
if (runtime.pendingResume && runtime.syncErrorToastId === undefined) void flushResumeSave(id);
|
||||
if (runtime.pendingResume && !runtime.saveFailed) void flushResumeSave(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +311,7 @@ function queueResumeSave(resume: Resume) {
|
||||
const runtime = getRuntime(resume.id);
|
||||
runtime.pendingResume = cloneResume(resume);
|
||||
runtime.hasPendingLocalChanges = true;
|
||||
runtime.saveFailed = false;
|
||||
void flushResumeSave(resume.id);
|
||||
}
|
||||
|
||||
@@ -264,12 +330,20 @@ function createRuntime(): Runtime {
|
||||
abortController,
|
||||
hasPendingLocalChanges: false,
|
||||
isSaving: false,
|
||||
saveFailed: false,
|
||||
syncResume,
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
runtime.beforeUnloadHandler = () => runtime.syncResume.flush();
|
||||
window.addEventListener("beforeunload", runtime.beforeUnloadHandler);
|
||||
// Changes made offline are sent as soon as the connection comes back.
|
||||
runtime.onlineHandler = () => {
|
||||
const current = useResumeStore.getState().resume;
|
||||
if (!runtime.hasPendingLocalChanges || !current) return;
|
||||
queueResumeSave(current);
|
||||
};
|
||||
window.addEventListener("online", runtime.onlineHandler);
|
||||
}
|
||||
|
||||
return runtime;
|
||||
@@ -303,6 +377,10 @@ function cleanupRuntime(id: string) {
|
||||
window.removeEventListener("beforeunload", runtime.beforeUnloadHandler);
|
||||
}
|
||||
|
||||
if (runtime.onlineHandler && typeof window !== "undefined") {
|
||||
window.removeEventListener("online", runtime.onlineHandler);
|
||||
}
|
||||
|
||||
if (runtime.deferredFocusHandler && typeof document !== "undefined") {
|
||||
document.removeEventListener("focusout", runtime.deferredFocusHandler, true);
|
||||
}
|
||||
@@ -332,8 +410,13 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
if (resume) setRuntimeBaseline(resume);
|
||||
resetHistoryRuntime();
|
||||
|
||||
// Changes that couldn't be saved last time (offline, closed tab) come back and are sent again.
|
||||
const unsaved = resume ? readUnsavedResumeData(resume.id) : undefined;
|
||||
const restored = resume && unsaved && !isEqual(unsaved, resume.data) ? { ...resume, data: unsaved } : null;
|
||||
if (resume && unsaved && !restored) clearUnsavedResume(resume.id);
|
||||
|
||||
set((state) => {
|
||||
state.resume = resume;
|
||||
state.resume = restored ?? resume;
|
||||
state.resumeId = resume?.id;
|
||||
state.isReady = resume !== null;
|
||||
state.undoStack = [];
|
||||
@@ -341,6 +424,15 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
state.canUndo = false;
|
||||
state.canRedo = false;
|
||||
});
|
||||
|
||||
if (restored) {
|
||||
toast.add({
|
||||
type: "info",
|
||||
description: t`Restored changes that hadn't been saved yet.`,
|
||||
id: "resume-restored-unsaved",
|
||||
});
|
||||
queueResumeSave(restored);
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
@@ -422,7 +514,7 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
updateResumeData: (fn) => {
|
||||
updateResumeData: (fn, options = {}) => {
|
||||
const currentResume = get().resume;
|
||||
if (!currentResume) return;
|
||||
|
||||
@@ -435,13 +527,18 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
// Coalesce bursts: only the first edit of a burst opens a new undo step by snapshotting the
|
||||
// pre-edit state. Edits within HISTORY_COALESCE_MS of the previous one fold into that step.
|
||||
// Coalesce bursts: only the first edit of a burst opens a new undo step, holding the pre-edit
|
||||
// state. Later edits to the same field fold into it (see shouldCoalesceEdit).
|
||||
const now = Date.now();
|
||||
const coalesce = historyCanCoalesce && now - historyLastEditAt < HISTORY_COALESCE_MS;
|
||||
const snapshotBefore = coalesce ? undefined : cloneResumeData(currentResume.data);
|
||||
const newStep = options.newStep ?? false;
|
||||
const coalesce = shouldCoalesceEdit(
|
||||
{ at: historyLastEditAt, key: historyLastKey, canCoalesce: historyCanCoalesce },
|
||||
{ at: now, key: options.coalesceKey, newStep },
|
||||
);
|
||||
const snapshotBefore = coalesce ? undefined : currentResume.data;
|
||||
historyLastEditAt = now;
|
||||
historyCanCoalesce = true;
|
||||
historyLastKey = options.coalesceKey;
|
||||
historyCanCoalesce = !newStep;
|
||||
|
||||
set((state) => {
|
||||
if (!state.resume) return;
|
||||
@@ -470,6 +567,15 @@ export const useResumeStore = create<ResumeStore>()(
|
||||
redo: () => {
|
||||
applyHistoryStep(get, set, "redo");
|
||||
},
|
||||
|
||||
retrySave: () => {
|
||||
const current = get().resume;
|
||||
if (!current) return;
|
||||
set((state) => {
|
||||
state.saveStatus = "saving";
|
||||
});
|
||||
queueResumeSave(current);
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -497,7 +603,7 @@ function applyHistoryStep(get: StoreGet, set: ImmerSet, direction: "undo" | "red
|
||||
|
||||
// The next edit after an undo/redo must start a brand-new undo step.
|
||||
resetHistoryRuntime();
|
||||
const current = cloneResumeData(currentResume.data);
|
||||
const current = currentResume.data;
|
||||
|
||||
set((draft) => {
|
||||
if (!draft.resume) return;
|
||||
@@ -579,10 +685,10 @@ export function useUpdateResumeData() {
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
return useCallback(
|
||||
(fn: (draft: WritableDraft<ResumeData>) => void) => {
|
||||
(fn: (draft: WritableDraft<ResumeData>) => void, options?: UpdateResumeDataOptions) => {
|
||||
if (!resumeId) return;
|
||||
bindRuntimeQueryClient(resumeId, queryClient);
|
||||
updateResumeData(fn);
|
||||
updateResumeData(fn, options);
|
||||
},
|
||||
[queryClient, resumeId, updateResumeData],
|
||||
);
|
||||
@@ -687,7 +793,7 @@ function saveResumeBeforeLeaving(id: string): boolean | Promise<boolean> {
|
||||
resolve(saved);
|
||||
};
|
||||
const unsubscribe = useResumeStore.subscribe((state) => {
|
||||
if (state.resume?.id !== id || state.saveStatus === "error") {
|
||||
if (state.resume?.id !== id || state.saveStatus === "error" || state.saveStatus === "offline") {
|
||||
finish(false);
|
||||
} else if (state.saveStatus === "saved" && !runtime.hasPendingLocalChanges) {
|
||||
finish(true);
|
||||
|
||||
Reference in New Issue
Block a user