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", create: "edges.create",
remove: "edges.remove", 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"; "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 { api } from "@/convex/_generated/api";
import type { Doc } from "@/convex/_generated/dataModel"; import type { Doc } from "@/convex/_generated/dataModel";
import { useAuthQuery } from "@/hooks/use-auth-query";
type AdjustmentPresetDoc = Doc<"adjustmentPresets">; type AdjustmentPresetDoc = Doc<"adjustmentPresets">;
type PresetsByNodeType = Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>; 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 = { type CanvasPresetsProviderProps = {
enabled?: boolean; enabled?: boolean;
@@ -20,25 +56,163 @@ export function CanvasPresetsProvider({
enabled = true, enabled = true,
children, children,
}: CanvasPresetsProviderProps) { }: 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 clearScheduledRetry = useCallback(() => {
const next = new Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>(); if (retryTimeoutRef.current !== null) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
}, []);
for (const preset of (rawPresets ?? []) as AdjustmentPresetDoc[]) { const scheduleRetry = useCallback(() => {
const existing = next.get(preset.nodeType); if (retryTimeoutRef.current !== null) {
if (existing) { return;
existing.push(preset);
} else {
next.set(preset.nodeType, [preset]);
}
} }
return next; retryTimeoutRef.current = setTimeout(() => {
}, [rawPresets]); 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 ( return (
<CanvasPresetsContext.Provider value={presetsByNodeType}> <CanvasPresetsContext.Provider value={contextValue}>
{children} {children}
</CanvasPresetsContext.Provider> </CanvasPresetsContext.Provider>
); );
@@ -52,5 +226,14 @@ export function useCanvasAdjustmentPresets(
return []; 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 { useCallback, useMemo, useState } from "react";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Palette } from "lucide-react"; import { Palette } from "lucide-react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; 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 { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper"; import BaseNodeWrapper from "@/components/canvas/nodes/base-node-wrapper";
import AdjustmentPreview from "@/components/canvas/nodes/adjustment-preview"; 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 tNodes = useTranslations("nodes");
const tToasts = useTranslations("toasts"); const tToasts = useTranslations("toasts");
const { queueNodeDataUpdate } = useCanvasSync(); const { queueNodeDataUpdate } = useCanvasSync();
const savePreset = useMutation(api.presets.save); const savePreset = useSaveCanvasAdjustmentPreset();
const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[]; const userPresets = useCanvasAdjustmentPresets("color-adjust") as PresetDoc[];
const [presetSelection, setPresetSelection] = useState("custom"); const [presetSelection, setPresetSelection] = useState("custom");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import type * as ai_retry from "../ai_retry.js";
import type * as ai_utils from "../ai_utils.js"; import type * as ai_utils from "../ai_utils.js";
import type * as auth from "../auth.js"; import type * as auth from "../auth.js";
import type * as batch_validation_utils from "../batch_validation_utils.js"; import type * as batch_validation_utils from "../batch_validation_utils.js";
import type * as canvasGraph from "../canvasGraph.js";
import type * as canvases from "../canvases.js"; import type * as canvases from "../canvases.js";
import type * as credits from "../credits.js"; import type * as credits from "../credits.js";
import type * as edges from "../edges.js"; import type * as edges from "../edges.js";
@@ -46,6 +47,7 @@ declare const fullApi: ApiFromModules<{
ai_utils: typeof ai_utils; ai_utils: typeof ai_utils;
auth: typeof auth; auth: typeof auth;
batch_validation_utils: typeof batch_validation_utils; batch_validation_utils: typeof batch_validation_utils;
canvasGraph: typeof canvasGraph;
canvases: typeof canvases; canvases: typeof canvases;
credits: typeof credits; credits: typeof credits;
edges: typeof edges; edges: typeof edges;

66
convex/canvasGraph.ts Normal file
View File

@@ -0,0 +1,66 @@
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { query, type QueryCtx } from "./_generated/server";
import { requireAuth } from "./helpers";
const PERFORMANCE_LOG_THRESHOLD_MS = 250;
export async function loadCanvasGraph(
ctx: QueryCtx,
args: {
canvasId: Id<"canvases">;
userId: string;
},
) {
const canvas = await ctx.db.get(args.canvasId);
if (!canvas || canvas.ownerId != args.userId) {
throw new Error("Canvas not found");
}
const [nodes, edges] = await Promise.all([
ctx.db
.query("nodes")
.withIndex("by_canvas", (q) => q.eq("canvasId", args.canvasId))
.collect(),
ctx.db
.query("edges")
.withIndex("by_canvas", (q) => q.eq("canvasId", args.canvasId))
.collect(),
]);
return { canvas, nodes, edges };
}
export const get = query({
args: { canvasId: v.id("canvases") },
handler: async (ctx, { canvasId }) => {
const startedAt = Date.now();
const authStartedAt = Date.now();
const user = await requireAuth(ctx);
const authMs = Date.now() - authStartedAt;
const graphStartedAt = Date.now();
const { canvas, nodes, edges } = await loadCanvasGraph(ctx, {
canvasId,
userId: user.userId,
});
const graphMs = Date.now() - graphStartedAt;
const durationMs = Date.now() - startedAt;
if (durationMs >= PERFORMANCE_LOG_THRESHOLD_MS) {
console.warn("[canvasGraph.get] slow graph query", {
canvasId,
userId: user.userId,
authMs,
graphMs,
nodeCount: nodes.length,
edgeCount: edges.length,
canvasUpdatedAt: canvas.updatedAt,
durationMs,
});
}
return { nodes, edges };
},
});

View File

@@ -0,0 +1,54 @@
# Canvas Presets Load Shedding Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Remove `presets:list` as a reactive canvas hot-path subscription while keeping adjustment preset UX intact.
**Architecture:** Move preset loading into `CanvasPresetsProvider` as a one-off client fetch using `useConvex().query(...)` when preset-aware nodes appear, then keep the provider state in sync locally after saves. Adjustment nodes continue to read presets through context, but they stop creating live Convex subscription pressure.
**Tech Stack:** Next.js 16, React 19, Convex, TypeScript, Vitest
---
### Task 1: Convert the provider to one-off loading
**Files:**
- Modify: `components/canvas/canvas-presets-context.tsx`
- Test: `tests/canvas-presets-context.test.ts`
**Step 1:** Write a failing test that expects the provider to fetch presets via an imperative Convex query and expose presets by node type.
**Step 2:** Run the targeted test and verify it fails for the expected reason.
**Step 3:** Implement provider-owned preset state, one-off loading, and derived per-type lookup.
**Step 4:** Re-run the targeted test and confirm it passes.
### Task 2: Keep local preset state fresh after saves
**Files:**
- Modify: `components/canvas/canvas-presets-context.tsx`
- Modify: `components/canvas/nodes/curves-node.tsx`
- Modify: `components/canvas/nodes/color-adjust-node.tsx`
- Modify: `components/canvas/nodes/light-adjust-node.tsx`
- Modify: `components/canvas/nodes/detail-adjust-node.tsx`
- Test: `tests/canvas-presets-context.test.ts`
**Step 1:** Write a failing test that expects saving a preset through context to update the provider state without a reactive query.
**Step 2:** Run the targeted test and verify it fails for the expected reason.
**Step 3:** Add a context-backed save helper and switch the adjustment nodes to use it.
**Step 4:** Re-run the targeted test and confirm it passes.
### Task 3: Verify the presets refactor
**Files:**
- Verify only
**Step 1:** Run the targeted preset tests.
**Step 2:** Run `pnpm test`.
**Step 3:** Run `pnpm lint` and confirm only pre-existing warnings remain.

View File

@@ -0,0 +1,65 @@
# Canvas Graph Query Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Reduce canvas hot-path Convex query overhead by collapsing `nodes:list` and `edges:list` into one shared graph query and keeping the optimistic client cache aligned with that query.
**Architecture:** Add a new Convex `canvasGraph.get` query that performs auth and canvas authorization once, then returns both node and edge collections together. Update the canvas data hook and optimistic local cache helpers to use this graph query as the primary reactive source while leaving the existing list queries available for other callers.
**Tech Stack:** Next.js 16, React 19, Convex, React Flow, TypeScript, Vitest
---
### Task 1: Add a shared Convex graph query
**Files:**
- Create: `convex/canvasGraph.ts`
- Test: `tests/convex/canvas-graph-query.test.ts`
**Step 1:** Write a failing test that expects one graph query to return both nodes and edges for an authorized canvas.
**Step 2:** Run the targeted test and verify it fails for the expected missing-module or missing-export reason.
**Step 3:** Implement `canvasGraph.get` with one auth check, one canvas authorization check, and indexed `nodes` + `edges` reads.
**Step 4:** Re-run the targeted test and confirm it passes.
### Task 2: Switch the canvas hook to the graph query
**Files:**
- Modify: `components/canvas/use-canvas-data.ts`
- Test: `tests/use-canvas-data.test.tsx`
**Step 1:** Write a failing hook test that expects `useCanvasData` to subscribe to the graph query and derive `convexNodes` / `convexEdges` from it.
**Step 2:** Run the targeted test and verify it fails for the expected reason.
**Step 3:** Update the hook to use the graph query result as the primary canvas model without changing the public hook return shape.
**Step 4:** Re-run the targeted test and confirm it passes.
### Task 3: Keep optimistic cache updates aligned with the graph query
**Files:**
- Modify: `components/canvas/use-canvas-sync-engine.ts`
- Create: `components/canvas/canvas-graph-query-cache.ts`
- Test: `components/canvas/__tests__/canvas-graph-query-cache.test.ts`
**Step 1:** Write failing tests for small cache helpers that read and update graph query data during optimistic mutations.
**Step 2:** Run the targeted helper tests and verify they fail for the expected missing-helper reason.
**Step 3:** Implement the helpers and update optimistic mutations to use them so online optimistic canvas updates still work with the new graph query.
**Step 4:** Re-run the targeted tests and confirm they pass.
### Task 4: Verify the hot-path refactor
**Files:**
- Verify only
**Step 1:** Run the targeted new tests for the graph query, hook, and cache helpers.
**Step 2:** Run `pnpm test` to confirm the full suite stays green.
**Step 3:** Run `pnpm lint` on the worktree and fix any regressions in touched files.

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", () => ({ vi.mock("@/components/canvas/canvas-presets-context", () => ({
useCanvasAdjustmentPresets: () => [], useCanvasAdjustmentPresets: () => [],
useSaveCanvasAdjustmentPreset: () => vi.fn(async () => undefined),
})); }));
vi.mock("@/components/canvas/canvas-sync-context", () => ({ 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"],
});
});
});