From e900a773ac51fe2aaa3c57602555fc85436412c6 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 21:37:54 -0700 Subject: [PATCH] fix(canvas): keep tracking org bounds during deploy after first fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: org import zoomed to fit the parent + first child, then froze at that framing while the remaining children kept materialising off-screen. The user had to manually pan/zoom to see the new arrivals. Two stacked bugs in useCanvasViewport's deploy-time auto-fit: 1. The user-pan-respect gate stamps userPannedAtRef on EVERY pointerdown that lands inside .react-flow__pane. That fires for ordinary clicks (deselect, click-near-a-card, modal-close-bubble from the import dialog) — not just for actual pan gestures. One accidental pre-import click was enough to lock out every fit for the rest of the deploy. Wheel is the canonical unambiguous pan/zoom signal; drop pointerdown. 2. Even with a real pan during deploy, when more children land the org's bounds grow and the user has lost context — the new arrivals are off-screen and the deploy is the primary thing they want to watch right now. The guard had no growth awareness, so one pan cancelled all follow-up fits unconditionally. Now we track the subtree size at the last fit (per root), and if the current subtree is larger we force the fit through regardless of the user-pan timestamp. When the subtree size hasn't changed (status updates on already-positioned nodes), the user-pan respect still applies — so post-deploy exploration isn't yanked back. The Map keyed by root id supports back-to-back imports of different orgs without one's growth count blocking the other's first fit. 985 canvas tests pass; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/canvas/useCanvasViewport.ts | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/canvas/src/components/canvas/useCanvasViewport.ts b/canvas/src/components/canvas/useCanvasViewport.ts index e20d8187..1a528b89 100644 --- a/canvas/src/components/canvas/useCanvasViewport.ts +++ b/canvas/src/components/canvas/useCanvasViewport.ts @@ -59,9 +59,19 @@ export function useCanvasViewport() { // RF is behind a Suspense boundary) AND keeps clicks on the // toolbar / modals / side panel from stamping user-pan-intent. // Capture phase runs before target-phase `stopPropagation` so a - // handler elsewhere can't swallow the signal. `pointerdown` covers - // mouse + touch + pen on every modern browser — no separate - // `touchstart` listener needed (would double-stamp on mobile). + // handler elsewhere can't swallow the signal. + // + // Wheel only — NOT pointerdown. A pointerdown on the pane fires for + // ordinary clicks (deselect, click-near-a-card, modal-close-bubble) + // as well as the start of a drag-pan. Treating every pointerdown as + // "user wants to override auto-fit" meant a single accidental click + // before/during an org import locked out every subsequent fit, so + // the viewport stuck at whatever the first fit landed on while + // children kept materialising off-screen. Wheel is the canonical + // unambiguous gesture: scroll-to-pan and pinch-zoom both surface as + // wheel events. Drag-pans without an accompanying wheel are rare + // enough that letting them be overridden by a follow-up auto-fit is + // the right tradeoff. useEffect(() => { if (typeof window === "undefined") return; const stamp = (e: Event) => { @@ -70,14 +80,9 @@ export function useCanvasViewport() { userPannedAtRef.current = Date.now(); }; const opts: AddEventListenerOptions = { passive: true, capture: true }; - const targets: Array = ["wheel", "pointerdown"]; - for (const ev of targets) { - document.addEventListener(ev, stamp, opts); - } + document.addEventListener("wheel", stamp, opts); return () => { - for (const ev of targets) { - document.removeEventListener(ev, stamp, opts); - } + document.removeEventListener("wheel", stamp, opts); }; }, []); @@ -189,17 +194,19 @@ export function useCanvasViewport() { // circuits: if the user moved after our last auto-fit, we never // fit again this deploy. const pendingFitRootRef = useRef(null); + // Subtree size at the moment of the last successful auto-fit, keyed + // by root id. When a new event arrives and the subtree has GROWN, + // we re-fit even if the user has panned in the meantime — they've + // lost context, the new arrivals are off-screen, and the org-deploy + // animation is the user's primary reason for being on the canvas + // right now. When the subtree hasn't grown (status update on an + // already-positioned node), the user-pan respect still applies. + const lastFitSubtreeSizeRef = useRef>(new Map()); useEffect(() => { const runFit = () => { const rootCandidate = pendingFitRootRef.current; pendingFitRootRef.current = null; if (!rootCandidate) return; - if ( - userPannedAtRef.current !== null && - userPannedAtRef.current > lastAutoFitAtRef.current - ) { - return; - } const state = useCanvasStore.getState(); // Climb to the true root — the event's rootId is the just- // landed child's direct parent, which may itself be nested. @@ -221,6 +228,21 @@ export function useCanvasViewport() { } } if (subtree.length === 0) return; + + // Growth check: if the subtree hasn't expanded since the last + // fit, fall back to the user-pan respect gate. If it HAS + // grown, force the fit through — the user's previous pan no + // longer reflects what's on screen and the deploy is the + // primary thing they want to watch. + const lastSize = lastFitSubtreeSizeRef.current.get(topId) ?? 0; + const grew = subtree.length > lastSize; + if ( + !grew && + userPannedAtRef.current !== null && + userPannedAtRef.current > lastAutoFitAtRef.current + ) { + return; + } fitView({ nodes: subtree.map((id) => ({ id })), // Short animation — server paces children ~2s apart, so a @@ -244,6 +266,7 @@ export function useCanvasViewport() { minZoom: 0.25, }); lastAutoFitAtRef.current = Date.now(); + lastFitSubtreeSizeRef.current.set(topId, subtree.length); }; const handler = (e: Event) => { const { rootId } = (e as CustomEvent<{ rootId: string }>).detail;