fix(web): preserve remote resume edits during autosave

This commit is contained in:
Cursor Agent
2026-05-26 23:08:57 +00:00
parent 8da780c868
commit 004ac15cb3
4 changed files with 251 additions and 81 deletions
@@ -5,6 +5,7 @@ import type { Resume } from "./draft";
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { ORPCError } from "@orpc/client";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { useBuilderResumeUpdateSubscription, useResumeStore, useResumeUpdateSubscription } from "./draft";
@@ -30,9 +31,14 @@ const toastMocks = vi.hoisted(() => ({
error: vi.fn(() => "sync-error-toast"),
}));
vi.mock("@orpc/client", () => ({
consumeEventIterator: consumeEventIteratorMock,
}));
vi.mock("@orpc/client", async (importOriginal) => {
const actual = await importOriginal<typeof import("@orpc/client")>();
return {
...actual,
consumeEventIterator: consumeEventIteratorMock,
};
});
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => queryClientMock,
@@ -103,10 +109,25 @@ function withBasicsName(resume: Resume, name: string): Resume {
};
}
function withSummaryContent(resume: Resume, content: string): Resume {
return {
...resume,
data: {
...resume.data,
summary: {
...resume.data.summary,
content,
},
},
};
}
function withUpdatedAt(resume: Resume, updatedAt: string): Resume {
return { ...resume, updatedAt: new Date(updatedAt) };
}
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
for (let index = 0; index < 10; index += 1) await Promise.resolve();
}
describe("builder resume autosave", () => {
@@ -131,10 +152,11 @@ describe("builder resume autosave", () => {
useResumeStore.getState().reset();
});
it("coalesces rapid local edits into one full-data update", async () => {
it("coalesces rapid local edits into one data patch", async () => {
const initial = makeResume("resume-rapid");
const updated = withBasicsName(initial, "Latest Name");
const updated = withUpdatedAt(withBasicsName(initial, "Latest Name"), "2026-05-26T12:01:00.000Z");
orpcMocks.updateResume.mockResolvedValue(updated);
orpcMocks.patchResume.mockResolvedValue(updated);
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
@@ -147,21 +169,25 @@ describe("builder resume autosave", () => {
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1);
expect(orpcMocks.updateResume).toHaveBeenCalledWith(
{ id: initial.id, data: updated.data },
expect(orpcMocks.patchResume).toHaveBeenCalledTimes(1);
expect(orpcMocks.patchResume).toHaveBeenCalledWith(
{
id: initial.id,
expectedUpdatedAt: initial.updatedAt,
operations: [{ op: "replace", path: "/basics/name", value: "Latest Name" }],
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
expect(orpcMocks.updateResume).not.toHaveBeenCalled();
});
it("saves the latest pending snapshot after an in-flight save resolves", async () => {
const initial = makeResume("resume-in-flight");
const first = withBasicsName(initial, "First Name");
const latest = withBasicsName(initial, "Latest Name");
const first = withUpdatedAt(withBasicsName(initial, "First Name"), "2026-05-26T12:01:00.000Z");
const latest = withUpdatedAt(withBasicsName(initial, "Latest Name"), "2026-05-26T12:02:00.000Z");
let resolveFirst!: (resume: Resume) => void;
orpcMocks.updateResume
orpcMocks.patchResume
.mockReturnValueOnce(
new Promise<Resume>((resolve) => {
resolveFirst = resolve;
@@ -183,24 +209,32 @@ describe("builder resume autosave", () => {
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1);
expect(orpcMocks.patchResume).toHaveBeenCalledTimes(1);
resolveFirst(first);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.updateResume.mock.calls[0]?.[0]).toEqual({ id: initial.id, data: first.data });
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data });
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
expect(orpcMocks.patchResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.patchResume.mock.calls[0]?.[0]).toEqual({
id: initial.id,
expectedUpdatedAt: initial.updatedAt,
operations: [{ op: "replace", path: "/basics/name", value: "First Name" }],
});
expect(orpcMocks.patchResume.mock.calls[1]?.[0]).toEqual({
id: initial.id,
expectedUpdatedAt: first.updatedAt,
operations: [{ op: "replace", path: "/basics/name", value: "Latest Name" }],
});
expect(orpcMocks.updateResume).not.toHaveBeenCalled();
});
it("does not run a stale debounced save after immediately saving an edit made during an in-flight save", async () => {
const initial = makeResume("resume-stale-timer");
const first = withBasicsName(initial, "First Name");
const latest = withBasicsName(initial, "Latest Name");
const first = withUpdatedAt(withBasicsName(initial, "First Name"), "2026-05-26T12:01:00.000Z");
const latest = withUpdatedAt(withBasicsName(initial, "Latest Name"), "2026-05-26T12:02:00.000Z");
let resolveFirst!: (resume: Resume) => void;
orpcMocks.updateResume
orpcMocks.patchResume
.mockReturnValueOnce(
new Promise<Resume>((resolve) => {
resolveFirst = resolve;
@@ -221,18 +255,22 @@ describe("builder resume autosave", () => {
resolveFirst(first);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.patchResume).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data });
expect(orpcMocks.patchResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.patchResume.mock.calls[1]?.[0]).toEqual({
id: initial.id,
expectedUpdatedAt: first.updatedAt,
operations: [{ op: "replace", path: "/basics/name", value: "Latest Name" }],
});
});
it("keeps the latest draft data and shows a persistent toast when saving fails", async () => {
const initial = makeResume("resume-failure");
orpcMocks.updateResume.mockRejectedValue(new Error("network down"));
orpcMocks.patchResume.mockRejectedValue(new Error("network down"));
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
@@ -247,7 +285,42 @@ describe("builder resume autosave", () => {
"Your latest changes could not be saved.",
expect.objectContaining({ duration: Number.POSITIVE_INFINITY }),
);
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
expect(orpcMocks.updateResume).not.toHaveBeenCalled();
});
it("rebases pending local edits after a server version conflict", async () => {
const initial = makeResume("resume-conflict");
const remote = withUpdatedAt(withSummaryContent(initial, "Remote Summary"), "2026-05-26T12:01:00.000Z");
const merged = withUpdatedAt(withBasicsName(remote, "Local Name"), "2026-05-26T12:02:00.000Z");
orpcMocks.patchResume.mockRejectedValueOnce(new ORPCError("RESUME_VERSION_CONFLICT")).mockResolvedValueOnce(merged);
orpcMocks.getResumeById.mockResolvedValue(remote);
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Local Name";
});
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(orpcMocks.getResumeById).toHaveBeenCalledWith(
{ id: initial.id },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(orpcMocks.patchResume).toHaveBeenCalledTimes(2);
expect(orpcMocks.patchResume.mock.calls[0]?.[0]).toEqual({
id: initial.id,
expectedUpdatedAt: initial.updatedAt,
operations: [{ op: "replace", path: "/basics/name", value: "Local Name" }],
});
expect(orpcMocks.patchResume.mock.calls[1]?.[0]).toEqual({
id: initial.id,
expectedUpdatedAt: remote.updatedAt,
operations: [{ op: "replace", path: "/basics/name", value: "Local Name" }],
});
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
expect(useResumeStore.getState().resume?.data.summary.content).toBe("Remote Summary");
});
});
@@ -317,10 +390,12 @@ describe("resume update stream subscription", () => {
it("does not overwrite pending local builder edits when a remote update arrives", async () => {
const initial = makeResume("resume-pending");
const remote = withBasicsName(initial, "Remote Name");
const remote = withUpdatedAt(withSummaryContent(initial, "Remote Summary"), "2026-05-26T12:01:00.000Z");
const merged = withUpdatedAt(withBasicsName(remote, "Local Name"), "2026-05-26T12:02:00.000Z");
const cancel = vi.fn().mockResolvedValue(undefined);
consumeEventIteratorMock.mockReturnValue(cancel);
orpcMocks.getResumeById.mockResolvedValue(remote);
orpcMocks.patchResume.mockResolvedValue(merged);
routerParamsMock.value = { resumeId: initial.id };
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
@@ -336,5 +411,15 @@ describe("resume update stream subscription", () => {
expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote);
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
expect(useResumeStore.getState().resume?.data.summary.content).toBe("Remote Summary");
expect(orpcMocks.patchResume).toHaveBeenCalledWith(
{
id: initial.id,
expectedUpdatedAt: remote.updatedAt,
operations: [{ op: "replace", path: "/basics/name", value: "Local Name" }],
},
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(orpcMocks.updateResume).not.toHaveBeenCalled();
});
});
+105 -24
View File
@@ -2,7 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { WritableDraft } from "immer";
import { t } from "@lingui/core/macro";
import { consumeEventIterator } from "@orpc/client";
import { consumeEventIterator, ORPCError } from "@orpc/client";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import { debounce, isEqual } from "es-toolkit";
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { applyResumePatches, createResumePatches } from "@reactive-resume/resume/patch";
import { orpc, streamClient } from "@/libs/orpc/client";
export type Resume = {
@@ -48,6 +49,7 @@ type Runtime = {
hasPendingLocalChanges: boolean;
isSaving: boolean;
pendingResume?: Resume;
serverResume?: Resume;
syncErrorToastId?: string | number;
syncResume: ReturnType<typeof debounce<(resume: Resume) => void>>;
beforeUnloadHandler?: () => void;
@@ -82,57 +84,131 @@ function createResumeUpdateEventIterator(resumeId: string) {
function setRuntimeBaseline(resume: Resume) {
const runtime = getRuntime(resume.id);
runtime.serverResume = cloneResume(resume);
runtime.hasPendingLocalChanges = false;
runtime.pendingResume = undefined;
}
function isAbortError(error: unknown): error is DOMException {
return error instanceof DOMException && error.name === "AbortError";
}
function isResumeVersionConflict(error: unknown): error is ORPCError {
return error instanceof ORPCError && error.code === "RESUME_VERSION_CONFLICT";
}
function rebasePendingResumeOnServer(serverResume: Resume, localResume: Resume) {
const runtime = getRuntime(serverResume.id);
const baseline = runtime.serverResume;
if (!baseline || baseline.id !== serverResume.id) {
throw new Error("Cannot rebase resume draft without a server baseline.");
}
const operations = createResumePatches(baseline.data, localResume.data);
runtime.serverResume = cloneResume(serverResume);
runtime.queryClient?.setQueryData(getResumeQueryKey(serverResume.id), serverResume);
if (operations.length === 0) {
runtime.hasPendingLocalChanges = false;
runtime.pendingResume = undefined;
useResumeStore.getState().replaceResumeFromServer(serverResume);
return;
}
const rebased: Resume = {
...serverResume,
data: applyResumePatches(serverResume.data, operations),
};
runtime.syncResume.cancel();
runtime.pendingResume = cloneResume(rebased);
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().replaceResumeDraft(rebased);
}
function handleSuccessfulResumeSave(runtime: Runtime, submitted: Resume, saved: Resume, submittedData: ResumeData) {
runtime.serverResume = cloneResume(saved);
runtime.queryClient?.setQueryData(getResumeQueryKey(submitted.id), saved);
const currentResume = useResumeStore.getState().resume;
const currentDataStillMatchesSubmission =
currentResume?.id === submitted.id && isEqual(currentResume.data, submittedData);
if (currentDataStillMatchesSubmission && !runtime.pendingResume) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(saved);
} else {
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().mergeResumeMetadata(saved);
if (!runtime.pendingResume && currentResume?.id === submitted.id && !isEqual(currentResume.data, submittedData)) {
runtime.syncResume.cancel();
runtime.pendingResume = cloneResume(currentResume);
}
}
if (runtime.syncErrorToastId !== undefined) {
toast.dismiss(runtime.syncErrorToastId);
runtime.syncErrorToastId = undefined;
}
}
async function flushResumeSave(id: string) {
const runtime = runtimes.get(id);
if (!runtime || runtime.isSaving || !runtime.pendingResume) return;
const submitted = runtime.pendingResume;
const submittedData = cloneResumeData(submitted.data);
const baseline = runtime.serverResume;
runtime.pendingResume = undefined;
runtime.isSaving = true;
try {
const updated = (await orpc.resume.update.call(
{ id: submitted.id, data: submittedData },
if (!baseline || baseline.id !== submitted.id) {
throw new Error("Cannot save resume draft without a server baseline.");
}
const operations = createResumePatches(baseline.data, submittedData);
if (operations.length === 0) {
handleSuccessfulResumeSave(runtime, submitted, baseline, submittedData);
return;
}
const updated = (await orpc.resume.patch.call(
{ id: submitted.id, expectedUpdatedAt: baseline.updatedAt, operations },
{ signal: runtime.abortController.signal },
)) as Resume;
runtime.queryClient?.setQueryData(getResumeQueryKey(submitted.id), updated);
handleSuccessfulResumeSave(runtime, submitted, updated, submittedData);
} catch (error: unknown) {
if (isAbortError(error)) return;
const currentResume = useResumeStore.getState().resume;
const currentDataStillMatchesSubmission =
currentResume?.id === submitted.id && isEqual(currentResume.data, submittedData);
let syncError = error;
if (currentDataStillMatchesSubmission && !runtime.pendingResume) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(updated);
} else {
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().mergeResumeMetadata(updated);
if (isResumeVersionConflict(error)) {
try {
const latest = (await orpc.resume.getById.call(
{ id: submitted.id },
{ signal: runtime.abortController.signal },
)) as Resume;
const currentResume = useResumeStore.getState().resume;
const localResume = currentResume?.id === submitted.id ? currentResume : submitted;
if (!runtime.pendingResume && currentResume?.id === submitted.id && !isEqual(currentResume.data, submittedData)) {
runtime.syncResume.cancel();
runtime.pendingResume = cloneResume(currentResume);
rebasePendingResumeOnServer(latest, localResume);
return;
} catch (rebaseError: unknown) {
if (isAbortError(rebaseError)) return;
syncError = rebaseError;
}
}
if (runtime.syncErrorToastId !== undefined) {
toast.dismiss(runtime.syncErrorToastId);
runtime.syncErrorToastId = undefined;
}
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") return;
runtime.pendingResume ??= submitted;
runtime.hasPendingLocalChanges = true;
runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, {
id: runtime.syncErrorToastId,
duration: Number.POSITIVE_INFINITY,
});
console.warn("Resume autosave failed:", syncError);
} finally {
runtime.isSaving = false;
if (runtime.pendingResume && runtime.syncErrorToastId === undefined) void flushResumeSave(id);
@@ -404,7 +480,12 @@ export function useBuilderResumeUpdateSubscription() {
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
if (hasPendingLocalChanges(resumeId)) {
useResumeStore.getState().mergeResumeMetadata(resume);
const currentResume = useResumeStore.getState().resume;
if (!currentResume || currentResume.id !== resumeId) return;
rebasePendingResumeOnServer(resume, currentResume);
const runtime = getRuntime(resumeId);
if (!runtime.isSaving && runtime.syncErrorToastId === undefined) void flushResumeSave(resumeId);
return;
}