From e88ab70251003e4bf81be6d8ded3e6452b3ff2f6 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 21 Apr 2026 21:47:32 -0700 Subject: [PATCH] fix(canvas): stop infinite re-render on ContextMenu mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContextMenu's children selector ran .filter() inside the Zustand hook, returning a brand-new array reference on every render. useSyncExternalStore under the hood compares snapshots with Object.is — a new array always differs, so React kept scheduling re-renders, hit the 50-update depth cap, and crashed with minified error #185. Observed as "Application error: a client-side exception" on every SaaS tenant once a session cookie resolved. Caught in dev mode where the build emits the clear warning: The result of getSnapshot should be cached to avoid an infinite loop at ContextMenu (src/components/ContextMenu.tsx:26:34) Fix: select the stable nodes array once, derive children via useMemo outside the store subscription. Same output, no new reference per render. Manually verified: dev bundle served through a cloudflared tunnel to a live tenant, ContextMenu component mounts cleanly, remaining console errors are all unrelated (localhost API 401s from the dev server pointing at its own origin). Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/ContextMenu.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index 3d869a81..f9010293 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { api } from "@/lib/api"; import { showToast } from "./Toaster"; @@ -23,8 +23,15 @@ export function ContextMenu() { const setPanelTab = useCanvasStore((s) => s.setPanelTab); const nestNode = useCanvasStore((s) => s.nestNode); const contextNodeId = contextMenu?.nodeId ?? null; - const children = useCanvasStore((s) => - contextNodeId ? s.nodes.filter((n) => n.data.parentId === contextNodeId) : [] + // Select the full nodes array (stable reference across unrelated store + // updates) and derive children via useMemo. Filtering inside the + // selector returned a new array every call, which Zustand's + // useSyncExternalStore saw as "snapshot changed" → schedule + // re-render → loop → React error #185. See canvas-store-snapshots. + const nodes = useCanvasStore((s) => s.nodes); + const children = useMemo( + () => (contextNodeId ? nodes.filter((n) => n.data.parentId === contextNodeId) : []), + [nodes, contextNodeId], ); const hasChildren = children.length > 0; const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);