From 1fb8fd2863099ff527666f1653a1cf7f6ff9e98c Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 3 Apr 2026 14:52:34 +0200 Subject: [PATCH] Refactor canvas storage URL handling and integrate adjustment presets - Introduced a new `CanvasPresetsProvider` to manage adjustment presets for nodes, enhancing state management and reducing reactivity. - Updated storage URL resolution to utilize a mutation instead of a reactive query, improving performance and reducing unnecessary re-renders. - Refactored adjustment nodes (color-adjust, curves, detail-adjust, light-adjust) to use the new preset context for fetching user presets. - Improved overall canvas functionality by streamlining storage ID collection and URL resolution processes. --- components/canvas/CLAUDE.md | 8 +- components/canvas/canvas-presets-context.tsx | 56 ++++++++ components/canvas/canvas.tsx | 132 ++++++++++++------ components/canvas/credit-display.tsx | 24 +--- components/canvas/nodes/color-adjust-node.tsx | 4 +- components/canvas/nodes/curves-node.tsx | 4 +- .../canvas/nodes/detail-adjust-node.tsx | 4 +- components/canvas/nodes/light-adjust-node.tsx | 4 +- convex/CLAUDE.md | 12 +- convex/storage.ts | 89 ++++++------ ...4-03-canvas-convex-load-shedding-design.md | 33 +++++ .../2026-04-03-canvas-convex-load-shedding.md | 63 +++++++++ lib/CLAUDE.md | 4 +- lib/canvas-utils.ts | 2 +- 14 files changed, 322 insertions(+), 117 deletions(-) create mode 100644 components/canvas/canvas-presets-context.tsx create mode 100644 docs/plans/2026-04-03-canvas-convex-load-shedding-design.md create mode 100644 docs/plans/2026-04-03-canvas-convex-load-shedding.md diff --git a/components/canvas/CLAUDE.md b/components/canvas/CLAUDE.md index f882a2d..72f1f0d 100644 --- a/components/canvas/CLAUDE.md +++ b/components/canvas/CLAUDE.md @@ -29,6 +29,7 @@ app/(app)/canvas/[canvasId]/page.tsx | Datei | Zweck | |------|-------| | `canvas-helpers.ts` | Shared Utility-Layer (Optimistic IDs, Node-Merge, Compare-Resolution, Edge/Hit-Helpers, Konstante Defaults) | +| `canvas-presets-context.tsx` | Shared Preset-Provider für Adjustment-Nodes; bündelt `presets.list` zu einer einzigen Query | | `canvas-node-change-helpers.ts` | Dimensions-/Resize-Transformationen für `asset` und `ai-image` Nodes | | `canvas-generation-failures.ts` | Hook für AI-Generation-Error-Tracking mit Schwellenwert-Toast | | `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) | @@ -53,7 +54,9 @@ Convex und React Flow verwenden unterschiedliche Datenmodelle. Das Mapping liegt **Status-Injection:** `convexNodeToRF` schreibt `_status`, `_statusMessage` und `retryCount` in `data`, damit Node-Komponenten darauf zugreifen können ohne das Node-Dokument direkt zu kennen. -**URL-Caching:** Images mit `storageId` werden über einen batch-Storage-URL-Query aufgelöst (`urlByStorage`-Map). Die vorherige URL wird in `previousDataByNodeId` gecacht, um Flackern beim Reload zu vermeiden. +**URL-Caching:** Images mit `storageId` werden im Canvas nicht mehr über eine reaktive Query aufgelöst. `canvas.tsx` sammelt die aktuellen `storageId`s aus `nodes.list` und ruft `storage.batchGetUrlsForCanvas` gezielt per Mutation auf, nur wenn sich das Set ändert. Die vorherige URL wird in `previousDataByNodeId` gecacht, um Flackern beim Reload zu vermeiden. + +**Load-Shedding-Hot-Path:** Der Canvas-Hot-Path soll so wenig Convex-Abhängigkeiten wie möglich haben. Direkt reaktiv bleiben nur die Kernmodelle (`nodes.list`, `edges.list`, `canvases.get`). Nebenpfade wie Storage-URL-Auflösung, Adjustment-Presets und Toolbar-Credits sind bewusst entkoppelt oder zusammengefasst. --- @@ -146,7 +149,7 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent | `asset-browser-panel.tsx` | Freepik/Stock-Asset-Browser | | `video-browser-panel.tsx` | Video-Asset-Browser | | `canvas-user-menu.tsx` | User-Avatar und Menü | -| `credit-display.tsx` | Credit-Balance Anzeige in der Toolbar | +| `credit-display.tsx` | Credit-Balance Anzeige in der Toolbar (nur `credits.getBalance`, kein Tier-Badge) | | `export-button.tsx` | Export-Button mit Format-Auswahl | | `connection-banner.tsx` | Offline-Banner bei Convex-Verbindungsverlust | | `custom-connection-line.tsx` | Angepasste temporäre Verbindungslinie | @@ -168,6 +171,7 @@ Im **Light Mode** wird der eigentliche Edge-`stroke` ebenfalls aus dieser Akzent ## Wichtige Gotchas - **`data.url` vs `storageId`:** Node-Komponenten erhalten `data.url` (aufgelöste HTTP-URL), nicht `storageId` direkt. Die URL wird von `convexNodeDocWithMergedStorageUrl` injiziert. Bei neuen Node-Typen mit Bild immer diesen Flow prüfen. +- **Adjustment-Presets:** `curves`, `color-adjust`, `light-adjust` und `detail-adjust` dürfen keine eigene `presets.list`-Query feuern. Immer `CanvasPresetsProvider` + `useCanvasAdjustmentPresets(...)` verwenden. - **Min-Zoom:** `CANVAS_MIN_ZOOM = 0.5 / 3` — dreimal weiter raus als React-Flow-Default. - **Parent-Nodes:** `parentId` zeigt auf einen Group- oder Frame-Node. React Flow erwartet, dass Parent-Nodes vor Child-Nodes in der `nodes`-Array stehen. - **Bridge-Edges:** Beim Löschen eines mittleren Nodes werden Kanten automatisch neu verbunden (`computeBridgeCreatesForDeletedNodes` aus `lib/canvas-utils.ts`). diff --git a/components/canvas/canvas-presets-context.tsx b/components/canvas/canvas-presets-context.tsx new file mode 100644 index 0000000..a246b55 --- /dev/null +++ b/components/canvas/canvas-presets-context.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { createContext, useContext, useMemo, type ReactNode } from "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; + +const CanvasPresetsContext = createContext(null); + +type CanvasPresetsProviderProps = { + enabled?: boolean; + children: ReactNode; +}; + +export function CanvasPresetsProvider({ + enabled = true, + children, +}: CanvasPresetsProviderProps) { + const rawPresets = useAuthQuery(api.presets.list, enabled ? {} : "skip"); + + const presetsByNodeType = useMemo(() => { + const next = new Map(); + + for (const preset of (rawPresets ?? []) as AdjustmentPresetDoc[]) { + const existing = next.get(preset.nodeType); + if (existing) { + existing.push(preset); + } else { + next.set(preset.nodeType, [preset]); + } + } + + return next; + }, [rawPresets]); + + return ( + + {children} + + ); +} + +export function useCanvasAdjustmentPresets( + nodeType: AdjustmentPresetDoc["nodeType"], +): AdjustmentPresetDoc[] { + const context = useContext(CanvasPresetsContext); + if (context === null) { + return []; + } + + return context.get(nodeType) ?? []; +} diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 97a6b45..72827c3 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -72,6 +72,7 @@ import { api } from "@/convex/_generated/api"; import type { Doc, Id } from "@/convex/_generated/dataModel"; import { authClient } from "@/lib/auth-client"; import { + isAdjustmentPresetNodeType, isCanvasNodeType, type CanvasNodeType, } from "@/lib/canvas-node-types"; @@ -95,6 +96,7 @@ import { type ConnectionDropMenuState, } from "@/components/canvas/canvas-connection-drop-menu"; import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; +import { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context"; import { AssetBrowserTargetContext, type AssetBrowserTargetApi, @@ -203,11 +205,6 @@ function summarizeResizePayload(payload: unknown): Record { }; } -function hasStorageId(node: Doc<"nodes">): boolean { - const data = node.data as Record | undefined; - return typeof data?.storageId === "string" && data.storageId.length > 0; -} - function validateCanvasConnection( connection: Connection, nodes: RFNode[], @@ -261,10 +258,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth(); const shouldSkipCanvasQueries = isSessionPending || isAuthLoading || !isAuthenticated; - const convexAuthUserProbe = useQuery( - api.auth.safeGetAuthUser, - shouldSkipCanvasQueries ? "skip" : {}, - ); useEffect(() => { if (process.env.NODE_ENV === "production") return; @@ -282,8 +275,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { convex: { isAuthenticated, shouldSkipCanvasQueries, - probeUserId: convexAuthUserProbe?.userId ?? null, - probeRecordId: convexAuthUserProbe?._id ?? null, }, session: { hasUser: Boolean(session?.user), @@ -292,8 +283,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); }, [ canvasId, - convexAuthUserProbe?._id, - convexAuthUserProbe?.userId, isAuthLoading, isAuthenticated, isSessionPending, @@ -310,20 +299,71 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { api.edges.list, shouldSkipCanvasQueries ? "skip" : { canvasId }, ); - const shouldSkipStorageUrlQuery = useMemo(() => { - if (shouldSkipCanvasQueries) return true; - if (convexNodes === undefined) return true; - return !convexNodes.some(hasStorageId); - }, [convexNodes, shouldSkipCanvasQueries]); - const storageUrlsById = useQuery( - api.storage.batchGetUrlsForCanvas, - shouldSkipStorageUrlQuery ? "skip" : { canvasId }, - ); + const storageIdsForCanvas = useMemo(() => { + if (!convexNodes) { + return [] as Id<"_storage">[]; + } + + return [...new Set( + convexNodes.flatMap((node) => { + const data = node.data as Record | undefined; + return typeof data?.storageId === "string" && data.storageId.length > 0 + ? [data.storageId as Id<"_storage">] + : []; + }), + )].sort(); + }, [convexNodes]); + const storageIdsForCanvasKey = storageIdsForCanvas.join(","); + const stableStorageIdsForCanvasRef = useRef(storageIdsForCanvas); + if (stableStorageIdsForCanvasRef.current.join(",") !== storageIdsForCanvasKey) { + stableStorageIdsForCanvasRef.current = storageIdsForCanvas; + } + const resolveStorageUrlsForCanvas = useMutation(api.storage.batchGetUrlsForCanvas); + const [storageUrlsById, setStorageUrlsById] = useState>(); const canvas = useQuery( api.canvases.get, shouldSkipCanvasQueries ? "skip" : { canvasId }, ); + useEffect(() => { + const requestedStorageIds = stableStorageIdsForCanvasRef.current; + + if (shouldSkipCanvasQueries || requestedStorageIds.length === 0) { + setStorageUrlsById(undefined); + return; + } + + let cancelled = false; + + void resolveStorageUrlsForCanvas({ + canvasId, + storageIds: requestedStorageIds, + }) + .then((result) => { + if (!cancelled) { + setStorageUrlsById(result); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + console.warn("[Canvas] failed to resolve storage URLs", { + canvasId, + storageIdCount: requestedStorageIds.length, + message: error instanceof Error ? error.message : String(error), + }); + } + }); + + return () => { + cancelled = true; + }; + }, [ + canvasId, + resolveStorageUrlsForCanvas, + shouldSkipCanvasQueries, + storageIdsForCanvasKey, + ]); + // ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ── const moveNode = useMutation(api.nodes.move); const resizeNode = useMutation(api.nodes.resize); @@ -571,6 +611,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); + const hasPresetAwareNodes = useMemo( + () => + nodes.some((node) => isAdjustmentPresetNodeType(node.type ?? "")) || + (convexNodes ?? []).some((node) => isAdjustmentPresetNodeType(node.type)), + [convexNodes, nodes], + ); const edgesRef = useRef(edges); edgesRef.current = edges; const [pendingSyncCount, setPendingSyncCount] = useState(0); @@ -2970,24 +3016,25 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { return ( - { - void syncPendingMoveForClientRequest(clientRequestId, realId).catch( - (error: unknown) => { - console.error( - "[Canvas] onCreateNodeSettled syncPendingMove failed", - error, - ); - }, - ); - }} - > - + + { + void syncPendingMoveForClientRequest(clientRequestId, realId).catch( + (error: unknown) => { + console.error( + "[Canvas] onCreateNodeSettled syncPendingMove failed", + error, + ); + }, + ); + }} + > +
-
-
+
+
+
); } diff --git a/components/canvas/credit-display.tsx b/components/canvas/credit-display.tsx index 9f25d97..8f498f9 100644 --- a/components/canvas/credit-display.tsx +++ b/components/canvas/credit-display.tsx @@ -7,22 +7,6 @@ import { api } from "@/convex/_generated/api"; import { Coins } from "lucide-react"; import { toast } from "@/lib/toast"; -const TIER_LABELS: Record = { - free: "Free", - starter: "Starter", - pro: "Pro", - max: "Max", - business: "Business", -}; - -const TIER_COLORS: Record = { - free: "text-muted-foreground", - starter: "text-blue-500", - pro: "text-purple-500", - max: "text-amber-500", - business: "text-amber-500", -}; - const showTestCreditGrant = typeof process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "string" && process.env.NEXT_PUBLIC_ALLOW_TEST_CREDIT_GRANT === "true"; @@ -30,10 +14,9 @@ const showTestCreditGrant = export function CreditDisplay() { const t = useTranslations('toasts'); const balance = useAuthQuery(api.credits.getBalance); - const subscription = useAuthQuery(api.credits.getSubscription); const grantTestCredits = useMutation(api.credits.grantTestCredits); - if (balance === undefined || subscription === undefined) { + if (balance === undefined) { return (
@@ -43,9 +26,6 @@ export function CreditDisplay() { } const available = balance.balance - balance.reserved; - const tier = subscription.tier; - const tierLabel = TIER_LABELS[tier] ?? tier; - const tierColor = TIER_COLORS[tier] ?? "text-muted-foreground"; const isLow = available < 10; const isEmpty = available <= 0; @@ -82,8 +62,6 @@ export function CreditDisplay() { ({balance.reserved} reserved) )} - · - {tierLabel}
{showTestCreditGrant && (