Skip to content

fix(web): anchor the dashboard viewport across graph re-layout - #377

Merged
Jason Robert (jrob5756) merged 2 commits into
mainfrom
feature/375-anchor-viewport-on-expand
Aug 7, 2026
Merged

fix(web): anchor the dashboard viewport across graph re-layout#377
Jason Robert (jrob5756) merged 2 commits into
mainfrom
feature/375-anchor-viewport-on-expand

Conversation

@jrob5756

Copy link
Copy Markdown
Collaborator

Closes#375

The bug

layoutTopLevel normalizes each rebuild's bounding box to origin:

node.position={x: node.position.x-minX,y: node.position.y-minY};

When an expanded container grows, minX/minY shift, so every node in the
graph gets a new position — including nodes nowhere near the one that was
toggled. Meanwhile the camera is never touched: the fitView prop is
initial-render only (I confirmed in the React Flow source that fitViewQueued
is diff-guarded and cleared after the first successful fit), and
FitViewOnContextSwitch fires only on viewPathKey change.

So the entire world slides under a fixed camera. Visually that reads as "the
view reset", and after an expand/collapse round trip you're looking at the right
layout from the wrong place.

The fix

Leave the layout pass alone; compensate the camera instead. After each rebuild:

  1. Pick an anchor node present in both the old and new node arrays.
  2. Compute its canvas-absolute top-left in both layouts. This has to walk
    parentId chains — React Flow stores a nested node's position relative to
    its parent, and inline expansion re-parents nodes, so raw positions aren't
    comparable across a rebuild.
  3. Pan by the negation of the delta, at unchanged zoom, with no animation.
  4. Fall back to doing nothing when the two layouts share no node (first build,
    context switch), where the existing fitView paths still own the camera.

Anchor preference:

  • The container owning the single expansion key that just toggled, so the
    chevron stays under the cursor. Both key namespaces sharing expandedContexts
    are checked (childContextKey for subworkflows/iterations,
    groupExpansionKey for for_each groups), on both sides of the rebuild —
    a group only carries groupExpansionKey once it's expandable.
  • Otherwise the shared node nearest the pane center, tie-broken on node id.

Expand-all toggles many keys at once, so it takes the second branch. There's no
new exclusivity rule and no fitView on expansion — expand-all and N-at-once
keep working exactly as before.

The same compensation steadies the graph when a running workflow's topology
grows (a for_each fanning out, a subworkflow's DAG arriving), which today also
slides the graph under a fixed camera.

Structural note

WorkflowGraph gains a ReactFlowProvider wrapper. There was previously no
provider anywhere in the app
— every useReactFlow() call site was a child of
<ReactFlow> and relied on its implicit provider. The rebuild effect lives
outside <ReactFlow> and so couldn't reach setViewport. Provider and
<ReactFlow> mount together (ResizableLayout swaps the whole graph out for
DialogOverlay), so the hoisted store has the same lifetime as before and the
initial fitView still runs once per mount. The export surface is unchanged.

Changes

  • newweb/frontend/src/lib/graph-anchor.ts — pure: toAbsolutePositions,
    resolveAnchorId, anchoredViewport, nextAnchorHint. No React, no
    @xyflow/react runtime import, same shape as lib/reconnect.ts.
  • newweb/frontend/src/lib/graph-anchor.test.ts — 35 tests.
  • web/frontend/src/components/graph/WorkflowGraph.tsx — provider split plus
    the anchoring effect. No change to graph-layout.ts, the store, node
    components, or the event pipeline.
  • CHANGELOG.md, rebuilt web/static/.

Verification

tsc -b clean; 98/98 frontend tests pass (35 new).

Browser-tested with Playwright against a live dashboard (1600x1000) running a
provider-free workflow with three subworkflow steps:

scenariotoggled node on-screen movement
expand subA0.00 px
collapse subA0.00 px
expand subA, then subB, then subC0.00 px each
  • expand → collapse returns to the byte-identical viewport transform string
  • expand-all → collapse-all returns to the byte-identical transform
  • all three subworkflows expand simultaneously (8 → 23 nodes)
  • F, the Controls buttons (zoom in/out/fit), double-click drill-down and
    breadcrumb-back all still work
  • no new console errors

Known limitation

Expanding three subworkflows individually and then clicking collapse-all
leaves ~19 px of horizontal pan, accumulating per repeat of that specific mixed
cycle. I traced it rather than guessing: three individual expands pin three
different containers, and no node in the collapsed layout carries the summed
delta needed to invert them, so it's structural rather than an anchor-selection
bug. Pinning one canonical node throughout would remove it, at the cost of the
property this exists for. Fit-view clears it, and it takes roughly 35 cycles
before content starts clipping. Documented in the module docstring.

Out of scope

  • Preserving manual node drags across a rebuild. nodesDraggable is on but the
    rebuild replaces the node array wholesale, so a dragged node snaps back to its
    dagre position. Separate fix (position memoization keyed by node id).
  • Animating the re-layout — much larger, and unnecessary once the camera stops
    jumping.

Expanding an inline subworkflow made the whole graph appear to jump.
`layoutTopLevel` normalizes each rebuild's bounding box to origin, so
growing one container shifts minX/minY and therefore every node, while
the camera is never touched — the `fitView` prop is initial-render only.
The world slid under a fixed viewport, which reads as "the view reset".
Compensate the camera instead of changing the layout. After each rebuild,
find a node present in both layouts, compute its canvas-absolute top-left
in each (walking parentId chains, since inline expansion re-parents nodes
and React Flow stores nested positions relative to the parent), and pan by
the negated delta at unchanged zoom. The toggled container is preferred as
the anchor so the chevron stays under the cursor; bulk toggles and live
topology growth fall back to the node nearest the pane center. Verified in
a browser: the toggled node moves 0.00px, and expand/collapse round-trips
to the byte-identical viewport.
`WorkflowGraph` gains a `ReactFlowProvider` wrapper because the rebuild
effect lives outside `<ReactFlow>` and could not otherwise reach
`setViewport`. The export surface is unchanged.
Closes#375
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jrob5756
Jason Robert (jrob5756)force-pushed the feature/375-anchor-viewport-on-expand branch from 6c3d0cd to b073d34CompareAugust 7, 2026 14:45
Blocking issues found by review:
- An instant `setViewport` on rebuild took d3-zoom's non-transition path,
which calls `interrupt()`, so a rebuild landing inside an animated
`fitView` cancelled it mid-flight and left its promise permanently
unsettled. Deep-link centering was the real casualty: it polls up to 40
frames to land on a node, and a running workflow's topology churn fires
exactly the rebuilds that would undo it. `lib/camera-authority.ts` makes
the ownership explicit — every animated fit claims the camera for its
duration and the rebuild effect yields while a claim is live.
- `NaN === 0` is false, so a non-finite delta slipped past the zero-delta
check into d3, producing an invalid CSS transform (graph renders at
identity) that then fed back through `getViewport()` into every later
rebuild. Guarded explicitly, mirroring `graph-layout.ts`.
Tests: the two `nextAnchorHint` "drops the hint" cases never passed a
non-null hint, so removing the context-switch and multi-toggle drops
outright survived the suite. A mutation sweep found 11 non-equivalent
survivors in all — zoom divisor, Euclidean ranking, pane-height guard,
next-only hint match, `anchoredViewport`'s own prev-layout resolution.
All 11 are now killed, and a new fixture pins a node nested inside an
already-expanded container, which is the only case that exercises the
parent-chain fold and the Y axis against real layout output.
Comments: the module claimed inline expansion *re-parents* nodes, but
`for_each` pills do not exist while collapsed and arrive already parented
— the test one line below asserted exactly that. The real invariant is
that a nested node keeps a byte-identical relative `position` while its
container moves. Also corrected: a docstring that contradicted
`nextAnchorHint` on sticky reuse, a cycle-degradation note describing
behaviour the code does not have, and a known-limitation paragraph that
omitted that the residual accumulates.
Also drops the unnecessary index signature on `AnchorNode.data` (which
turns the integration fixtures into a standing compile-time check that
React Flow's `Node` stays assignable), types the hint ref as
`AnchorHintResult` so a future field cannot be silently dropped, merges
`resolveAnchorFrom` into `resolveAnchorId`, and records the design in
AGENTS.md.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jrob5756
Jason Robert (jrob5756) marked this pull request as ready for review August 7, 2026 17:38
@jrob5756
Jason Robert (jrob5756) merged commit cf3e641 into mainAug 7, 2026
10 checks passed
@jrob5756
Jason Robert (jrob5756) deleted the feature/375-anchor-viewport-on-expand branch August 7, 2026 17:39
Jason Robert (jrob5756) pushed a commit that referenced this pull request Aug 7, 2026
Resolves conflicts in two generated artifacts by rebuilding from the
merged source rather than picking a side:
- src/conductor/web/static/** (hashed bundle + index.html)
- src/conductor/web/frontend/tsconfig.tsbuildinfo
All hand-written source auto-merged. #377 added real frontend source
(graph-anchor, camera-authority, use-deep-link) whose 47 tests pass
alongside this branch's 79.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dashboard: anchor the viewport across graph re-layout so expanding a subworkflow doesn't reset the view

1 participant

@jrob5756