perf(canvas): reduce Convex hot-path query load

This commit is contained in:
2026-04-08 12:49:23 +02:00
parent 96d9c895ad
commit 90e36a5c15
18 changed files with 1159 additions and 78 deletions

View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/convex/_generated/api", () => ({
api: {
canvasGraph: { get: "canvasGraph.get" },
},
}));
import {
getCanvasGraphEdgesFromQuery,
getCanvasGraphNodesFromQuery,
setCanvasGraphEdgesInQuery,
setCanvasGraphNodesInQuery,
} from "@/components/canvas/canvas-graph-query-cache";
describe("canvas graph query cache helpers", () => {
it("returns cached nodes and edges from the shared graph query", () => {
const graph = {
nodes: [{ _id: "node_1" }],
edges: [{ _id: "edge_1" }],
};
const localStore = {
getQuery: vi.fn((_query, args) =>
args.canvasId === "canvas_1" ? graph : undefined,
),
};
expect(getCanvasGraphNodesFromQuery(localStore as never, { canvasId: "canvas_1" as never })).toEqual(graph.nodes);
expect(getCanvasGraphEdgesFromQuery(localStore as never, { canvasId: "canvas_1" as never })).toEqual(graph.edges);
});
it("preserves the sibling collection when replacing nodes or edges", () => {
const graph = {
nodes: [{ _id: "node_1" }],
edges: [{ _id: "edge_1" }],
};
const localStore = {
getQuery: vi.fn((_query, args) =>
Object.keys(args).length === 1 && args.canvasId === "canvas_1"
? graph
: undefined,
),
setQuery: vi.fn(),
};
setCanvasGraphNodesInQuery(localStore as never, {
canvasId: "canvas_1" as never,
nodes: [{ _id: "node_2" }] as never,
});
setCanvasGraphEdgesInQuery(localStore as never, {
canvasId: "canvas_1" as never,
edges: [{ _id: "edge_2" }] as never,
});
expect(localStore.getQuery).toHaveBeenNthCalledWith(1, "canvasGraph.get", { canvasId: "canvas_1" });
expect(localStore.getQuery).toHaveBeenNthCalledWith(2, "canvasGraph.get", { canvasId: "canvas_1" });
expect(localStore.setQuery).toHaveBeenNthCalledWith(1, "canvasGraph.get", { canvasId: "canvas_1" }, {
nodes: [{ _id: "node_2" }],
edges: [{ _id: "edge_1" }],
});
expect(localStore.setQuery).toHaveBeenNthCalledWith(2, "canvasGraph.get", { canvasId: "canvas_1" }, {
nodes: [{ _id: "node_1" }],
edges: [{ _id: "edge_2" }],
});
});
});

View File

@@ -0,0 +1,371 @@
// @vitest-environment jsdom
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
const queryMock = vi.hoisted(() => vi.fn());
const saveMutationMock = vi.hoisted(() => vi.fn());
const authState = vi.hoisted(() => ({ isAuthenticated: true }));
const convexClient = vi.hoisted(() => ({ query: queryMock }));
vi.mock("@/convex/_generated/api", () => ({
api: {
presets: {
list: "presets.list",
save: "presets.save",
},
},
}));
vi.mock("convex/react", () => ({
useConvex: () => convexClient,
useConvexAuth: () => ({ isAuthenticated: authState.isAuthenticated }),
useMutation: (key: string) => {
if (key === "presets.save") {
return saveMutationMock;
}
return vi.fn();
},
}));
import {
CanvasPresetsProvider,
useCanvasAdjustmentPresets,
useSaveCanvasAdjustmentPreset,
} from "@/components/canvas/canvas-presets-context";
type PresetConsumerSnapshot = {
curves: string[];
colorAdjust: string[];
};
const latestSnapshotRef: { current: PresetConsumerSnapshot | null } = { current: null };
const latestSaveRef: {
current:
| ((args: {
name: string;
nodeType: "curves" | "color-adjust" | "light-adjust" | "detail-adjust";
params: unknown;
}) => Promise<unknown>)
| null;
} = { current: null };
function Harness() {
const curves = useCanvasAdjustmentPresets("curves");
const colorAdjust = useCanvasAdjustmentPresets("color-adjust");
const savePreset = useSaveCanvasAdjustmentPreset();
useEffect(() => {
latestSnapshotRef.current = {
curves: curves.map((preset) => preset.name),
colorAdjust: colorAdjust.map((preset) => preset.name),
};
latestSaveRef.current = savePreset;
return () => {
latestSnapshotRef.current = null;
latestSaveRef.current = null;
};
}, [colorAdjust, curves, savePreset]);
return null;
}
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("CanvasPresetsProvider", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
vi.useRealTimers();
container?.remove();
container = null;
root = null;
latestSnapshotRef.current = null;
latestSaveRef.current = null;
authState.isAuthenticated = true;
queryMock.mockReset();
saveMutationMock.mockReset();
});
it("loads presets with an imperative one-off query and exposes them by node type", async () => {
queryMock.mockResolvedValue([
{ _id: "preset-1", name: "Film Fade", nodeType: "curves", params: {} },
{ _id: "preset-2", name: "Warm Pop", nodeType: "color-adjust", params: {} },
{ _id: "preset-3", name: "Crisp Contrast", nodeType: "curves", params: {} },
]);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(CanvasPresetsProvider, { enabled: true }, React.createElement(Harness)),
);
});
await vi.waitFor(() => {
expect(latestSnapshotRef.current).toEqual({
curves: ["Film Fade", "Crisp Contrast"],
colorAdjust: ["Warm Pop"],
});
});
expect(queryMock).toHaveBeenCalledTimes(1);
expect(queryMock).toHaveBeenCalledWith("presets.list", {});
});
it("refreshes provider state after saving a preset through context", async () => {
queryMock
.mockResolvedValueOnce([
{ _id: "preset-1", name: "Film Fade", nodeType: "curves", params: {} },
])
.mockResolvedValueOnce([
{ _id: "preset-2", name: "Studio Glow", nodeType: "curves", params: {} },
{ _id: "preset-1", name: "Film Fade", nodeType: "curves", params: {} },
]);
saveMutationMock.mockResolvedValue("preset-2");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(CanvasPresetsProvider, { enabled: true }, React.createElement(Harness)),
);
});
await vi.waitFor(() => {
expect(latestSnapshotRef.current?.curves).toEqual(["Film Fade"]);
});
await act(async () => {
await latestSaveRef.current?.({
name: "Studio Glow",
nodeType: "curves",
params: { contrast: 10 },
});
});
await vi.waitFor(() => {
expect(latestSnapshotRef.current?.curves).toEqual(["Studio Glow", "Film Fade"]);
});
expect(saveMutationMock).toHaveBeenCalledWith({
name: "Studio Glow",
nodeType: "curves",
params: { contrast: 10 },
});
expect(queryMock).toHaveBeenCalledTimes(2);
});
it("waits for auth before fetching presets", async () => {
authState.isAuthenticated = false;
queryMock.mockResolvedValue([
{ _id: "preset-1", name: "Recovered", nodeType: "curves", params: {} },
]);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(CanvasPresetsProvider, { enabled: true }, React.createElement(Harness)),
);
});
expect(queryMock).not.toHaveBeenCalled();
expect(latestSnapshotRef.current).toEqual({ curves: [], colorAdjust: [] });
authState.isAuthenticated = true;
await act(async () => {
root?.render(
React.createElement(CanvasPresetsProvider, { enabled: true }, React.createElement(Harness)),
);
});
await vi.waitFor(() => {
expect(latestSnapshotRef.current?.curves).toEqual(["Recovered"]);
});
expect(queryMock).toHaveBeenCalledTimes(1);
});
it("retries after repeated transient preset fetch failures", async () => {
vi.useFakeTimers();
queryMock
.mockRejectedValueOnce(new Error("temporary-1"))
.mockRejectedValueOnce(new Error("temporary-2"))
.mockResolvedValueOnce([
{ _id: "preset-1", name: "Recovered", nodeType: "curves", params: {} },
]);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(CanvasPresetsProvider, { enabled: true }, React.createElement(Harness)),
);
await Promise.resolve();
await Promise.resolve();
});
expect(queryMock).toHaveBeenCalledTimes(2);
expect(latestSnapshotRef.current?.curves).toEqual([]);
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
await Promise.resolve();
});
await vi.waitFor(() => {
expect(latestSnapshotRef.current?.curves).toEqual(["Recovered"]);
});
expect(queryMock).toHaveBeenCalledTimes(3);
});
it("does not fail the save flow when only the refresh step fails", async () => {
vi.useFakeTimers();
queryMock
.mockResolvedValueOnce([
{ _id: "preset-1", name: "Film Fade", nodeType: "curves", params: {} },
])
.mockRejectedValueOnce(new Error("refresh-temporary"))
.mockResolvedValueOnce([
{ _id: "preset-2", name: "Studio Glow", nodeType: "curves", params: {} },
{ _id: "preset-1", name: "Film Fade", nodeType: "curves", params: {} },
]);
saveMutationMock.mockResolvedValue("preset-2");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(CanvasPresetsProvider, { enabled: true }, React.createElement(Harness)),
);
});
await vi.waitFor(() => {
expect(latestSnapshotRef.current?.curves).toEqual(["Film Fade"]);
});
const savePromise = latestSaveRef.current?.({
name: "Studio Glow",
nodeType: "curves",
params: { contrast: 10 },
});
await act(async () => {
await savePromise;
});
expect(saveMutationMock).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(latestSnapshotRef.current?.curves).toEqual(["Studio Glow", "Film Fade"]);
});
expect(queryMock).toHaveBeenCalledTimes(3);
});
it("ignores stale failed refreshes after a newer refresh succeeds", async () => {
vi.useFakeTimers();
let rejectOlderRefresh: ((error: Error) => void) | null = null;
const olderRefreshPromise = new Promise<never>((_, reject) => {
rejectOlderRefresh = reject;
});
queryMock
.mockResolvedValueOnce([
{ _id: "preset-1", name: "Film Fade", nodeType: "curves", params: {} },
])
.mockImplementationOnce(() => olderRefreshPromise)
.mockResolvedValueOnce([
{ _id: "preset-3", name: "Cinematic Glow", nodeType: "curves", params: {} },
{ _id: "preset-2", name: "Studio Glow", nodeType: "curves", params: {} },
{ _id: "preset-1", name: "Film Fade", nodeType: "curves", params: {} },
]);
saveMutationMock.mockResolvedValue("preset-2");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
React.createElement(CanvasPresetsProvider, { enabled: true }, React.createElement(Harness)),
);
});
await vi.waitFor(() => {
expect(latestSnapshotRef.current?.curves).toEqual(["Film Fade"]);
});
const firstSavePromise = latestSaveRef.current?.({
name: "Studio Glow",
nodeType: "curves",
params: { contrast: 10 },
});
await act(async () => {
await Promise.resolve();
});
const secondSavePromise = latestSaveRef.current?.({
name: "Cinematic Glow",
nodeType: "curves",
params: { contrast: 20 },
});
await act(async () => {
await secondSavePromise;
});
await vi.waitFor(() => {
expect(latestSnapshotRef.current?.curves).toEqual([
"Cinematic Glow",
"Studio Glow",
"Film Fade",
]);
});
await act(async () => {
rejectOlderRefresh?.(new Error("older refresh failed"));
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
await firstSavePromise;
});
expect(latestSnapshotRef.current?.curves).toEqual([
"Cinematic Glow",
"Studio Glow",
"Film Fade",
]);
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
await Promise.resolve();
});
expect(queryMock).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/convex/helpers", () => ({
requireAuth: vi.fn(),
}));
import type { Id } from "@/convex/_generated/dataModel";
import { loadCanvasGraph } from "@/convex/canvasGraph";
describe("loadCanvasGraph", () => {
it("returns nodes and edges for an authorized canvas", async () => {
const canvasId = "canvas_1" as Id<"canvases">;
const nodes = [{ _id: "node_1", canvasId }];
const edges = [{ _id: "edge_1", canvasId }];
const ctx = {
db: {
get: vi.fn(async (id: Id<"canvases">) =>
id === canvasId ? { _id: canvasId, ownerId: "user_1" } : null,
),
query: vi.fn((table: "nodes" | "edges") => ({
withIndex: vi.fn((_index: string, apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown) => {
const queryBuilder = {
eq: vi.fn().mockReturnThis(),
};
apply(queryBuilder);
return {
collect: vi.fn(async () => (table === "nodes" ? nodes : edges)),
};
}),
})),
},
};
await expect(
loadCanvasGraph(ctx as never, {
canvasId,
userId: "user_1",
}),
).resolves.toEqual({
canvas: { _id: canvasId, ownerId: "user_1" },
nodes,
edges,
});
});
it("throws when the canvas belongs to another user", async () => {
const canvasId = "canvas_1" as Id<"canvases">;
const ctx = {
db: {
get: vi.fn(async () => ({ _id: canvasId, ownerId: "other_user" })),
query: vi.fn(),
},
};
await expect(
loadCanvasGraph(ctx as never, {
canvasId,
userId: "user_1",
}),
).rejects.toThrow("Canvas not found");
});
});

View File

@@ -35,6 +35,7 @@ vi.mock("lucide-react", () => ({
vi.mock("@/components/canvas/canvas-presets-context", () => ({
useCanvasAdjustmentPresets: () => [],
useSaveCanvasAdjustmentPreset: () => vi.fn(async () => undefined),
}));
vi.mock("@/components/canvas/canvas-sync-context", () => ({

View File

@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
const useQueryMock = vi.hoisted(() => vi.fn());
const resolveStorageUrlsForCanvasMock = vi.hoisted(() => vi.fn());
vi.mock("@/convex/_generated/api", () => ({
api: {
canvasGraph: { get: "canvasGraph.get" },
canvases: { get: "canvases.get" },
storage: { batchGetUrlsForCanvas: "storage.batchGetUrlsForCanvas" },
},
}));
vi.mock("convex/react", () => ({
useConvexAuth: () => ({ isLoading: false, isAuthenticated: true }),
useMutation: () => resolveStorageUrlsForCanvasMock,
useQuery: useQueryMock,
}));
vi.mock("@/lib/auth-client", () => ({
authClient: {
useSession: () => ({
data: { user: { email: "user@example.com" } },
isPending: false,
}),
},
}));
import { useCanvasData } from "@/components/canvas/use-canvas-data";
const latestHookValue: {
current: ReturnType<typeof useCanvasData> | null;
} = { current: null };
function HookHarness() {
const value = useCanvasData({ canvasId: "canvas_1" as never });
useEffect(() => {
latestHookValue.current = value;
return () => {
latestHookValue.current = null;
};
}, [value]);
return null;
}
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useCanvasData", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
latestHookValue.current = null;
useQueryMock.mockReset();
resolveStorageUrlsForCanvasMock.mockReset();
});
it("subscribes to the shared graph query and derives nodes and edges from it", async () => {
const graph = {
nodes: [
{
_id: "node_1",
canvasId: "canvas_1",
data: { storageId: "storage_1" },
},
],
edges: [{ _id: "edge_1", canvasId: "canvas_1" }],
};
useQueryMock.mockImplementation((query: string) => {
if (query === "canvasGraph.get") {
return graph;
}
if (query === "canvases.get") {
return { _id: "canvas_1" };
}
return undefined;
});
resolveStorageUrlsForCanvasMock.mockResolvedValue({ storage_1: "https://cdn.example.com/1" });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(React.createElement(HookHarness));
});
await act(async () => {
await Promise.resolve();
});
expect(useQueryMock).toHaveBeenCalledWith("canvasGraph.get", { canvasId: "canvas_1" });
expect(latestHookValue.current?.convexNodes).toEqual(graph.nodes);
expect(latestHookValue.current?.convexEdges).toEqual(graph.edges);
expect(resolveStorageUrlsForCanvasMock).toHaveBeenCalledWith({
canvasId: "canvas_1",
storageIds: ["storage_1"],
});
});
});