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

@@ -31,6 +31,9 @@ vi.mock("@/convex/_generated/api", () => ({
create: "edges.create",
remove: "edges.remove",
},
canvasGraph: {
get: "canvasGraph.get",
},
},
}));

View File

@@ -0,0 +1,80 @@
import type { OptimisticLocalStore } from "convex/browser";
import type { FunctionReference } from "convex/server";
import { api } from "@/convex/_generated/api";
import type { Doc, Id } from "@/convex/_generated/dataModel";
export const canvasGraphQuery = (api as unknown as {
canvasGraph: {
get: FunctionReference<
"query",
"public",
{ canvasId: Id<"canvases"> },
{ nodes: Doc<"nodes">[]; edges: Doc<"edges">[] }
>;
};
}).canvasGraph.get;
type CanvasGraphQueryResult = {
nodes: Doc<"nodes">[];
edges: Doc<"edges">[];
};
type CanvasGraphArgs = {
canvasId: Id<"canvases">;
};
function getCanvasGraphFromQuery(
localStore: OptimisticLocalStore,
args: CanvasGraphArgs,
): CanvasGraphQueryResult | undefined {
return localStore.getQuery(canvasGraphQuery, args) as CanvasGraphQueryResult | undefined;
}
export function getCanvasGraphNodesFromQuery(
localStore: OptimisticLocalStore,
args: CanvasGraphArgs,
): Doc<"nodes">[] | undefined {
return getCanvasGraphFromQuery(localStore, args)?.nodes;
}
export function getCanvasGraphEdgesFromQuery(
localStore: OptimisticLocalStore,
args: CanvasGraphArgs,
): Doc<"edges">[] | undefined {
return getCanvasGraphFromQuery(localStore, args)?.edges;
}
export function setCanvasGraphNodesInQuery(
localStore: OptimisticLocalStore,
args: CanvasGraphArgs & { nodes: Doc<"nodes">[] },
): void {
const current = getCanvasGraphFromQuery(localStore, {
canvasId: args.canvasId,
});
if (!current) {
return;
}
localStore.setQuery(canvasGraphQuery, { canvasId: args.canvasId }, {
nodes: args.nodes,
edges: current.edges,
});
}
export function setCanvasGraphEdgesInQuery(
localStore: OptimisticLocalStore,
args: CanvasGraphArgs & { edges: Doc<"edges">[] },
): void {
const current = getCanvasGraphFromQuery(localStore, {
canvasId: args.canvasId,
});
if (!current) {
return;
}
localStore.setQuery(canvasGraphQuery, { canvasId: args.canvasId }, {
nodes: current.nodes,
edges: args.edges,
});
}

View File

@@ -1,15 +1,51 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useConvex, useConvexAuth, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Doc } from "@/convex/_generated/dataModel";
import { useAuthQuery } from "@/hooks/use-auth-query";
type AdjustmentPresetDoc = Doc<"adjustmentPresets">;
type PresetsByNodeType = Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>;
type SaveAdjustmentPresetArgs = {
name: string;
nodeType: AdjustmentPresetDoc["nodeType"];
params: unknown;
};
const CanvasPresetsContext = createContext<PresetsByNodeType | null>(null);
type CanvasPresetsContextValue = {
presetsByNodeType: PresetsByNodeType;
savePreset: (args: SaveAdjustmentPresetArgs) => Promise<void>;
};
const MAX_AUTO_LOAD_ATTEMPTS = 2;
const RETRY_DELAY_MS = 1000;
const CanvasPresetsContext = createContext<CanvasPresetsContextValue | null>(null);
function groupPresetsByNodeType(rawPresets: AdjustmentPresetDoc[]): PresetsByNodeType {
const next = new Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>();
for (const preset of rawPresets) {
const existing = next.get(preset.nodeType);
if (existing) {
existing.push(preset);
} else {
next.set(preset.nodeType, [preset]);
}
}
return next;
}
type CanvasPresetsProviderProps = {
enabled?: boolean;
@@ -20,25 +56,163 @@ export function CanvasPresetsProvider({
enabled = true,
children,
}: CanvasPresetsProviderProps) {
const rawPresets = useAuthQuery(api.presets.list, enabled ? {} : "skip");
const convex = useConvex();
const { isAuthenticated } = useConvexAuth();
const savePresetMutation = useMutation(api.presets.save);
const latestLoadRequestIdRef = useRef(0);
const autoLoadInFlightRef = useRef(false);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [rawPresets, setRawPresets] = useState<AdjustmentPresetDoc[]>([]);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [retryNonce, setRetryNonce] = useState(0);
const presetsByNodeType = useMemo<PresetsByNodeType>(() => {
const next = new Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>();
const clearScheduledRetry = useCallback(() => {
if (retryTimeoutRef.current !== null) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
}, []);
for (const preset of (rawPresets ?? []) as AdjustmentPresetDoc[]) {
const existing = next.get(preset.nodeType);
if (existing) {
existing.push(preset);
} else {
next.set(preset.nodeType, [preset]);
}
const scheduleRetry = useCallback(() => {
if (retryTimeoutRef.current !== null) {
return;
}
return next;
}, [rawPresets]);
retryTimeoutRef.current = setTimeout(() => {
retryTimeoutRef.current = null;
setRetryNonce((current) => current + 1);
}, RETRY_DELAY_MS);
}, []);
const loadPresets = useCallback(async () => {
const presets = await convex.query(api.presets.list, {});
return presets as AdjustmentPresetDoc[];
}, [convex]);
const refreshPresets = useCallback(async () => {
const requestId = ++latestLoadRequestIdRef.current;
try {
const presets = await loadPresets();
if (requestId === latestLoadRequestIdRef.current) {
clearScheduledRetry();
setRawPresets(presets);
}
return { requestId, presets };
} catch (error: unknown) {
throw { requestId, error };
}
}, [clearScheduledRetry, loadPresets]);
useEffect(() => {
return () => {
clearScheduledRetry();
};
}, [clearScheduledRetry]);
useEffect(() => {
if (
!enabled ||
!isAuthenticated ||
hasLoadedOnce ||
autoLoadInFlightRef.current
) {
return;
}
let cancelled = false;
autoLoadInFlightRef.current = true;
void (async () => {
for (let attempt = 0; attempt < MAX_AUTO_LOAD_ATTEMPTS; attempt += 1) {
try {
await refreshPresets();
if (!cancelled) {
setHasLoadedOnce(true);
}
return;
} catch (caught: unknown) {
if (cancelled) {
return;
}
const refreshError =
typeof caught === "object" && caught !== null && "error" in caught
? ((caught as { error: unknown }).error ?? caught)
: caught;
console.warn("[CanvasPresetsProvider] failed to load presets", {
message:
refreshError instanceof Error
? refreshError.message
: String(refreshError),
attempt: attempt + 1,
});
}
}
if (!cancelled) {
scheduleRetry();
}
})().finally(() => {
autoLoadInFlightRef.current = false;
});
return () => {
cancelled = true;
};
}, [enabled, hasLoadedOnce, isAuthenticated, refreshPresets, retryNonce, scheduleRetry]);
const savePreset = useCallback(
async (args: SaveAdjustmentPresetArgs) => {
await savePresetMutation(args);
try {
await refreshPresets();
setHasLoadedOnce(true);
} catch (caught: unknown) {
const refreshError =
typeof caught === "object" && caught !== null && "error" in caught
? ((caught as { error: unknown }).error ?? caught)
: caught;
const requestId =
typeof caught === "object" && caught !== null && "requestId" in caught
? ((caught as { requestId: unknown }).requestId as number)
: latestLoadRequestIdRef.current;
console.warn("[CanvasPresetsProvider] failed to refresh presets after save", {
message:
refreshError instanceof Error
? refreshError.message
: String(refreshError),
});
if (requestId === latestLoadRequestIdRef.current) {
setHasLoadedOnce(false);
scheduleRetry();
}
}
},
[refreshPresets, savePresetMutation, scheduleRetry],
);
const presetsByNodeType = useMemo<PresetsByNodeType>(
() => groupPresetsByNodeType(rawPresets),
[rawPresets],
);
const contextValue = useMemo<CanvasPresetsContextValue>(
() => ({
presetsByNodeType,
savePreset,
}),
[presetsByNodeType, savePreset],
);
return (
<CanvasPresetsContext.Provider value={presetsByNodeType}>
<CanvasPresetsContext.Provider value={contextValue}>
{children}
</CanvasPresetsContext.Provider>
);
@@ -52,5 +226,14 @@ export function useCanvasAdjustmentPresets(
return [];
}
return context.get(nodeType) ?? [];
return context.presetsByNodeType.get(nodeType) ?? [];
}
export function useSaveCanvasAdjustmentPreset(): CanvasPresetsContextValue["savePreset"] {
const context = useContext(CanvasPresetsContext);
if (context === null) {
throw new Error("useSaveCanvasAdjustmentPreset must be used within CanvasPresetsProvider");
}
return context.savePreset;
}

View File

@@ -2,13 +2,14 @@
import { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import { Palette } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
import {
useCanvasAdjustmentPresets,
useSaveCanvasAdjustmentPreset,
} from "@/components/canvas/canvas-presets-context";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
@@ -46,7 +47,7 @@ export default function ColorAdjustNode({ id, data, selected, width }: NodeProps
const tNodes = useTranslations("nodes");
const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save);
const savePreset = useSaveCanvasAdjustmentPreset();
const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[];
const [presetSelection, setPresetSelection] = useState("custom");

View File

@@ -2,13 +2,14 @@
import { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import { TrendingUp } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
import {
useCanvasAdjustmentPresets,
useSaveCanvasAdjustmentPreset,
} from "@/components/canvas/canvas-presets-context";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
@@ -46,7 +47,7 @@ export default function CurvesNode({ id, data, selected, width }: NodeProps<Curv
const tNodes = useTranslations("nodes");
const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save);
const savePreset = useSaveCanvasAdjustmentPreset();
const userPresets = useCanvasAdjustmentPresets("curves") as PresetDoc[];
const [presetSelection, setPresetSelection] = useState("custom");

View File

@@ -2,13 +2,14 @@
import { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import { Focus } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
import {
useCanvasAdjustmentPresets,
useSaveCanvasAdjustmentPreset,
} from "@/components/canvas/canvas-presets-context";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
@@ -46,7 +47,7 @@ export default function DetailAdjustNode({ id, data, selected, width }: NodeProp
const tNodes = useTranslations("nodes");
const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save);
const savePreset = useSaveCanvasAdjustmentPreset();
const userPresets = useCanvasAdjustmentPresets("detail-adjust") as PresetDoc[];
const [presetSelection, setPresetSelection] = useState("custom");

View File

@@ -2,13 +2,14 @@
import { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl";
import { Sun } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasAdjustmentPresets } from "@/components/canvas/canvas-presets-context";
import {
useCanvasAdjustmentPresets,
useSaveCanvasAdjustmentPreset,
} from "@/components/canvas/canvas-presets-context";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview";
@@ -46,7 +47,7 @@ export default function LightAdjustNode({ id, data, selected, width }: NodeProps
const tNodes = useTranslations("nodes");
const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save);
const savePreset = useSaveCanvasAdjustmentPreset();
const userPresets = useCanvasAdjustmentPresets("light-adjust") as PresetDoc[];
const [presetSelection, setPresetSelection] = useState("custom");

View File

@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client";
import { canvasGraphQuery } from "./canvas-graph-query-cache";
type UseCanvasDataParams = {
canvasId: Id<"canvases">;
@@ -45,14 +47,12 @@ export function useCanvasData({ canvasId }: UseCanvasDataParams) {
shouldSkipCanvasQueries,
]);
const convexNodes = useQuery(
api.nodes.list,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
const convexEdges = useQuery(
api.edges.list,
const graph = useQuery(
canvasGraphQuery,
shouldSkipCanvasQueries ? "skip" : { canvasId },
);
const convexNodes = graph?.nodes;
const convexEdges = graph?.edges;
const canvas = useQuery(
api.canvases.get,
shouldSkipCanvasQueries ? "skip" : { canvasId },

View File

@@ -45,6 +45,12 @@ import {
OPTIMISTIC_NODE_PREFIX,
type PendingEdgeSplit,
} from "./canvas-helpers";
import {
getCanvasGraphEdgesFromQuery,
getCanvasGraphNodesFromQuery,
setCanvasGraphEdgesInQuery,
setCanvasGraphNodesInQuery,
} from "./canvas-graph-query-cache";
type QueueSyncMutation = <TType extends keyof CanvasSyncOpPayloadByType>(
type: TType,
@@ -588,7 +594,7 @@ export function useCanvasSyncEngine({
const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
(localStore, args) => {
const current = localStore.getQuery(api.nodes.list, {
const current = getCanvasGraphNodesFromQuery(localStore, {
canvasId: args.canvasId,
});
if (current === undefined) return;
@@ -615,20 +621,20 @@ export function useCanvasSyncEngine({
zIndex: args.zIndex,
};
localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [
...current,
synthetic,
]);
setCanvasGraphNodesInQuery(localStore, {
canvasId: args.canvasId,
nodes: [...current, synthetic],
});
},
);
const createNodeWithEdgeFromSource = useMutation(
api.nodes.createWithEdgeFromSource,
).withOptimisticUpdate((localStore, args) => {
const nodeList = localStore.getQuery(api.nodes.list, {
const nodeList = getCanvasGraphNodesFromQuery(localStore, {
canvasId: args.canvasId,
});
const edgeList = localStore.getQuery(api.edges.list, {
const edgeList = getCanvasGraphEdgesFromQuery(localStore, {
canvasId: args.canvasId,
});
if (nodeList === undefined || edgeList === undefined) return;
@@ -674,23 +680,23 @@ export function useCanvasSyncEngine({
targetHandle: args.targetHandle,
};
localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [
...nodeList,
syntheticNode,
]);
localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [
...edgeList,
syntheticEdge,
]);
setCanvasGraphNodesInQuery(localStore, {
canvasId: args.canvasId,
nodes: [...nodeList, syntheticNode],
});
setCanvasGraphEdgesInQuery(localStore, {
canvasId: args.canvasId,
edges: [...edgeList, syntheticEdge],
});
});
const createNodeWithEdgeToTarget = useMutation(
api.nodes.createWithEdgeToTarget,
).withOptimisticUpdate((localStore, args) => {
const nodeList = localStore.getQuery(api.nodes.list, {
const nodeList = getCanvasGraphNodesFromQuery(localStore, {
canvasId: args.canvasId,
});
const edgeList = localStore.getQuery(api.edges.list, {
const edgeList = getCanvasGraphEdgesFromQuery(localStore, {
canvasId: args.canvasId,
});
if (nodeList === undefined || edgeList === undefined) return;
@@ -736,24 +742,24 @@ export function useCanvasSyncEngine({
targetHandle: args.targetHandle,
};
localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [
...nodeList,
syntheticNode,
]);
localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [
...edgeList,
syntheticEdge,
]);
setCanvasGraphNodesInQuery(localStore, {
canvasId: args.canvasId,
nodes: [...nodeList, syntheticNode],
});
setCanvasGraphEdgesInQuery(localStore, {
canvasId: args.canvasId,
edges: [...edgeList, syntheticEdge],
});
});
const createNodeWithEdgeSplitMut = useMutation(api.nodes.createWithEdgeSplit);
const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
(localStore, args) => {
const edgeList = localStore.getQuery(api.edges.list, {
const edgeList = getCanvasGraphEdgesFromQuery(localStore, {
canvasId: args.canvasId,
});
const nodeList = localStore.getQuery(api.nodes.list, {
const nodeList = getCanvasGraphNodesFromQuery(localStore, {
canvasId: args.canvasId,
});
if (edgeList === undefined || nodeList === undefined) return;
@@ -776,10 +782,10 @@ export function useCanvasSyncEngine({
sourceHandle: args.sourceHandle,
targetHandle: args.targetHandle,
};
localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [
...edgeList,
synthetic,
]);
setCanvasGraphEdgesInQuery(localStore, {
canvasId: args.canvasId,
edges: [...edgeList, synthetic],
});
},
);
@@ -1167,10 +1173,10 @@ export function useCanvasSyncEngine({
const splitEdgeAtExistingNodeMut = useMutation(
api.nodes.splitEdgeAtExistingNode,
).withOptimisticUpdate((localStore, args) => {
const edgeList = localStore.getQuery(api.edges.list, {
const edgeList = getCanvasGraphEdgesFromQuery(localStore, {
canvasId: args.canvasId,
});
const nodeList = localStore.getQuery(api.nodes.list, {
const nodeList = getCanvasGraphNodesFromQuery(localStore, {
canvasId: args.canvasId,
});
if (edgeList === undefined || nodeList === undefined) return;
@@ -1205,15 +1211,17 @@ export function useCanvasSyncEngine({
targetHandle: args.splitTargetHandle,
},
);
localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, nextEdges);
setCanvasGraphEdgesInQuery(localStore, {
canvasId: args.canvasId,
edges: nextEdges,
});
if (args.positionX !== undefined && args.positionY !== undefined) {
const px = args.positionX;
const py = args.positionY;
localStore.setQuery(
api.nodes.list,
{ canvasId: args.canvasId },
nodeList.map((n: Doc<"nodes">) =>
setCanvasGraphNodesInQuery(localStore, {
canvasId: args.canvasId,
nodes: nodeList.map((n: Doc<"nodes">) =>
n._id === args.middleNodeId
? {
...n,
@@ -1222,7 +1230,7 @@ export function useCanvasSyncEngine({
}
: n,
),
);
});
}
});