fix(dashboard): TourFitView timeout no longer freezes refit

Codex review on PR #114: when the RAF poll window expires before
highlighted nodes have been measured, the timeout fallback was setting
`fittedKeyRef.current = targetKey`, marking the step as fitted even
though the proper highlight fit never ran. If Stage 2 layout landed
after the 4s cap, the effect early-returned on the next nodes update
because the target key already matched, so the camera stayed pinned to
the fallback layer fit instead of zooming onto the actual highlights.

Fix:

  - Subscribe to React Flow's user-node array via `useNodes()` so the
    effect re-fires when Stage 2 finally produces the highlighted ids
    after the per-step RAF poll has already given up.
  - On timeout, pan into the layer for usability but do NOT set
    `fittedKeyRef`. The next nodes update gets another shot at the
    highlight fit, and on success `fittedKeyRef` records the proper fit.
  - Use a separate `fallbackKeyRef` to ensure the fallback `fitView`
    fires at most once per step — without this, every subsequent nodes
    update during the unready window would trigger a viewport jump.
  - Reset both refs when `tourHighlightedNodeIds` clears (stop tour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-05-04 19:24:09 +08:00
Unverified
parent f58f0edd62
commit 75368dc103
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
ReactFlowProvider,
useNodes,
useNodesState,
useEdgesState,
useReactFlow,
@@ -95,24 +96,29 @@ function TourFitView() {
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const setTourFitPending = useDashboardStore((s) => s.setTourFitPending);
const { fitView, getInternalNode } = useReactFlow();
// Subscribe to React Flow's user-node array so this effect re-fires when
// the node set changes (e.g. Stage 2 finally lands the highlighted ids
// after the per-step RAF window already gave up). The RAF poll inside
// covers the common fast path; the `nodes` dep covers slow Stage 2.
const nodes = useNodes();
const fittedKeyRef = useRef<string>("");
const fallbackKeyRef = useRef<string>("");
useEffect(() => {
const targetKey = tourHighlightedNodeIds.join("\n");
if (targetKey === "") {
fittedKeyRef.current = "";
fallbackKeyRef.current = "";
setTourFitPending(false);
return;
}
if (targetKey === fittedKeyRef.current) return;
// Wait for React Flow to finish Stage 2 layout AND post-mount measure
// before fitting. We poll the internal lookup directly because
// `useNodes()` reflects user-supplied nodes only and doesn't fire on
// measure completion. Once every highlighted id has measured
// dimensions, hand the ids to fitView — React Flow handles the
// child→absolute coordinate transform itself, which is more reliable
// than recomputing bbox manually.
// Poll React Flow's internal lookup directly — `useNodes()` reflects
// user-supplied nodes and may not fire on measure completion. Once
// every highlighted id has measured dimensions, `fitView({ nodes })`
// handles the child→absolute coordinate transform itself, which is
// more reliable than recomputing bbox manually.
const MAX_FRAMES = 240; // ~4s at 60fps
let frame = 0;
let cancelled = false;
@@ -138,6 +144,7 @@ function TourFitView() {
minZoom: 0.4,
});
fittedKeyRef.current = targetKey;
fallbackKeyRef.current = "";
setTourFitPending(false);
return;
}
@@ -145,8 +152,16 @@ function TourFitView() {
rafId = requestAnimationFrame(tick);
return;
}
fitView({ duration: 500, padding: 0.3 });
fittedKeyRef.current = targetKey;
// Highlights still not ready after the poll window. Pan into the
// layer so the user isn't stranded, but DON'T set fittedKeyRef —
// if Stage 2 lands later, a `nodes` change will re-fire this effect
// and we'll get another shot at the proper highlight fit.
// `fallbackKeyRef` prevents the fallback fitView from re-firing on
// every subsequent nodes update for the same step.
if (fallbackKeyRef.current !== targetKey) {
fitView({ duration: 500, padding: 0.3 });
fallbackKeyRef.current = targetKey;
}
setTourFitPending(false);
};
rafId = requestAnimationFrame(tick);
@@ -154,7 +169,7 @@ function TourFitView() {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [tourHighlightedNodeIds, fitView, getInternalNode, setTourFitPending]);
}, [tourHighlightedNodeIds, nodes, fitView, getInternalNode, setTourFitPending]);
return null;
}