mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
refactor: better resume two-way sync in case of MCP/API updates
This commit is contained in:
@@ -39,7 +39,6 @@
|
||||
"@reactive-resume/fonts": "workspace:*",
|
||||
"@reactive-resume/import": "workspace:*",
|
||||
"@reactive-resume/pdf": "workspace:*",
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/ui": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
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 { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { useBuilderResumeUpdateSubscription, useResumeStore, useResumeUpdateSubscription } from "./draft";
|
||||
|
||||
const orpcMocks = vi.hoisted(() => ({
|
||||
getResumeById: vi.fn(),
|
||||
patchResume: vi.fn(),
|
||||
streamSubscribe: vi.fn(),
|
||||
updateResume: vi.fn(),
|
||||
}));
|
||||
|
||||
const consumeEventIteratorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const queryClientMock = vi.hoisted(() => ({
|
||||
setQueryData: vi.fn(),
|
||||
}));
|
||||
|
||||
const routerParamsMock = vi.hoisted(() => ({
|
||||
value: {} as { resumeId?: string },
|
||||
}));
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
dismiss: vi.fn(),
|
||||
error: vi.fn(() => "sync-error-toast"),
|
||||
}));
|
||||
|
||||
vi.mock("@orpc/client", () => ({
|
||||
consumeEventIterator: consumeEventIteratorMock,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => queryClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useParams: () => routerParamsMock.value,
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
getById: {
|
||||
call: orpcMocks.getResumeById,
|
||||
queryOptions: ({ input }: { input: { id: string } }) => ({
|
||||
queryKey: ["resume", "getById", input.id],
|
||||
}),
|
||||
},
|
||||
patch: {
|
||||
call: orpcMocks.patchResume,
|
||||
},
|
||||
update: {
|
||||
call: orpcMocks.updateResume,
|
||||
},
|
||||
},
|
||||
},
|
||||
streamClient: {
|
||||
resume: {
|
||||
updates: {
|
||||
subscribe: orpcMocks.streamSubscribe,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: toastMocks,
|
||||
}));
|
||||
|
||||
function cloneResumeData(data: ResumeData): ResumeData {
|
||||
return structuredClone(data);
|
||||
}
|
||||
|
||||
function makeResume(id: string): Resume {
|
||||
return {
|
||||
id,
|
||||
name: "Resume",
|
||||
slug: id,
|
||||
tags: [],
|
||||
data: cloneResumeData(defaultResumeData),
|
||||
isLocked: false,
|
||||
isPublic: false,
|
||||
hasPassword: false,
|
||||
updatedAt: new Date("2026-05-26T12:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function withBasicsName(resume: Resume, name: string): Resume {
|
||||
return {
|
||||
...resume,
|
||||
data: {
|
||||
...resume.data,
|
||||
basics: {
|
||||
...resume.data.basics,
|
||||
name,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("builder resume autosave", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
orpcMocks.getResumeById.mockReset();
|
||||
orpcMocks.patchResume.mockReset();
|
||||
orpcMocks.streamSubscribe.mockReset();
|
||||
orpcMocks.updateResume.mockReset();
|
||||
consumeEventIteratorMock.mockReset();
|
||||
queryClientMock.setQueryData.mockClear();
|
||||
routerParamsMock.value = {};
|
||||
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
||||
toastMocks.dismiss.mockClear();
|
||||
toastMocks.error.mockClear();
|
||||
useResumeStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
useResumeStore.getState().reset();
|
||||
});
|
||||
|
||||
it("coalesces rapid local edits into one full-data update", async () => {
|
||||
const initial = makeResume("resume-rapid");
|
||||
const updated = withBasicsName(initial, "Latest Name");
|
||||
orpcMocks.updateResume.mockResolvedValue(updated);
|
||||
useResumeStore.getState().initialize(initial);
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "First Name";
|
||||
});
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Latest Name";
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1);
|
||||
expect(orpcMocks.updateResume).toHaveBeenCalledWith(
|
||||
{ id: initial.id, data: updated.data },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(orpcMocks.patchResume).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");
|
||||
let resolveFirst!: (resume: Resume) => void;
|
||||
|
||||
orpcMocks.updateResume
|
||||
.mockReturnValueOnce(
|
||||
new Promise<Resume>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(latest);
|
||||
|
||||
useResumeStore.getState().initialize(initial);
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "First Name";
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Latest Name";
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(orpcMocks.updateResume).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();
|
||||
});
|
||||
|
||||
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");
|
||||
let resolveFirst!: (resume: Resume) => void;
|
||||
|
||||
orpcMocks.updateResume
|
||||
.mockReturnValueOnce(
|
||||
new Promise<Resume>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(latest);
|
||||
|
||||
useResumeStore.getState().initialize(initial);
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "First Name";
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Latest Name";
|
||||
});
|
||||
|
||||
resolveFirst(first);
|
||||
await flushMicrotasks();
|
||||
expect(orpcMocks.updateResume).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 });
|
||||
});
|
||||
|
||||
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"));
|
||||
useResumeStore.getState().initialize(initial);
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Unsaved Name";
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Unsaved Name");
|
||||
expect(toastMocks.error).toHaveBeenCalledWith(
|
||||
"Your latest changes could not be saved.",
|
||||
expect.objectContaining({ duration: Number.POSITIVE_INFINITY }),
|
||||
);
|
||||
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resume update stream subscription", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
orpcMocks.streamSubscribe.mockReset();
|
||||
consumeEventIteratorMock.mockReset();
|
||||
orpcMocks.getResumeById.mockReset();
|
||||
queryClientMock.setQueryData.mockClear();
|
||||
routerParamsMock.value = {};
|
||||
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
||||
useResumeStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
useResumeStore.getState().reset();
|
||||
});
|
||||
|
||||
it("subscribes by explicit resume id and calls the provided update handler", async () => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
consumeEventIteratorMock.mockReturnValue(cancel);
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useResumeUpdateSubscription({
|
||||
resumeId: "resume-stream",
|
||||
onUpdate,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(orpcMocks.streamSubscribe).toHaveBeenCalledWith({ id: "resume-stream" });
|
||||
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise<void> } | undefined;
|
||||
expect(handlers).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
await handlers?.onEvent();
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces the builder draft from the server when there are no pending local edits", async () => {
|
||||
const initial = makeResume("resume-clean");
|
||||
const remote = withBasicsName(initial, "Remote Name");
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
consumeEventIteratorMock.mockReturnValue(cancel);
|
||||
orpcMocks.getResumeById.mockResolvedValue(remote);
|
||||
routerParamsMock.value = { resumeId: initial.id };
|
||||
useResumeStore.getState().initialize(initial);
|
||||
|
||||
renderHook(() => useBuilderResumeUpdateSubscription());
|
||||
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise<void> } | undefined;
|
||||
|
||||
await act(async () => {
|
||||
await handlers?.onEvent();
|
||||
});
|
||||
|
||||
expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote);
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Remote Name");
|
||||
});
|
||||
|
||||
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 cancel = vi.fn().mockResolvedValue(undefined);
|
||||
consumeEventIteratorMock.mockReturnValue(cancel);
|
||||
orpcMocks.getResumeById.mockResolvedValue(remote);
|
||||
routerParamsMock.value = { resumeId: initial.id };
|
||||
useResumeStore.getState().initialize(initial);
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Local Name";
|
||||
});
|
||||
|
||||
renderHook(() => useBuilderResumeUpdateSubscription());
|
||||
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise<void> } | undefined;
|
||||
|
||||
await act(async () => {
|
||||
await handlers?.onEvent();
|
||||
});
|
||||
|
||||
expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote);
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ 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 = {
|
||||
@@ -46,13 +45,20 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions;
|
||||
type Runtime = {
|
||||
abortController: AbortController;
|
||||
queryClient?: QueryClient;
|
||||
baselineData?: ResumeData;
|
||||
hasPendingLocalChanges: boolean;
|
||||
isSaving: boolean;
|
||||
pendingResume?: Resume;
|
||||
syncErrorToastId?: string | number;
|
||||
syncResume: ReturnType<typeof debounce<(resume: Resume) => Promise<void>>>;
|
||||
syncResume: ReturnType<typeof debounce<(resume: Resume) => void>>;
|
||||
beforeUnloadHandler?: () => void;
|
||||
};
|
||||
|
||||
type ResumeUpdateSubscriptionOptions = {
|
||||
resumeId?: string;
|
||||
onUpdate: () => Promise<void> | void;
|
||||
onError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
const runtimes = new Map<string, Runtime>();
|
||||
|
||||
@@ -66,66 +72,86 @@ function cloneResumeData(data: ResumeData): ResumeData {
|
||||
return structuredClone(data);
|
||||
}
|
||||
|
||||
function cloneResume(resume: Resume): Resume {
|
||||
return { ...resume, data: cloneResumeData(resume.data) };
|
||||
}
|
||||
|
||||
function createResumeUpdateEventIterator(resumeId: string) {
|
||||
return streamClient.resume.updates.subscribe({ id: resumeId });
|
||||
}
|
||||
|
||||
function setRuntimeBaseline(resume: Resume) {
|
||||
const runtime = getRuntime(resume.id);
|
||||
runtime.baselineData = cloneResumeData(resume.data);
|
||||
runtime.hasPendingLocalChanges = false;
|
||||
runtime.pendingResume = 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);
|
||||
runtime.pendingResume = undefined;
|
||||
runtime.isSaving = true;
|
||||
|
||||
try {
|
||||
const updated = (await orpc.resume.update.call(
|
||||
{ id: submitted.id, data: submittedData },
|
||||
{ signal: runtime.abortController.signal },
|
||||
)) as Resume;
|
||||
|
||||
runtime.queryClient?.setQueryData(getResumeQueryKey(submitted.id), updated);
|
||||
|
||||
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(updated);
|
||||
} else {
|
||||
runtime.hasPendingLocalChanges = true;
|
||||
useResumeStore.getState().mergeResumeMetadata(updated);
|
||||
|
||||
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;
|
||||
}
|
||||
} 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,
|
||||
});
|
||||
} finally {
|
||||
runtime.isSaving = false;
|
||||
if (runtime.pendingResume && runtime.syncErrorToastId === undefined) void flushResumeSave(id);
|
||||
}
|
||||
}
|
||||
|
||||
function queueResumeSave(resume: Resume) {
|
||||
const runtime = getRuntime(resume.id);
|
||||
runtime.pendingResume = cloneResume(resume);
|
||||
runtime.hasPendingLocalChanges = true;
|
||||
void flushResumeSave(resume.id);
|
||||
}
|
||||
|
||||
function createRuntime(): Runtime {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const syncResume = debounce(
|
||||
async (resume: Resume) => {
|
||||
const runtime = runtimes.get(resume.id);
|
||||
if (!runtime) return;
|
||||
|
||||
const baselineData = runtime.baselineData ?? cloneResumeData(resume.data);
|
||||
const operations = createResumePatches(baselineData, resume.data);
|
||||
|
||||
if (operations.length === 0) {
|
||||
runtime.hasPendingLocalChanges = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const submittedData = cloneResumeData(resume.data);
|
||||
|
||||
try {
|
||||
const updated = (await orpc.resume.patch.call(
|
||||
{ id: resume.id, operations },
|
||||
{ signal: abortController.signal },
|
||||
)) as Resume;
|
||||
|
||||
runtime.queryClient?.setQueryData(getResumeQueryKey(resume.id), updated);
|
||||
runtime.baselineData = cloneResumeData(updated.data);
|
||||
|
||||
const currentResume = useResumeStore.getState().resume;
|
||||
const currentDataStillMatchesSubmission =
|
||||
currentResume?.id === resume.id && isEqual(currentResume.data, submittedData);
|
||||
|
||||
if (currentDataStillMatchesSubmission) {
|
||||
runtime.hasPendingLocalChanges = false;
|
||||
useResumeStore.getState().replaceResumeFromServer(updated);
|
||||
} else {
|
||||
runtime.hasPendingLocalChanges = true;
|
||||
useResumeStore.getState().mergeResumeMetadata(updated);
|
||||
syncCurrentResume(resume.id);
|
||||
}
|
||||
|
||||
if (runtime.syncErrorToastId === undefined) return;
|
||||
toast.dismiss(runtime.syncErrorToastId);
|
||||
runtime.syncErrorToastId = undefined;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, {
|
||||
id: runtime.syncErrorToastId,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}
|
||||
(resume: Resume) => {
|
||||
queueResumeSave(resume);
|
||||
},
|
||||
SAVE_DEBOUNCE_MS,
|
||||
{ signal: abortController.signal },
|
||||
@@ -134,6 +160,7 @@ function createRuntime(): Runtime {
|
||||
const runtime: Runtime = {
|
||||
abortController,
|
||||
hasPendingLocalChanges: false,
|
||||
isSaving: false,
|
||||
syncResume,
|
||||
};
|
||||
|
||||
@@ -330,56 +357,26 @@ export function useUpdateResumeData() {
|
||||
);
|
||||
}
|
||||
|
||||
export function useResumeUpdateSubscription() {
|
||||
const queryClient = useQueryClient();
|
||||
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
|
||||
const params = useParams({ strict: false }) as { resumeId?: string };
|
||||
const resumeId = params.resumeId;
|
||||
export function useResumeUpdateSubscription({ resumeId, onUpdate, onError }: ResumeUpdateSubscriptionOptions) {
|
||||
const [_retryNonce, setRetryNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeId) return;
|
||||
|
||||
bindRuntimeQueryClient(resumeId, queryClient);
|
||||
|
||||
let didCancel = false;
|
||||
let retryTimer: number | undefined;
|
||||
const cancel = consumeEventIterator(createResumeUpdateEventIterator(resumeId), {
|
||||
onEvent: async () => {
|
||||
try {
|
||||
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
|
||||
|
||||
if (hasPendingLocalChanges(resumeId)) {
|
||||
const runtime = getRuntime(resumeId);
|
||||
const currentResume = useResumeStore.getState().resume;
|
||||
const baselineData = runtime.baselineData ?? currentResume?.data;
|
||||
|
||||
if (currentResume && baselineData) {
|
||||
const localOperations = createResumePatches(baselineData, currentResume.data);
|
||||
const mergedData = applyResumePatches(resume.data, localOperations);
|
||||
|
||||
runtime.baselineData = cloneResumeData(resume.data);
|
||||
runtime.hasPendingLocalChanges = localOperations.length > 0;
|
||||
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
|
||||
useResumeStore.getState().replaceResumeDraft({ ...resume, data: mergedData });
|
||||
syncCurrentResume(resumeId);
|
||||
} else {
|
||||
runtime.baselineData = cloneResumeData(resume.data);
|
||||
useResumeStore.getState().mergeResumeMetadata(resume);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
|
||||
replaceResumeFromServer(resume);
|
||||
await onUpdate();
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
console.warn("Failed to refresh resume after update event:", error);
|
||||
onError?.(error);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (didCancel) return;
|
||||
console.warn("Resume update stream failed, reconnecting:", error);
|
||||
onError?.(error);
|
||||
retryTimer = window.setTimeout(() => setRetryNonce((value) => value + 1), 2500);
|
||||
},
|
||||
});
|
||||
@@ -389,7 +386,36 @@ export function useResumeUpdateSubscription() {
|
||||
if (retryTimer) window.clearTimeout(retryTimer);
|
||||
void cancel().catch(() => {});
|
||||
};
|
||||
}, [onError, onUpdate, resumeId]);
|
||||
}
|
||||
|
||||
export function useBuilderResumeUpdateSubscription() {
|
||||
const queryClient = useQueryClient();
|
||||
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
|
||||
const params = useParams({ strict: false }) as { resumeId?: string };
|
||||
const resumeId = params.resumeId;
|
||||
|
||||
const onUpdate = useCallback(async () => {
|
||||
if (!resumeId) return;
|
||||
|
||||
bindRuntimeQueryClient(resumeId, queryClient);
|
||||
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
|
||||
|
||||
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
|
||||
|
||||
if (hasPendingLocalChanges(resumeId)) {
|
||||
useResumeStore.getState().mergeResumeMetadata(resume);
|
||||
return;
|
||||
}
|
||||
|
||||
replaceResumeFromServer(resume);
|
||||
}, [queryClient, replaceResumeFromServer, resumeId]);
|
||||
|
||||
const onError = useCallback((error: unknown) => {
|
||||
console.warn("Resume update stream failed, reconnecting:", error);
|
||||
}, []);
|
||||
|
||||
useResumeUpdateSubscription({ resumeId, onUpdate, onError });
|
||||
}
|
||||
|
||||
export function useResumeCleanup() {
|
||||
|
||||
@@ -57,6 +57,7 @@ import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { client, orpc, streamClient } from "@/libs/orpc/client";
|
||||
import { AgentThreadSidebar } from "./-components/thread-sidebar";
|
||||
import { attachmentIdsFromTransportBody, buildAgentChatSubmission } from "./-helpers/chat-attachments";
|
||||
import { useAgentResumeUpdateSubscription } from "./-hooks/use-agent-resume-updates";
|
||||
|
||||
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
|
||||
type AgentAction = AgentThreadDetail["actions"][number];
|
||||
@@ -1218,6 +1219,7 @@ function RouteComponent() {
|
||||
const [isThreadsCollapsed, setIsThreadsCollapsed] = useState(false);
|
||||
const [isResumeCollapsed, setIsResumeCollapsed] = useState(false);
|
||||
const { data, isLoading, error } = useQuery(orpc.agent.threads.get.queryOptions({ input: { id: threadId } }));
|
||||
useAgentResumeUpdateSubscription({ resumeId: data?.resume?.id, threadId });
|
||||
|
||||
const toggleThreadsPanel = useCallback(() => {
|
||||
const panel = threadsPanelRef.current;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAgentResumeUpdateSubscription } from "./use-agent-resume-updates";
|
||||
|
||||
const subscriptionMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const queryClientMock = vi.hoisted(() => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => queryClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useResumeUpdateSubscription: subscriptionMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
agent: {
|
||||
threads: {
|
||||
get: {
|
||||
queryKey: ({ input }: { input: { id: string } }) => ["agent", "threads", "get", input.id],
|
||||
},
|
||||
list: {
|
||||
queryKey: () => ["agent", "threads", "list"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useAgentResumeUpdateSubscription", () => {
|
||||
beforeEach(() => {
|
||||
subscriptionMock.mockReset();
|
||||
queryClientMock.invalidateQueries.mockClear();
|
||||
});
|
||||
|
||||
it("invalidates the active thread and thread list when the working resume changes", async () => {
|
||||
renderHook(() =>
|
||||
useAgentResumeUpdateSubscription({
|
||||
resumeId: "resume-1",
|
||||
threadId: "thread-1",
|
||||
}),
|
||||
);
|
||||
|
||||
const options = subscriptionMock.mock.calls[0]?.[0] as
|
||||
| { resumeId?: string; onUpdate: () => Promise<void> }
|
||||
| undefined;
|
||||
expect(options?.resumeId).toBe("resume-1");
|
||||
|
||||
await options?.onUpdate();
|
||||
|
||||
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agent", "threads", "list"],
|
||||
});
|
||||
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agent", "threads", "get", "thread-1"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useResumeUpdateSubscription } from "@/features/resume/builder/draft";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type UseAgentResumeUpdateSubscriptionInput = {
|
||||
resumeId?: string;
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
export function useAgentResumeUpdateSubscription({ resumeId, threadId }: UseAgentResumeUpdateSubscriptionInput) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onUpdate = useCallback(async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() }),
|
||||
queryClient.invalidateQueries({ queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }) }),
|
||||
]);
|
||||
}, [queryClient, threadId]);
|
||||
|
||||
useResumeUpdateSubscription({
|
||||
resumeId,
|
||||
onUpdate,
|
||||
onError: useCallback((error: unknown) => {
|
||||
console.warn("Agent resume update stream failed, reconnecting:", error);
|
||||
}, []),
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import { useEffect, useRef } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import {
|
||||
useBuilderResumeUpdateSubscription,
|
||||
useInitializeResumeStore,
|
||||
useMergeResumeMetadata,
|
||||
useResumeCleanup,
|
||||
useResumeStore,
|
||||
useResumeUpdateSubscription,
|
||||
} from "@/features/resume/builder/draft";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -62,7 +62,7 @@ function RouteComponent() {
|
||||
const isInitialized = isReady && initializedResumeId === resumeId;
|
||||
|
||||
useResumeCleanup();
|
||||
useResumeUpdateSubscription();
|
||||
useBuilderResumeUpdateSubscription();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) return;
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"@better-auth/infra",
|
||||
"@better-auth/passkey",
|
||||
"@orpc/experimental-ratelimit",
|
||||
"@react-pdf/renderer",
|
||||
"@sindresorhus/slugify",
|
||||
"@t3-oss/env-core",
|
||||
"@uiw/color-convert",
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("rateLimitConfig", () => {
|
||||
});
|
||||
|
||||
it("provides reasonable mutation throughput for resume edits", () => {
|
||||
expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 60, window: 60 * 1000 });
|
||||
expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 300, window: 60 * 1000 });
|
||||
});
|
||||
|
||||
it("limits storage uploads more strictly than deletes", () => {
|
||||
|
||||
@@ -45,6 +45,6 @@ export const rateLimitConfig = {
|
||||
jobsTestConnection: { maxRequests: 10, window: 60 * 1000 },
|
||||
storageUpload: { maxRequests: 20, window: 60 * 1000 },
|
||||
storageDelete: { maxRequests: 30, window: 60 * 1000 },
|
||||
resumeMutations: { maxRequests: 60, window: 60 * 1000 },
|
||||
resumeMutations: { maxRequests: 300, window: 60 * 1000 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
Generated
-3
@@ -336,9 +336,6 @@ importers:
|
||||
'@reactive-resume/pdf':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/pdf
|
||||
'@reactive-resume/resume':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/resume
|
||||
'@reactive-resume/schema':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/schema
|
||||
|
||||
Reference in New Issue
Block a user