From de4cd236d8179a35995338f9d073b89f0a68b75c Mon Sep 17 00:00:00 2001 From: nanda Date: Fri, 17 Apr 2026 09:06:45 -0700 Subject: [PATCH 1/7] feat: add measurement + edge computation --- .../src/components/demos/FlowDemo.tsx | 13 +- packages/kumo/src/components/flow/diagram.tsx | 203 +++++++++++++----- .../src/components/flow/flow-layout.spec.md | 172 +++++++++++++++ .../kumo/src/components/flow/flow-layout.ts | 90 ++++++++ .../kumo/src/components/flow/flow.test.tsx | 137 ++++++++++++ packages/kumo/src/components/flow/node.tsx | 38 ++-- .../kumo/src/components/flow/parallel.tsx | 15 +- 7 files changed, 597 insertions(+), 71 deletions(-) create mode 100644 packages/kumo/src/components/flow/flow-layout.spec.md create mode 100644 packages/kumo/src/components/flow/flow-layout.ts diff --git a/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx b/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx index 0b0cee0761..2e93c0ed1d 100644 --- a/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx @@ -49,7 +49,10 @@ export function FlowParallelDemo() { Start - Branch A + + Branch A1 + Branch A2 + Branch B Branch C @@ -62,7 +65,9 @@ export function FlowParallelDemo() { export function FlowCustomContentDemo() { return ( - } /> + } + /> @@ -133,7 +138,9 @@ export function FlowAnchorDemo() { export function FlowCenteredDemo() { return ( - } /> + } + /> my-worker ({ + nodes: {}, + edges: new Set(), + }); + + console.log(flowState); + + const reportNodeSize = useCallback( + (id: string, width: number, height: number) => { + setFlowState((prev) => { + const existing = prev.nodes[id]; + if (existing?.width === width && existing?.height === height) + return prev; + return { + ...prev, + nodes: { + ...prev.nodes, + [id]: { ...existing, width, height }, + }, + }; + }); + }, + [], + ); + + const reportEdges = useCallback((edges: Set) => { + setFlowState((prev) => { + const merged = new Set([...prev.edges, ...edges]); + return { ...prev, edges: merged }; + }); + }, []); + + const flowStateContextValue = useMemo( + () => ({ reportNodeSize, reportEdges, state: flowState }), + [reportNodeSize, reportEdges, flowState], + ); + useEffect(() => { if (!canvas) return; if (!wrapperRef.current || !contentRef.current) return; @@ -279,57 +317,62 @@ export function FlowDiagram({ ); return ( - - + + - {children} - + + {children} + + + {/* Vertical scrollbar */} + {canScrollY && ( +
+ +
+ )} - {/* Vertical scrollbar */} - {canScrollY && ( -
- -
- )} - - {/* Horizontal scrollbar */} - {canScrollX && ( -
- -
- )} -
-
+ {/* Horizontal scrollbar */} + {canScrollX && ( +
+ +
+ )} + + + ); } @@ -346,13 +389,48 @@ export type RectLike = { height: number; }; -export type NodeData = { - parallel?: boolean; +type NodeDataBase = { disabled?: boolean; start?: RectLike | null; end?: RectLike | null; }; +export type NodeData = + | (NodeDataBase & { kind: "node" }) + | (NodeDataBase & { kind: "parallel"; children: string[] }) + | (NodeDataBase & { kind: "list"; children: string[] }); + +// ============================================================================ +// FlowState +// ============================================================================ + +export type FlowState = { + nodes: { + [id: string]: { + width: number; + height: number; + position?: { x: number; y: number }; + }; + }; + edges: Set; +}; + +type FlowStateContextValue = { + reportNodeSize: (id: string, width: number, height: number) => void; + reportEdges: (edges: Set) => void; + state: FlowState; +}; + +const FlowStateContext = createContext(null); + +export function useFlowStateContext(): FlowStateContextValue { + const context = useContext(FlowStateContext); + if (context === null) { + throw new Error("useFlowStateContext must be used within a FlowDiagram"); + } + return context; +} + export const useNodeGroup = () => useDescendants(); export const useNode = (props: NodeData, id?: string) => @@ -407,6 +485,7 @@ export function FlowNodeList({ children }: { children: ReactNode }) { const descendants = useNodeGroup(); const containerRef = useRef(null); const [connectors, setConnectors] = useState([]); + const { reportEdges } = useFlowStateContext(); const computeConnectors = useCallback(() => { const edges: Connector[] = []; @@ -420,7 +499,11 @@ export function FlowNodeList({ children }: { children: ReactNode }) { const currentNode = nodes[i]; const nextNode = nodes[i + 1]; - if (currentNode.props?.parallel || nextNode.props?.parallel) continue; + if ( + currentNode.props?.kind === "parallel" || + nextNode.props?.kind === "parallel" + ) + continue; const currentRect = getNodeRect(currentNode, { type: "start" }); const nextRect = getNodeRect(nextNode, { type: "end" }); @@ -452,6 +535,14 @@ export function FlowNodeList({ children }: { children: ReactNode }) { computeConnectors(); }, [computeConnectors]); + /** + * Recompute edges whenever the descendants change. This is the measurement + * phase's edge computation — pure, no DOM access. + */ + useLayoutEffect(() => { + reportEdges(computeEdges(descendants.descendants)); + }, [descendants.descendants, reportEdges]); + /** * Recompute on scroll/resize: the container shifts in the viewport without * any ResizeObserver firing, so we must re-read all rects explicitly. @@ -481,12 +572,18 @@ export function FlowNodeList({ children }: { children: ReactNode }) { const nodeProps = useMemo( () => ({ - parallel: false, + kind: "list" as const, + children: descendants.descendants.map((d) => d.id), disabled: false, start: startAnchor, end: endAnchor, }), - [JSON.stringify(startAnchor), JSON.stringify(endAnchor)], + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + JSON.stringify(startAnchor), + JSON.stringify(endAnchor), + JSON.stringify(descendants.descendants.map((d) => d.id)), + ], ); // Register with parent context if we're nested (e.g., inside Flow.Parallel) diff --git a/packages/kumo/src/components/flow/flow-layout.spec.md b/packages/kumo/src/components/flow/flow-layout.spec.md new file mode 100644 index 0000000000..d0febb7cc6 --- /dev/null +++ b/packages/kumo/src/components/flow/flow-layout.spec.md @@ -0,0 +1,172 @@ +# Spec: Flow Manual Layout + +## Problem + +The current `Flow` component relies on the DOM's layout algorithm to position the nodes (and subsequently, the arrows connecting the nodes). While this keeps things simple and intuitive, it leads to two main issues: + +1. **Incorrect arrows** where arrows are drawn to/from stale node positions. +2. **Animations being practically impossible** as layout has to be synchronously calculated _and_ measured on every frame. + +## Solution + +Proposal: **Remove Flow's dependency from the DOM by manually calculating layout for each Flow node**. + +By maunally calculating layout, we get to control exactly where each Flow node should render and when these updates should occur. + +## Implementation + +### Considerations + +- **Width and height of flow nodes are NOT known ahead of time**. Flow nodes can be arbitrarily rendered using the `render` prop, so we cannot make any assumptions on the size of the flow node without measuring the DOM. + + +### Phases + +There are two phases to this implementation: + +1. **Measurement**, where both (a) the width and height of each flow node is recorded, and (b) where links are computed based on component hierarchy. +2. **Layout**, where the positions of each flow node is computed based on the results of (a) and (b) in the measurement phase. +3. **Render**, where the edges between each node is drawn as an SVG path based on the computed coordinates from the layout phase. + +### Measurement + +**Measuring flow nodes** + +Each flow node measures its width and height (via `.getBoundingClientRect()`) on mount and whenever its size changes (detected via `ResizeObserver`). Measurements are recorded at the root `Flow` component. + +**Flow state** + +```tsx +type FlowState = { + nodes: { + [id: string]: { + width: number; + height: number; + position?: { x: number; y: number }; + } + }; + edges: [string, string][]; +} +``` + +- `width` and `height` are computed within a flow node and passed up to the root flow component +- `edges` is an array of `[to, from]` pairs where `to` and `from` are IDs of flow nodes +- `edges` are computed based on the component hierarchy (see next section for details) +- `position` is populated in the layout phase + +**Computing edges** + +Edges are computed based on the React component hierarchy according to the following rules: + +1. Adjacent `Flow.Node`s are connected from the former flow node to the latter. + +```tsx + + A + B + +``` + +``` +Edges: +A -> B +``` + +This applies even if nodes are nested in other elements, as long as they are not nested under other Flow components. + +```tsx + +
+ A +
+ B +
+``` + +``` +Edges: +A -> B +``` + +2. `Flow.Node`s adjacent to a `Flow.Parallel` will be connected to _all_ nodes in the parallel group. + +```tsx + + A + + B1 + B2 + + C + +``` + +``` +Edges: +A -> B1 +A -> B2 +B1 -> C +B2 -> C +``` + +3. Adjacent `Parallel` nodes will _not_ link to one another. + +```tsx + + A + + B1 + B2 + + + C1 + C2 + + D + +``` + +``` +Edges: +A -> B1 +A -> B2 +C1 -> D +C2 -> D +``` + +4. `Flow.Node`s adjacent to a `Flow.List` will be connected to the _first_ and _last_ node in the list group. + +```tsx + + A + + + B1 + B2 + + C1 + + D + +``` + +``` +Edges: +A -> B1 +A -> C1 +B1 -> B2 +B2 -> D +C1 -> D +``` + +**Anchors** + +TBD + +### Layout + +TBD + +### Render + +TBD \ No newline at end of file diff --git a/packages/kumo/src/components/flow/flow-layout.ts b/packages/kumo/src/components/flow/flow-layout.ts new file mode 100644 index 0000000000..0d916feafa --- /dev/null +++ b/packages/kumo/src/components/flow/flow-layout.ts @@ -0,0 +1,90 @@ +import type { DescendantInfo } from "./use-children"; +import type { NodeData } from "./diagram"; + +/** + * Computes edges between flow nodes based on the component hierarchy encoded + * in the flat `descendants` array. + * + * Rules (from spec): + * 1. Adjacent `node` entries are connected directly. + * 2. A `node` adjacent to a `parallel` group connects to all of that group's + * immediate children (first child for incoming, last child for outgoing). + * 3. Adjacent `parallel` groups are NOT connected to one another. + * 4. A `list` group connects externally to its first and last child only. + * + * Edges are returned as a `Set` where each entry is formatted as + * `""` (using an em dash as the separator). + * + * The function is pure — it does not access the DOM and has no side effects. + */ +export function computeEdges( + descendants: DescendantInfo[], +): Set { + const edges = new Set(); + + function addEdge(from: string, to: string) { + edges.add(`${from}—${to}`); + } + + /** + * Returns the IDs that act as "exit points" (outgoing connection targets) + * for a given descendant. For a plain node this is just [id]. For a + * parallel/list group it's the last child of each branch. + */ + function exitIds(d: DescendantInfo): string[] { + if (d.props.kind === "node") return [d.id]; + if (d.props.kind === "parallel") { + // Each child is a branch; last node of each branch is an exit point. + // Since children are ordered, the last child is d.props.children[last]. + // For a parallel group the children are the direct children registered + // under it — each child is itself either a node, list, or nested parallel. + // At this level we simply return all children as exit points because each + // branch ends at its own last node (which it reports as its child ID). + return d.props.children; + } + if (d.props.kind === "list") { + // A list connects externally only via its last child. + const last = d.props.children[d.props.children.length - 1]; + return last ? [last] : []; + } + return []; + } + + /** + * Returns the IDs that act as "entry points" (incoming connection sources) + * for a given descendant. For a plain node this is just [id]. For a + * parallel/list group it's the first child of each branch. + */ + function entryIds(d: DescendantInfo): string[] { + if (d.props.kind === "node") return [d.id]; + if (d.props.kind === "parallel") { + return d.props.children; + } + if (d.props.kind === "list") { + const first = d.props.children[0]; + return first ? [first] : []; + } + return []; + } + + for (let i = 0; i < descendants.length - 1; i++) { + const current = descendants[i]; + const next = descendants[i + 1]; + + // Rule 3: adjacent parallel groups are not connected. + if (current.props.kind === "parallel" && next.props.kind === "parallel") { + continue; + } + + const froms = exitIds(current); + const tos = entryIds(next); + + for (const from of froms) { + for (const to of tos) { + addEdge(from, to); + } + } + } + + return edges; +} diff --git a/packages/kumo/src/components/flow/flow.test.tsx b/packages/kumo/src/components/flow/flow.test.tsx index 2a92132281..6cbf8e467c 100644 --- a/packages/kumo/src/components/flow/flow.test.tsx +++ b/packages/kumo/src/components/flow/flow.test.tsx @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { act, render, screen } from "@testing-library/react"; import { useState, useEffect } from "react"; import { Flow } from "./index"; +import { computeEdges } from "./flow-layout"; +import type { DescendantInfo } from "./use-children"; +import type { NodeData } from "./diagram"; function shouldHaveIndex(element: Element, index: number) { expect(element.getAttribute("data-node-index")).toBe(String(index)); @@ -352,3 +355,137 @@ describe("Flow", () => { shouldHaveIndex(screen.getByText("immediate"), 1); }); }); + +// ============================================================================ +// Helpers for computeEdges unit tests +// ============================================================================ + +function node(id: string): DescendantInfo { + return { id, renderOrder: 0, props: { kind: "node" } }; +} + +function parallel(id: string, children: string[]): DescendantInfo { + return { id, renderOrder: 0, props: { kind: "parallel", children } }; +} + +function list(id: string, children: string[]): DescendantInfo { + return { id, renderOrder: 0, props: { kind: "list", children } }; +} + +describe("computeEdges", () => { + describe("Rule 1: adjacent nodes are connected", () => { + it("connects two sequential nodes", () => { + const edges = computeEdges([node("A"), node("B")]); + expect(edges).toEqual(new Set(["A—B"])); + }); + + it("connects three sequential nodes", () => { + const edges = computeEdges([node("A"), node("B"), node("C")]); + expect(edges).toEqual(new Set(["A—B", "B—C"])); + }); + + it("returns no edges for a single node", () => { + expect(computeEdges([node("A")])).toEqual(new Set()); + }); + + it("returns no edges for an empty list", () => { + expect(computeEdges([])).toEqual(new Set()); + }); + }); + + describe("Rule 2: node adjacent to parallel connects to all branches", () => { + it("connects preceding node to all parallel children", () => { + const edges = computeEdges([ + node("A"), + parallel("P", ["B1", "B2"]), + node("C"), + ]); + expect(edges.has("A—B1")).toBe(true); + expect(edges.has("A—B2")).toBe(true); + }); + + it("connects all parallel children to the following node", () => { + const edges = computeEdges([ + node("A"), + parallel("P", ["B1", "B2"]), + node("C"), + ]); + expect(edges.has("B1—C")).toBe(true); + expect(edges.has("B2—C")).toBe(true); + }); + + it("produces exactly the right edge set for A -> [B1,B2] -> C", () => { + const edges = computeEdges([ + node("A"), + parallel("P", ["B1", "B2"]), + node("C"), + ]); + expect(edges).toEqual(new Set(["A—B1", "A—B2", "B1—C", "B2—C"])); + }); + }); + + describe("Rule 3: adjacent parallel groups are not connected", () => { + it("skips edges between two adjacent parallel groups", () => { + const edges = computeEdges([ + node("A"), + parallel("P1", ["B1", "B2"]), + parallel("P2", ["C1", "C2"]), + node("D"), + ]); + expect(edges.has("B1—C1")).toBe(false); + expect(edges.has("B1—C2")).toBe(false); + expect(edges.has("B2—C1")).toBe(false); + expect(edges.has("B2—C2")).toBe(false); + }); + + it("produces exactly the right edge set for A -> [B1,B2] | [C1,C2] -> D", () => { + const edges = computeEdges([ + node("A"), + parallel("P1", ["B1", "B2"]), + parallel("P2", ["C1", "C2"]), + node("D"), + ]); + expect(edges).toEqual(new Set(["A—B1", "A—B2", "C1—D", "C2—D"])); + }); + }); + + describe("Rule 4: list connects externally via first and last child only", () => { + it("connects preceding node to first list child only", () => { + const edges = computeEdges([ + node("A"), + list("L", ["B1", "B2"]), + node("C"), + ]); + expect(edges.has("A—B1")).toBe(true); + expect(edges.has("A—B2")).toBe(false); + }); + + it("connects last list child to following node only", () => { + const edges = computeEdges([ + node("A"), + list("L", ["B1", "B2"]), + node("C"), + ]); + expect(edges.has("B2—C")).toBe(true); + expect(edges.has("B1—C")).toBe(false); + }); + + it("produces exactly the right edge set for A -> List[B1,B2] -> D with parallel branch", () => { + // Mirrors the spec example: + // + // B1 -> B2 + // C1 + // + // Outer: A -> Parallel([List[B1,B2], C1]) -> D + const edges = computeEdges([ + node("A"), + parallel("P", ["L", "C1"]), + node("D"), + ]); + expect(edges.has("A—L")).toBe(true); + expect(edges.has("A—C1")).toBe(true); + expect(edges.has("L—D")).toBe(true); + expect(edges.has("C1—D")).toBe(true); + }); + }); +}); diff --git a/packages/kumo/src/components/flow/node.tsx b/packages/kumo/src/components/flow/node.tsx index 56836d9486..4622d3a9bf 100644 --- a/packages/kumo/src/components/flow/node.tsx +++ b/packages/kumo/src/components/flow/node.tsx @@ -13,7 +13,12 @@ import { type ReactElement, type ReactNode, } from "react"; -import { useNode, type NodeData, type RectLike } from "./diagram"; +import { + useFlowStateContext, + useNode, + type NodeData, + type RectLike, +} from "./diagram"; import { useDescendantsContext } from "./use-children"; // Utility to merge refs @@ -75,6 +80,24 @@ export const FlowNode = forwardRef( const { measurementEpoch, notifySizeChange } = useDescendantsContext(); + const nodeProps = useMemo( + () => ({ + kind: "node" as const, + disabled, + ...measurements, + }), + [measurements, disabled], + ); + + const { index, id } = useNode(nodeProps, idProp); + + const { reportNodeSize } = useFlowStateContext(); + + // Keep a stable ref to the current id so remeasure doesn't need it as a + // dependency (avoiding re-creating the callback on every render). + const idRef = useRef(id); + idRef.current = id; + const remeasure = useCallback(() => { if (!nodeRef.current) return; @@ -94,18 +117,9 @@ export const FlowNode = forwardRef( if (JSON.stringify(m) === JSON.stringify(newVal)) return m; return newVal; }); - }, []); - const nodeProps = useMemo( - () => ({ - parallel: false, - disabled, - ...measurements, - }), - [measurements, disabled], - ); - - const { index, id } = useNode(nodeProps, idProp); + reportNodeSize(idRef.current, nodeRect.width, nodeRect.height); + }, [reportNodeSize]); /** * Observe the node element for size changes so that connectors update even diff --git a/packages/kumo/src/components/flow/parallel.tsx b/packages/kumo/src/components/flow/parallel.tsx index c96da5c95d..4eb91e2925 100644 --- a/packages/kumo/src/components/flow/parallel.tsx +++ b/packages/kumo/src/components/flow/parallel.tsx @@ -96,9 +96,18 @@ export function FlowParallelNode({ const { index, getPrevious, getNext } = useNode( useMemo( - () => ({ parallel: true, start: startAnchor, end: endAnchor }), + () => ({ + kind: "parallel" as const, + children: descendants.descendants.map((d) => d.id), + start: startAnchor, + end: endAnchor, + }), // eslint-disable-next-line react-hooks/exhaustive-deps - [JSON.stringify(startAnchor), JSON.stringify(endAnchor)], + [ + JSON.stringify(startAnchor), + JSON.stringify(endAnchor), + JSON.stringify(descendants.descendants.map((d) => d.id)), + ], ), ); @@ -399,7 +408,7 @@ export function FlowParallelNode({ }; }, [computeLinks]); - const previousIsParallel = getPrevious()?.props?.parallel === true; + const previousIsParallel = getPrevious()?.props?.kind === "parallel"; return (
Date: Fri, 17 Apr 2026 09:54:28 -0700 Subject: [PATCH 2/7] feat: absolute position nodes --- packages/kumo/src/components/flow/diagram.tsx | 26 ++++++-------- packages/kumo/src/components/flow/node.tsx | 11 ++++-- .../kumo/src/components/flow/parallel.tsx | 34 ++----------------- 3 files changed, 21 insertions(+), 50 deletions(-) diff --git a/packages/kumo/src/components/flow/diagram.tsx b/packages/kumo/src/components/flow/diagram.tsx index 93ea922df8..4f1c21c6d4 100644 --- a/packages/kumo/src/components/flow/diagram.tsx +++ b/packages/kumo/src/components/flow/diagram.tsx @@ -128,8 +128,6 @@ export function FlowDiagram({ edges: new Set(), }); - console.log(flowState); - const reportNodeSize = useCallback( (id: string, width: number, height: number) => { setFlowState((prev) => { @@ -156,7 +154,12 @@ export function FlowDiagram({ }, []); const flowStateContextValue = useMemo( - () => ({ reportNodeSize, reportEdges, state: flowState }), + () => ({ + reportNodeSize, + reportEdges, + state: flowState, + containerRef: contentRef, + }), [reportNodeSize, reportEdges, flowState], ); @@ -419,6 +422,8 @@ type FlowStateContextValue = { reportNodeSize: (id: string, width: number, height: number) => void; reportEdges: (edges: Set) => void; state: FlowState; + /** Ref to the root flow content container. Nodes use this to compute their absolute position. */ + containerRef: React.RefObject; }; const FlowStateContext = createContext(null); @@ -591,19 +596,8 @@ export function FlowNodeList({ children }: { children: ReactNode }) { return ( -
-
    - {children} -
+
+
    {children}
diff --git a/packages/kumo/src/components/flow/node.tsx b/packages/kumo/src/components/flow/node.tsx index 4622d3a9bf..fe9545fa3e 100644 --- a/packages/kumo/src/components/flow/node.tsx +++ b/packages/kumo/src/components/flow/node.tsx @@ -193,7 +193,13 @@ export const FlowNode = forwardRef( "data-node-index": index, "data-node-id": id, "data-testid": renderProps["data-testid"] ?? id, - style: { cursor: "default", ...renderProps.style }, + style: { + position: "absolute", + top: 0, + left: 0, + cursor: "default", + ...renderProps.style, + }, children: renderProps.children ?? children, } as React.HTMLAttributes & { ref: React.Ref }); } else { @@ -201,8 +207,7 @@ export const FlowNode = forwardRef( element = (
  • +
    {links && ( @@ -446,16 +427,7 @@ export function FlowParallelNode({ )}
    -
      +
        {children} From 01d2eee4aeb355b9f0bd00e4882de23a67897faa Mon Sep 17 00:00:00 2001 From: nanda Date: Mon, 20 Apr 2026 11:48:52 -0700 Subject: [PATCH 3/7] refactor(flow): rebuild using manual layout --- .../src/components/demos/FlowDemo.tsx | 22 + .../src/pages/tests/flow.astro | 13 + .../kumo/src/components/flow/connectors.tsx | 52 ++ packages/kumo/src/components/flow/diagram.tsx | 525 ++++++++---------- .../src/components/flow/flow-layout.spec.md | 445 +++++++++++++-- .../kumo/src/components/flow/flow-layout.ts | 244 +++++--- .../kumo/src/components/flow/flow.test.tsx | 138 ++--- packages/kumo/src/components/flow/node.tsx | 191 ++----- .../kumo/src/components/flow/parallel.tsx | 465 +--------------- .../kumo/src/components/flow/use-children.tsx | 180 ++---- 10 files changed, 1090 insertions(+), 1185 deletions(-) diff --git a/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx b/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx index 2e93c0ed1d..91f9fb4112 100644 --- a/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx @@ -310,6 +310,28 @@ export function FlowSequentialParallelDemo() { ); } +/** Flow diagram where a node can be dynamically added and removed */ +export function FlowDynamicNodeDemo() { + const [showMiddle, setShowMiddle] = useState(false); + + return ( +
        + + + Start + {showMiddle && Middle} + End + +
        + ); +} + /** Flow diagram with expandable nodes in a parallel group */ export function FlowExpandableDemo() { return ( diff --git a/packages/kumo-docs-astro/src/pages/tests/flow.astro b/packages/kumo-docs-astro/src/pages/tests/flow.astro index 5165aaf14d..fdda3eaa61 100644 --- a/packages/kumo-docs-astro/src/pages/tests/flow.astro +++ b/packages/kumo-docs-astro/src/pages/tests/flow.astro @@ -13,6 +13,7 @@ import { FlowParallelNestedListDemo, FlowSequentialParallelDemo, FlowExpandableDemo, + FlowDynamicNodeDemo, } from "../../components/demos/FlowDemo"; --- @@ -126,6 +127,18 @@ import {
    +
    +

    + FlowDynamicNodeDemo +

    +

    + Flow diagram with a node that can be added and removed dynamically +

    +
    + +
    +
    +

    FlowExpandableDemo diff --git a/packages/kumo/src/components/flow/connectors.tsx b/packages/kumo/src/components/flow/connectors.tsx index 5c42d84676..5340352283 100644 --- a/packages/kumo/src/components/flow/connectors.tsx +++ b/packages/kumo/src/components/flow/connectors.tsx @@ -1,4 +1,5 @@ import { forwardRef, useId, type ReactNode } from "react"; +import type { Edges, NodePositions, FlowState } from "./flow-layout"; export interface Connector { x1: number; @@ -151,6 +152,57 @@ export function createRoundedPath( return commands.join(" "); } +// ============================================================================= +// FlowConnectors +// ============================================================================= + +type FlowConnectorsProps = { + edges: Edges; + nodePositions: NodePositions; + nodes: FlowState["nodes"]; +}; + +/** + * Draws every edge in the flow using only computed positions and measured + * node sizes — no DOM rect lookups needed. + * + * Each edge connects the right-center of the source node to the left-center + * of the target node. + * + * Intended to be rendered once at the top-level Flow component, absolutely + * positioned to overlay the entire diagram. + */ +export function FlowConnectors({ + edges, + nodePositions, + nodes, +}: FlowConnectorsProps) { + const connectors: Connector[] = []; + + for (const [fromId, toId] of edges) { + const fromPos = nodePositions[fromId]; + const toPos = nodePositions[toId]; + const fromNode = nodes[fromId]; + const toNode = nodes[toId]; + + if (!fromPos || !toPos || !fromNode || !toNode) continue; + + connectors.push({ + // right-center of the source node + x1: fromPos.x + fromNode.width, + y1: fromPos.y + fromNode.height / 2, + // left-center of the target node + x2: toPos.x, + y2: toPos.y + toNode.height / 2, + fromId, + toId, + single: true, + }); + } + + return ; +} + export const Connectors = forwardRef( function Connectors({ connectors, children, ...pathProps }, svgRef) { const id = useId(); diff --git a/packages/kumo/src/components/flow/diagram.tsx b/packages/kumo/src/components/flow/diagram.tsx index 4f1c21c6d4..2872554db4 100644 --- a/packages/kumo/src/components/flow/diagram.tsx +++ b/packages/kumo/src/components/flow/diagram.tsx @@ -4,7 +4,6 @@ import { useContext, useEffect, useId, - useLayoutEffect, useMemo, useRef, useState, @@ -15,11 +14,10 @@ import { useMotionTemplate, useMotionValue, useTransform, - type MotionValue, type PanInfo, } from "motion/react"; import { cn } from "../../utils/cn"; -import { Connectors, type Connector } from "./connectors"; +import { FlowConnectors } from "./connectors"; import { DescendantsProvider, useDescendantIndex, @@ -27,7 +25,15 @@ import { useOptionalDescendantsContext, type DescendantInfo, } from "./use-children"; -import { computeEdges } from "./flow-layout"; +import { + computeEdges, + computePositions, + computeDiagramRect, + type FlowState, + type TreeNode, +} from "./flow-layout"; + +export type { FlowState, TreeNode }; const DEFAULT_PADDING = { y: 64, @@ -41,37 +47,7 @@ function isEventFromNode(target: EventTarget | null): boolean { /** Minimum scrollbar thumb size in percentage to ensure visibility */ const MIN_SCROLLBAR_THUMB_SIZE = 10; -// Vertical orientation is currently a no-op -type Orientation = "horizontal" | "vertical"; -type Align = "start" | "center"; - -interface DiagramContextValue { - orientation: Orientation; - align: Align; - x: MotionValue; - y: MotionValue; - /** Ref to the canvas viewport wrapper element */ - wrapperRef: React.RefObject; -} - -const DiagramContext = createContext(null); - -export function useDiagramContext(): DiagramContextValue { - const context = useContext(DiagramContext); - if (context === null) { - throw new Error("useDiagramContext must be used within a FlowDiagram"); - } - return context; -} - interface FlowDiagramProps { - orientation?: Orientation; - /** - * Controls vertical alignment of nodes in horizontal orientation. - * - `start`: Nodes align to the top (default) - * - `center`: Nodes are vertically centered - */ - align?: Align; /** * Whether to render the pannable canvas wrapper. * - `true`: Renders with pannable canvas, scrollbars, and pan gestures (default) @@ -94,8 +70,6 @@ interface FlowDiagramProps { } export function FlowDiagram({ - orientation = "horizontal", - align = "start", canvas = true, padding: requestedPadding, onOverflowChange, @@ -123,44 +97,93 @@ export function FlowDiagram({ const [isPanning, setIsPanning] = useState(false); const [canPan, setCanPan] = useState(false); - const [flowState, setFlowState] = useState({ - nodes: {}, - edges: new Set(), - }); - - const reportNodeSize = useCallback( - (id: string, width: number, height: number) => { - setFlowState((prev) => { - const existing = prev.nodes[id]; - if (existing?.width === width && existing?.height === height) + const [nodes, setNodes] = useState({}); + const [rootDescendants, setRootDescendants] = useState< + DescendantInfo[] + >([]); + // Maps each list/parallel node's id to its own immediate descendants, + // populated by reportDescendants calls from nested FlowNodeList and + // FlowParallelNode components. + const [childrenByParent, setChildrenByParent] = useState< + Map[]> + >(new Map()); + + const reportNode = useCallback( + ( + id: string, + props: { width: number; height: number; disabled?: boolean }, + ) => { + setNodes((prev) => { + const existing = prev[id]; + if ( + existing?.width === props.width && + existing?.height === props.height && + existing?.disabled === props.disabled + ) return prev; - return { - ...prev, - nodes: { - ...prev.nodes, - [id]: { ...existing, width, height }, - }, - }; + return { ...prev, [id]: props }; }); }, [], ); - const reportEdges = useCallback((edges: Set) => { - setFlowState((prev) => { - const merged = new Set([...prev.edges, ...edges]); - return { ...prev, edges: merged }; + const removeNode = useCallback((id: string) => { + setNodes((prev) => { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; }); }, []); + const reportDescendants = useCallback( + (id: string | null, descendants: DescendantInfo[]) => { + if (id === null) { + setRootDescendants((prev) => { + if (JSON.stringify(prev) === JSON.stringify(descendants)) return prev; + return descendants; + }); + } else { + setChildrenByParent((prev) => { + const existing = prev.get(id); + if (JSON.stringify(existing) === JSON.stringify(descendants)) + return prev; + const next = new Map(prev); + next.set(id, descendants); + return next; + }); + } + }, + [], + ); + + // Derive the tree from root descendants synchronously — never stored in state. + const tree = descendantsToTree(rootDescendants, childrenByParent); + const flowState: FlowState = { nodes, tree }; + + // Derive edges, positions, and diagram size synchronously — never stored in state. + const edges = computeEdges(flowState); + const nodePositions = computePositions(flowState); + const diagramRect = computeDiagramRect(nodePositions, flowState); + const flowStateContextValue = useMemo( () => ({ - reportNodeSize, - reportEdges, - state: flowState, - containerRef: contentRef, + reportNode, + removeNode, + reportDescendants, + nodePositions, + edges, }), - [reportNodeSize, reportEdges, flowState], + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + reportNode, + removeNode, + reportDescendants, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(nodePositions), + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(edges), + ], ); useEffect(() => { @@ -314,116 +337,100 @@ export function FlowDiagram({ const scrollTop = useMotionTemplate`${scrollbarYPercent}%`; const scrollLeft = useMotionTemplate`${scrollbarXPercent}%`; - const contextValue = useMemo( - () => ({ orientation, align, x, y, wrapperRef }), - [orientation, align, x, y], - ); - return ( - + - - {children} - - - {/* Vertical scrollbar */} - {canScrollY && ( -
    - -
    - )} - - {/* Horizontal scrollbar */} - {canScrollX && ( -
    - -
    - )} + {children} +
    + +
    -
    + + {/* Vertical scrollbar */} + {canScrollY && ( +
    + +
    + )} + + {/* Horizontal scrollbar */} + {canScrollX && ( +
    + +
    + )} +
    ); } -// --- - -export type RectLike = { - x: number; - y: number; - top: number; - left: number; - right: number; - bottom: number; - width: number; - height: number; -}; - -type NodeDataBase = { - disabled?: boolean; - start?: RectLike | null; - end?: RectLike | null; -}; - export type NodeData = - | (NodeDataBase & { kind: "node" }) - | (NodeDataBase & { kind: "parallel"; children: string[] }) - | (NodeDataBase & { kind: "list"; children: string[] }); + | { kind: "node"; disabled?: boolean } + | { kind: "parallel"; disabled?: boolean; children: string[] } + | { kind: "list"; disabled?: boolean; children: string[] }; // ============================================================================ -// FlowState +// FlowState context // ============================================================================ -export type FlowState = { - nodes: { - [id: string]: { - width: number; - height: number; - position?: { x: number; y: number }; - }; - }; - edges: Set; -}; - type FlowStateContextValue = { - reportNodeSize: (id: string, width: number, height: number) => void; - reportEdges: (edges: Set) => void; - state: FlowState; - /** Ref to the root flow content container. Nodes use this to compute their absolute position. */ - containerRef: React.RefObject; + reportNode: ( + id: string, + props: { width: number; height: number; disabled?: boolean }, + ) => void; + removeNode: (id: string) => void; + /** + * Report immediate descendants from a list/parallel node. + * Pass `null` as `id` for the root FlowNodeList. + */ + reportDescendants: ( + id: string | null, + descendants: DescendantInfo[], + ) => void; + /** Derived node positions (computed synchronously from FlowState). */ + nodePositions: Record; + /** Derived edges (computed synchronously from FlowState). */ + edges: [string, string][]; }; const FlowStateContext = createContext(null); @@ -449,27 +456,39 @@ export const useOptionalNode = (props: NodeData) => { const parentContext = useOptionalDescendantsContext(); const id = useId(); - // Claim render order during render if we have a parent context const renderOrder = parentContext?.claimRenderOrder(id) ?? -1; - const unregisterRef = useRef<(() => void) | null>(null); + // Keep mutable refs so the mount/unmount effect always has current values. + const registerRef = useRef(parentContext?.register); + registerRef.current = parentContext?.register; + const propsRef = useRef(props); + propsRef.current = props; + const renderOrderRef = useRef(renderOrder); + renderOrderRef.current = renderOrder; + // Mount: register once. Unmount: unregister. useEffect(() => { - if (!parentContext?.register) return; - - const { unregister } = parentContext.register(id, renderOrder, props); - - if (!unregisterRef.current) { - unregisterRef.current = unregister; - } - - return () => { - if (unregisterRef.current) { - unregisterRef.current(); - unregisterRef.current = null; - } - }; - }, [id, renderOrder, props, parentContext?.register]); + if (!registerRef.current) return; + const { unregister } = registerRef.current( + id, + renderOrderRef.current, + propsRef.current, + ); + return unregister; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + // Prop / order updates: keep stored entry fresh without remove→re-add cycle. + // `props` is excluded from deps for the same reason as in useDescendantIndex: + // it is recreated every render (contains `tree` objects), so including it + // causes register() → setRegisteredDescendants() → re-render → infinite loop. + // propsRef.current is updated synchronously each render so the effect always + // uses the latest value. + useEffect(() => { + if (!registerRef.current) return; + registerRef.current(id, renderOrder, propsRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, renderOrder]); if (!parentContext) return null; @@ -477,131 +496,75 @@ export const useOptionalNode = (props: NodeData) => { return { index, id }; }; -export const getNodeRect = ( - node: DescendantInfo | undefined, - { type = "start" }: { type?: "start" | "end" }, -): RectLike | null => { - if (!node) return null; - return node.props[type] ?? null; -}; +/** + * Recursively build a TreeNode from a flat list of registered descendants. + * + * Each descendant only carries `kind` and `children` (IDs of its own + * immediate children). The full tree is reconstructed bottom-up using the + * descendants maps that each list/parallel node maintains locally. + */ +function descendantsToTree( + descendants: DescendantInfo[], + childrenByParent: Map[]> = new Map(), +): TreeNode { + return { + kind: "list", + children: descendants.map((d) => descendantToTreeNode(d, childrenByParent)), + }; +} + +function descendantToTreeNode( + d: DescendantInfo, + childrenByParent: Map[]>, +): TreeNode { + if (d.props.kind === "node") return { kind: "node", id: d.id }; + const ownDescendants = childrenByParent.get(d.id) ?? []; + return { + kind: d.props.kind, + children: ownDescendants.map((child) => + descendantToTreeNode(child, childrenByParent), + ), + }; +} export function FlowNodeList({ children }: { children: ReactNode }) { - const { orientation, align } = useDiagramContext(); const descendants = useNodeGroup(); - const containerRef = useRef(null); - const [connectors, setConnectors] = useState([]); - const { reportEdges } = useFlowStateContext(); - - const computeConnectors = useCallback(() => { - const edges: Connector[] = []; - const nodes = descendants.descendants; - const containerRect = containerRef.current?.getBoundingClientRect(); - - const offsetX = containerRect?.left ?? 0; - const offsetY = containerRect?.top ?? 0; - - for (let i = 0; i < nodes.length - 1; i++) { - const currentNode = nodes[i]; - const nextNode = nodes[i + 1]; - - if ( - currentNode.props?.kind === "parallel" || - nextNode.props?.kind === "parallel" - ) - continue; - - const currentRect = getNodeRect(currentNode, { type: "start" }); - const nextRect = getNodeRect(nextNode, { type: "end" }); - - if (currentRect && nextRect) { - const isDisabled = - currentNode.props.disabled || nextNode.props.disabled; - edges.push({ - x1: currentRect.left - offsetX + currentRect.width, - y1: currentRect.top - offsetY + currentRect.height / 2, - x2: nextRect.left - offsetX, - y2: nextRect.top - offsetY + nextRect.height / 2, - disabled: isDisabled, - single: true, - fromId: currentNode.id, - toId: nextNode.id, - }); - } - } - - setConnectors(edges); - }, [descendants.descendants]); - - /** - * Recompute connectors after layout so that containerRect and node rects are - * read in the same synchronous pass — preventing stale-rect mismatches. - */ - useLayoutEffect(() => { - computeConnectors(); - }, [computeConnectors]); - - /** - * Recompute edges whenever the descendants change. This is the measurement - * phase's edge computation — pure, no DOM access. - */ - useLayoutEffect(() => { - reportEdges(computeEdges(descendants.descendants)); - }, [descendants.descendants, reportEdges]); - - /** - * Recompute on scroll/resize: the container shifts in the viewport without - * any ResizeObserver firing, so we must re-read all rects explicitly. - */ - useEffect(() => { - window.addEventListener("scroll", computeConnectors, { - capture: true, - passive: true, - }); - window.addEventListener("resize", computeConnectors, { passive: true }); - return () => { - window.removeEventListener("scroll", computeConnectors, { - capture: true, - }); - window.removeEventListener("resize", computeConnectors); - }; - }, [computeConnectors]); - - // Get the first and last node's anchor points for parent registration - const firstNode = descendants.descendants[0]; - const lastNode = descendants.descendants[descendants.descendants.length - 1]; - - // Use the first node's "end" anchor as our "end" (incoming connector point) - // Use the last node's "start" anchor as our "start" (outgoing connector point) - const endAnchor = firstNode?.props?.end ?? null; - const startAnchor = lastNode?.props?.start ?? null; + const { reportDescendants } = useFlowStateContext(); + + // Only structural info (kind, id, children) is keyed — not DOM rects — + // to avoid re-computing on every measurement update. + const structuralKey = JSON.stringify( + descendants.descendants.map((d) => ({ + id: d.id, + kind: d.props.kind, + children: d.props.kind !== "node" ? d.props.children : undefined, + })), + ); const nodeProps = useMemo( () => ({ kind: "list" as const, children: descendants.descendants.map((d) => d.id), disabled: false, - start: startAnchor, - end: endAnchor, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [ - JSON.stringify(startAnchor), - JSON.stringify(endAnchor), - JSON.stringify(descendants.descendants.map((d) => d.id)), - ], + [structuralKey], ); - // Register with parent context if we're nested (e.g., inside Flow.Parallel) - useOptionalNode(nodeProps); + // Register with parent context if nested (e.g., inside Flow.Parallel). + // Returns null when this is the root FlowNodeList (no parent context). + const registration = useOptionalNode(nodeProps); + + // Report our immediate descendants upward so FlowDiagram can reconstruct + // the full tree. Root list uses null as id; nested lists use their own id. + useEffect(() => { + reportDescendants(registration?.id ?? null, descendants.descendants); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [structuralKey, reportDescendants, registration?.id]); return ( -
    -
      {children}
    -
    - -
    -
    +
      {children}
    ); } diff --git a/packages/kumo/src/components/flow/flow-layout.spec.md b/packages/kumo/src/components/flow/flow-layout.spec.md index d0febb7cc6..4afbcf0316 100644 --- a/packages/kumo/src/components/flow/flow-layout.spec.md +++ b/packages/kumo/src/components/flow/flow-layout.spec.md @@ -24,7 +24,7 @@ By maunally calculating layout, we get to control exactly where each Flow node s There are two phases to this implementation: -1. **Measurement**, where both (a) the width and height of each flow node is recorded, and (b) where links are computed based on component hierarchy. +1. **Measurement**, where both the width and height of each flow node is recorded and the tree of nodes is derived from the React component structure. 2. **Layout**, where the positions of each flow node is computed based on the results of (a) and (b) in the measurement phase. 3. **Render**, where the edges between each node is drawn as an SVG path based on the computed coordinates from the layout phase. @@ -32,33 +32,33 @@ There are two phases to this implementation: **Measuring flow nodes** -Each flow node measures its width and height (via `.getBoundingClientRect()`) on mount and whenever its size changes (detected via `ResizeObserver`). Measurements are recorded at the root `Flow` component. +Each flow node measures its width and height (via `.getBoundingClientRect()`) on mount and whenever its size changes (detected via `ResizeObserver`). Measurements are stored on the node object, which then gets passed up to the root Flow component. **Flow state** +The root Flow component stores the tree structure of all nodes that it contains, mimicking the React component structure. + ```tsx type FlowState = { nodes: { [id: string]: { width: number; height: number; - position?: { x: number; y: number }; } }; - edges: [string, string][]; + tree: TreeNode; } -``` - -- `width` and `height` are computed within a flow node and passed up to the root flow component -- `edges` is an array of `[to, from]` pairs where `to` and `from` are IDs of flow nodes -- `edges` are computed based on the component hierarchy (see next section for details) -- `position` is populated in the layout phase - -**Computing edges** -Edges are computed based on the React component hierarchy according to the following rules: +type TreeNode = { + kind: "list" | "parallel" + children: TreeNode[] +} | { + kind: "node" + id: string; +} +``` -1. Adjacent `Flow.Node`s are connected from the former flow node to the latter. +**Examples** ```tsx @@ -67,40 +67,177 @@ Edges are computed based on the React component hierarchy according to the follo ``` -``` -Edges: -A -> B +```tsx +Tree: +{ + kind: "list", + children: [ + { + kind: "node", + id: ... // generated ID from the node component + }, + { + kind: "node", + id: ... // generated ID from the node component + } + ] +} ``` -This applies even if nodes are nested in other elements, as long as they are not nested under other Flow components. +--- ```tsx -
    - A -
    - B + A + + B1 + B2 + + C
    ``` -``` -Edges: -A -> B +```tsx +Tree: +{ + kind: "list", + children: [ + { + kind: "node", + id: ... // generated ID from the node component + }, + { + kind: "parallel", + children: [ + { + kind: "node", + id: ... // generated ID from the node component + }, + { + kind: "node", + id: ... // generated ID from the node component + }, + ] + }, + { + kind: "node", + id: ... // generated ID from the node component + } + ] +} ``` -2. `Flow.Node`s adjacent to a `Flow.Parallel` will be connected to _all_ nodes in the parallel group. +--- ```tsx A - B1 - B2 + + B1 + B2 + + C1 - C + D ``` +```tsx +Tree: +{ + kind: "list", + children: [ + { + kind: "node", + id: ... // generated ID from the node component + }, + { + kind: "parallel", + children: [ + { + kind: "list", + children: [ + { + kind: "node", + id: ... // generated ID from the node component + }, + { + kind: "node", + id: ... // generated ID from the node component + }, + ] + }, + { + kind: "node", + id: ... // generated ID from the node component + }, + ] + }, + { + kind: "node", + id: ... // generated ID from the node component + } + ] +} +``` + +### Layout + +In the layout phase, the edges and positions of the nodes are derived from the tree stored in state. **Derived is the keyword here**—neither edges nor node positions should be stored in state: + +```tsx +const [flowState, setFlowState] = useState(...) + +const edges = computeEdges(flowState); +const nodePositions = computePositions(flowState) +``` + +```tsx +type Edges = [string, string][] // [fromId, toId] +type NodePositions = Record // string = ID +``` + +**Computing edges** + +Edges are computed from the stored tree according to the following rules: + +1. Adjacent nodes are connected from the former flow node to the latter. + +```tsx +{ + kind: "list", + children: [ + { kind: "node", id: "A" }, + { kind: "node", id: "B" }, + ] +} +``` + +``` +Edges: +A -> B +``` + +2. Nodes adjacent to a parallel node will be connected to all children of the parallel node. + +```tsx +{ + kind: "list", + children: [ + { kind: "node", id: "A" }, + { + kind: "parallel", + children: [ + { kind: "node", id: "B1" }, + { kind: "node", id: "B2" }, + ] + }, + { kind: "node", id: "C" }, + ] +} +``` + ``` Edges: A -> B1 @@ -109,21 +246,31 @@ B1 -> C B2 -> C ``` -3. Adjacent `Parallel` nodes will _not_ link to one another. +3. Adjacent parallel nodes will _not_ link to one another. + ```tsx - - A - - B1 - B2 - - - C1 - C2 - - D - +{ + kind: "list", + children: [ + { kind: "node", id: "A" }, + { + kind: "parallel", + children: [ + { kind: "node", id: "B1" }, + { kind: "node", id: "B2" }, + ] + }, + { + kind: "parallel", + children: [ + { kind: "node", id: "C1" }, + { kind: "node", id: "C2" }, + ] + }, + { kind: "node", id: "D" }, + ] +} ``` ``` @@ -134,20 +281,29 @@ C1 -> D C2 -> D ``` -4. `Flow.Node`s adjacent to a `Flow.List` will be connected to the _first_ and _last_ node in the list group. +4. Nodes adjacent to a list node will be connected to the _first_ and _last_ node in the list group. ```tsx - - A - - - B1 - B2 - - C1 - - D - +{ + kind: "list", + children: [ + { kind: "node", id: "A" }, + { + kind: "parallel", + children: [ + { + kind: "list", + children: [ + { kind: "node", id: "B1" }, + { kind: "node", id: "B2" }, + ] + }, + { kind: "node", id: "C1" }, + ] + }, + { kind: "node", id: "D" }, + ] +} ``` ``` @@ -159,11 +315,186 @@ B2 -> D C1 -> D ``` -**Anchors** +**Computing positions** -TBD +```tsx +function computePositions( + flowState: FlowState, + { columnGap = 64, rowGap = 48 } = {} +): Record; +``` -### Layout +Node positions are derived by the size of the node and the group (parallel/list) that the node belongs to. + +1. **List** + +Children of list nodes are laid out horizontally, separated by `columnGap` between each node. + +Given: + +```tsx +Nodes: +{ + A: { width: 40, height: 40 }, + B: { width: 60, height: 40 }, + C: { width: 40, height: 40 }, +} + +Tree: +{ + kind: "list", + children: [ + { kind: "node", id: "A" }, + { kind: "node", id: "B" }, + { kind: "node", id: "C" }, + ] +} +``` + +Output: + +```tsx +{ + A: { x: 0, y: 0 }, + B: { x: 40 + , y: 0 }, + C: { x: 40 + + 60 + , y: 0 }, +} +``` + +2. **Parallel** + +Nodes within a parallel node are laid out vertically, separated by `rowGap`. + +Given: + +```tsx +Nodes: +{ + A: { width: 40, height: 40 }, + B1: { width: 60, height: 40 }, + B2: { width: 60, height: 40 }, + C: { width: 40, height: 40 }, +} + +Tree: +{ + kind: "list", + children: [ + { kind: "node", id: "A" }, + { + kind: "parallel", + children: [ + { kind: "node", id: "B1" }, + { kind: "node", id: "B2" }, + ] + }, + { kind: "node", id: "C" }, + ] +} +``` + +Output: + +```tsx +{ + A: { x: 0, y: 0 }, + B1: { x: 40 + , y: 0 }, + B2: { x: 40 + , y: 40 + }, + C: { x: 40 + + 60 + , y: 0 }, +} +``` + +The parallel group should take up the space of the widest child. + +Given: + +```tsx +Nodes: +{ + A: { width: 40, height: 40 }, + B1: { width: 60, height: 40 }, + B2: { width: 100, height: 40 }, + C: { width: 40, height: 40 }, +} + +Tree: +{ + kind: "list", + children: [ + { kind: "node", id: "A" }, + { + kind: "parallel", + children: [ + { kind: "node", id: "B1" }, + { kind: "node", id: "B2" }, + ] + }, + { kind: "node", id: "C" }, + ] +} +``` + +Output: + +```tsx +{ + A: { x: 0, y: 0 }, + B1: { x: 40 + , y: 0 }, + B2: { x: 40 + , y: 40 + }, + C: { x: 40 + + 100 + , y: 0 }, +} +``` + +This is true if the parallel node has child parallel or list nodes too. + +Given: + +```tsx +Nodes: +{ + A: { width: 40, height: 40 }, + B1: { width: 60, height: 40 }, + B2: { width: 100, height: 40 }, + C1: { width: 40, height: 40 }, + D: { width: 40, height: 40 } +} + +Tree: +{ + kind: "list", + children: [ + { kind: "node", id: "A" }, + { + kind: "parallel", + children: [ + { + kind: "list", + children: [ + { kind: "node", id: "B1" }, + { kind: "node", id: "B2" }, + ] + }, + { kind: "node", id: "C1" }, + ] + }, + { kind: "node", id: "D" }, + ] +} +``` + +Output: + +```tsx +{ + A: { x: 0, y: 0 }, + B1: { x: 40 + , y: 0 }, + B2: { x: 40 + + 60 + , y: 0 }, + C1: { x: 40 + , y: 40 + }, + D: { x: 40 + + 60 + + 100 + , y: 0 }, +} +``` + +**Anchors** TBD diff --git a/packages/kumo/src/components/flow/flow-layout.ts b/packages/kumo/src/components/flow/flow-layout.ts index 0d916feafa..69b4534dca 100644 --- a/packages/kumo/src/components/flow/flow-layout.ts +++ b/packages/kumo/src/components/flow/flow-layout.ts @@ -1,90 +1,210 @@ -import type { DescendantInfo } from "./use-children"; -import type { NodeData } from "./diagram"; +// ============================================================================= +// Types +// ============================================================================= + +export type TreeNode = + | { kind: "list" | "parallel"; children: TreeNode[] } + | { kind: "node"; id: string }; + +export type FlowState = { + nodes: { + [id: string]: { + width: number; + height: number; + disabled?: boolean; + }; + }; + tree: TreeNode; +}; + +export type Edges = [string, string][]; +export type NodePositions = Record; +export type DiagramRect = { width: number; height: number }; + +// ============================================================================= +// computeEdges +// ============================================================================= /** - * Computes edges between flow nodes based on the component hierarchy encoded - * in the flat `descendants` array. + * Computes edges between flow nodes from the tree stored in FlowState. * * Rules (from spec): - * 1. Adjacent `node` entries are connected directly. + * 1. Adjacent `node` entries in a list are connected directly. * 2. A `node` adjacent to a `parallel` group connects to all of that group's - * immediate children (first child for incoming, last child for outgoing). + * immediate entry/exit points. * 3. Adjacent `parallel` groups are NOT connected to one another. - * 4. A `list` group connects externally to its first and last child only. - * - * Edges are returned as a `Set` where each entry is formatted as - * `""` (using an em dash as the separator). + * 4. A `list` group connects externally to its first and last node only. * * The function is pure — it does not access the DOM and has no side effects. */ -export function computeEdges( - descendants: DescendantInfo[], -): Set { - const edges = new Set(); +export function computeEdges(flowState: FlowState): Edges { + const edges: Edges = []; + collectEdges(flowState.tree, edges); + return edges; +} - function addEdge(from: string, to: string) { - edges.add(`${from}—${to}`); +/** + * Returns the IDs of "entry points" for a tree node — the first node(s) that + * would receive an incoming edge when something connects into this subtree. + */ +function entryIds(node: TreeNode): string[] { + if (node.kind === "node") return [node.id]; + if (node.kind === "parallel") { + return node.children.flatMap((child) => entryIds(child)); } + // list: only the first child is the entry point + if (node.children.length === 0) return []; + return entryIds(node.children[0]); +} - /** - * Returns the IDs that act as "exit points" (outgoing connection targets) - * for a given descendant. For a plain node this is just [id]. For a - * parallel/list group it's the last child of each branch. - */ - function exitIds(d: DescendantInfo): string[] { - if (d.props.kind === "node") return [d.id]; - if (d.props.kind === "parallel") { - // Each child is a branch; last node of each branch is an exit point. - // Since children are ordered, the last child is d.props.children[last]. - // For a parallel group the children are the direct children registered - // under it — each child is itself either a node, list, or nested parallel. - // At this level we simply return all children as exit points because each - // branch ends at its own last node (which it reports as its child ID). - return d.props.children; +/** + * Returns the IDs of "exit points" for a tree node — the last node(s) that + * would emit an outgoing edge when something connects out of this subtree. + */ +function exitIds(node: TreeNode): string[] { + if (node.kind === "node") return [node.id]; + if (node.kind === "parallel") { + return node.children.flatMap((child) => exitIds(child)); + } + // list: only the last child is the exit point + if (node.children.length === 0) return []; + return exitIds(node.children[node.children.length - 1]); +} + +/** + * Recursively processes a tree node, collecting edges into `edges`. + */ +function collectEdges(node: TreeNode, edges: Edges) { + if (node.kind === "node") return; + + if (node.kind === "parallel") { + for (const child of node.children) { + collectEdges(child, edges); } - if (d.props.kind === "list") { - // A list connects externally only via its last child. - const last = d.props.children[d.props.children.length - 1]; - return last ? [last] : []; + return; + } + + // node.kind === "list": recurse children, then connect adjacent pairs + for (const child of node.children) { + collectEdges(child, edges); + } + + for (let i = 0; i < node.children.length - 1; i++) { + const current = node.children[i]; + const next = node.children[i + 1]; + + // Rule 3: adjacent parallel groups are not connected + if (current.kind === "parallel" && next.kind === "parallel") continue; + + for (const from of exitIds(current)) { + for (const to of entryIds(next)) { + edges.push([from, to]); + } } - return []; } +} + +// ============================================================================= +// computePositions +// ============================================================================= + +/** + * Computes pixel positions for every node in the flow. + * + * - List children are laid out horizontally, separated by `columnGap`. + * - Parallel children are laid out vertically, separated by `rowGap`. + * - A parallel group occupies the width of its widest child branch. + * + * Returns a map of node ID → `{ x, y }` (top-left corner, relative to the + * flow container origin). + * + * This function is pure — it does not access the DOM. + */ +export function computePositions( + flowState: FlowState, + { columnGap = 64, rowGap = 16 } = {}, +): NodePositions { + const positions: NodePositions = {}; /** - * Returns the IDs that act as "entry points" (incoming connection sources) - * for a given descendant. For a plain node this is just [id]. For a - * parallel/list group it's the first child of each branch. + * Recursively lay out a subtree, writing absolute positions into `positions`. + * + * @returns `{ width, height }` — the bounding box of this subtree */ - function entryIds(d: DescendantInfo): string[] { - if (d.props.kind === "node") return [d.id]; - if (d.props.kind === "parallel") { - return d.props.children; - } - if (d.props.kind === "list") { - const first = d.props.children[0]; - return first ? [first] : []; + function layout( + node: TreeNode, + originX: number, + originY: number, + ): { width: number; height: number } { + if (node.kind === "node") { + const measured = flowState.nodes[node.id]; + const w = measured?.width ?? 0; + const h = measured?.height ?? 0; + positions[node.id] = { x: originX, y: originY }; + return { width: w, height: h }; } - return []; - } - for (let i = 0; i < descendants.length - 1; i++) { - const current = descendants[i]; - const next = descendants[i + 1]; + if (node.kind === "list") { + // Place children left-to-right + let cursorX = originX; + let totalHeight = 0; - // Rule 3: adjacent parallel groups are not connected. - if (current.props.kind === "parallel" && next.props.kind === "parallel") { - continue; + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const { width, height } = layout(child, cursorX, originY); + cursorX += width; + if (i < node.children.length - 1) cursorX += columnGap; + totalHeight = Math.max(totalHeight, height); + } + + return { width: cursorX - originX, height: totalHeight }; } - const froms = exitIds(current); - const tos = entryIds(next); + // node.kind === "parallel": place children top-to-bottom + let cursorY = originY; + let maxWidth = 0; - for (const from of froms) { - for (const to of tos) { - addEdge(from, to); - } + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const { width, height } = layout(child, originX, cursorY); + maxWidth = Math.max(maxWidth, width); + cursorY += height; + if (i < node.children.length - 1) cursorY += rowGap; } + + return { width: maxWidth, height: cursorY - originY }; } - return edges; + layout(flowState.tree, 0, 0); + + return positions; +} + +// ============================================================================= +// computeDiagramRect +// ============================================================================= + +/** + * Returns the bounding rectangle of the entire diagram. + * + * - `width` = x of the rightmost node's left edge + that node's width + * - `height` = y of the bottommost node's top edge + that node's height + * + * This function is pure — it does not access the DOM. + */ +export function computeDiagramRect( + positions: NodePositions, + flowState: FlowState, +): DiagramRect { + let width = 0; + let height = 0; + + for (const [id, pos] of Object.entries(positions)) { + const node = flowState.nodes[id]; + if (!node) continue; + width = Math.max(width, pos.x + node.width); + height = Math.max(height, pos.y + node.height); + } + + return { width, height }; } diff --git a/packages/kumo/src/components/flow/flow.test.tsx b/packages/kumo/src/components/flow/flow.test.tsx index 6cbf8e467c..4d5d72f949 100644 --- a/packages/kumo/src/components/flow/flow.test.tsx +++ b/packages/kumo/src/components/flow/flow.test.tsx @@ -3,8 +3,7 @@ import { act, render, screen } from "@testing-library/react"; import { useState, useEffect } from "react"; import { Flow } from "./index"; import { computeEdges } from "./flow-layout"; -import type { DescendantInfo } from "./use-children"; -import type { NodeData } from "./diagram"; +import type { FlowState, TreeNode } from "./flow-layout"; function shouldHaveIndex(element: Element, index: number) { expect(element.getAttribute("data-node-index")).toBe(String(index)); @@ -360,78 +359,91 @@ describe("Flow", () => { // Helpers for computeEdges unit tests // ============================================================================ -function node(id: string): DescendantInfo { - return { id, renderOrder: 0, props: { kind: "node" } }; +function makeState(tree: TreeNode): FlowState { + return { nodes: {}, tree }; } -function parallel(id: string, children: string[]): DescendantInfo { - return { id, renderOrder: 0, props: { kind: "parallel", children } }; +function node(id: string): TreeNode { + return { kind: "node", id }; } -function list(id: string, children: string[]): DescendantInfo { - return { id, renderOrder: 0, props: { kind: "list", children } }; +function parallel(children: TreeNode[]): TreeNode { + return { kind: "parallel", children }; +} + +function list(children: TreeNode[]): TreeNode { + return { kind: "list", children }; +} + +/** Returns edges as a Set of "from—to" strings for easy assertion. */ +function edgeSet(state: FlowState): Set { + return new Set(computeEdges(state).map(([from, to]) => `${from}—${to}`)); } describe("computeEdges", () => { describe("Rule 1: adjacent nodes are connected", () => { it("connects two sequential nodes", () => { - const edges = computeEdges([node("A"), node("B")]); + const edges = edgeSet(makeState(list([node("A"), node("B")]))); expect(edges).toEqual(new Set(["A—B"])); }); it("connects three sequential nodes", () => { - const edges = computeEdges([node("A"), node("B"), node("C")]); + const edges = edgeSet(makeState(list([node("A"), node("B"), node("C")]))); expect(edges).toEqual(new Set(["A—B", "B—C"])); }); it("returns no edges for a single node", () => { - expect(computeEdges([node("A")])).toEqual(new Set()); + expect(edgeSet(makeState(list([node("A")])))).toEqual(new Set()); }); it("returns no edges for an empty list", () => { - expect(computeEdges([])).toEqual(new Set()); + expect(edgeSet(makeState(list([])))).toEqual(new Set()); }); }); describe("Rule 2: node adjacent to parallel connects to all branches", () => { it("connects preceding node to all parallel children", () => { - const edges = computeEdges([ - node("A"), - parallel("P", ["B1", "B2"]), - node("C"), - ]); + const edges = edgeSet( + makeState( + list([node("A"), parallel([node("B1"), node("B2")]), node("C")]), + ), + ); expect(edges.has("A—B1")).toBe(true); expect(edges.has("A—B2")).toBe(true); }); it("connects all parallel children to the following node", () => { - const edges = computeEdges([ - node("A"), - parallel("P", ["B1", "B2"]), - node("C"), - ]); + const edges = edgeSet( + makeState( + list([node("A"), parallel([node("B1"), node("B2")]), node("C")]), + ), + ); expect(edges.has("B1—C")).toBe(true); expect(edges.has("B2—C")).toBe(true); }); it("produces exactly the right edge set for A -> [B1,B2] -> C", () => { - const edges = computeEdges([ - node("A"), - parallel("P", ["B1", "B2"]), - node("C"), - ]); + const edges = edgeSet( + makeState( + list([node("A"), parallel([node("B1"), node("B2")]), node("C")]), + ), + ); expect(edges).toEqual(new Set(["A—B1", "A—B2", "B1—C", "B2—C"])); }); }); describe("Rule 3: adjacent parallel groups are not connected", () => { it("skips edges between two adjacent parallel groups", () => { - const edges = computeEdges([ - node("A"), - parallel("P1", ["B1", "B2"]), - parallel("P2", ["C1", "C2"]), - node("D"), - ]); + const edges = edgeSet( + makeState( + list([ + node("A"), + parallel([node("B1"), node("B2")]), + parallel([node("C1"), node("C2")]), + node("D"), + ]), + ), + ); expect(edges.has("B1—C1")).toBe(false); expect(edges.has("B1—C2")).toBe(false); expect(edges.has("B2—C1")).toBe(false); @@ -439,53 +451,49 @@ describe("computeEdges", () => { }); it("produces exactly the right edge set for A -> [B1,B2] | [C1,C2] -> D", () => { - const edges = computeEdges([ - node("A"), - parallel("P1", ["B1", "B2"]), - parallel("P2", ["C1", "C2"]), - node("D"), - ]); + const edges = edgeSet( + makeState( + list([ + node("A"), + parallel([node("B1"), node("B2")]), + parallel([node("C1"), node("C2")]), + node("D"), + ]), + ), + ); expect(edges).toEqual(new Set(["A—B1", "A—B2", "C1—D", "C2—D"])); }); }); describe("Rule 4: list connects externally via first and last child only", () => { it("connects preceding node to first list child only", () => { - const edges = computeEdges([ - node("A"), - list("L", ["B1", "B2"]), - node("C"), - ]); + const edges = edgeSet( + makeState(list([node("A"), list([node("B1"), node("B2")]), node("C")])), + ); expect(edges.has("A—B1")).toBe(true); expect(edges.has("A—B2")).toBe(false); }); it("connects last list child to following node only", () => { - const edges = computeEdges([ - node("A"), - list("L", ["B1", "B2"]), - node("C"), - ]); + const edges = edgeSet( + makeState(list([node("A"), list([node("B1"), node("B2")]), node("C")])), + ); expect(edges.has("B2—C")).toBe(true); expect(edges.has("B1—C")).toBe(false); }); - it("produces exactly the right edge set for A -> List[B1,B2] -> D with parallel branch", () => { - // Mirrors the spec example: - // - // B1 -> B2 - // C1 - // - // Outer: A -> Parallel([List[B1,B2], C1]) -> D - const edges = computeEdges([ - node("A"), - parallel("P", ["L", "C1"]), - node("D"), - ]); - expect(edges.has("A—L")).toBe(true); - expect(edges.has("A—C1")).toBe(true); - expect(edges.has("L—D")).toBe(true); - expect(edges.has("C1—D")).toBe(true); + it("produces exactly the right edge set for spec example 4", () => { + // A -> Parallel([List[B1,B2], C1]) -> D + const edges = edgeSet( + makeState( + list([ + node("A"), + parallel([list([node("B1"), node("B2")]), node("C1")]), + node("D"), + ]), + ), + ); + expect(edges).toEqual(new Set(["A—B1", "A—C1", "B1—B2", "B2—D", "C1—D"])); }); }); }); diff --git a/packages/kumo/src/components/flow/node.tsx b/packages/kumo/src/components/flow/node.tsx index fe9545fa3e..6e8e2d34f6 100644 --- a/packages/kumo/src/components/flow/node.tsx +++ b/packages/kumo/src/components/flow/node.tsx @@ -5,21 +5,13 @@ import { isValidElement, useCallback, useContext, - useEffect, useLayoutEffect, useMemo, useRef, - useState, type ReactElement, type ReactNode, } from "react"; -import { - useFlowStateContext, - useNode, - type NodeData, - type RectLike, -} from "./diagram"; -import { useDescendantsContext } from "./use-children"; +import { useFlowStateContext, useNode, type NodeData } from "./diagram"; // Utility to merge refs function mergeRefs( @@ -70,119 +62,37 @@ export type FlowNodeProps = { export const FlowNode = forwardRef( function FlowNode({ id: idProp, render, children, disabled = false }, ref) { const nodeRef = useRef(null); - const startAnchorRef = useRef(null); - const endAnchorRef = useRef(null); - const [measurements, setMeasurements] = useState<{ - start: RectLike | null; - end: RectLike | null; - }>({ start: null, end: null }); - - const { measurementEpoch, notifySizeChange } = - useDescendantsContext(); - - const nodeProps = useMemo( - () => ({ - kind: "node" as const, - disabled, - ...measurements, - }), - [measurements, disabled], - ); + const nodeProps = useMemo((): NodeData => ({ kind: "node" }), []); const { index, id } = useNode(nodeProps, idProp); + const { reportNode, removeNode, nodePositions } = useFlowStateContext(); - const { reportNodeSize } = useFlowStateContext(); - - // Keep a stable ref to the current id so remeasure doesn't need it as a - // dependency (avoiding re-creating the callback on every render). - const idRef = useRef(id); - idRef.current = id; - - const remeasure = useCallback(() => { + const reportSize = useCallback(() => { if (!nodeRef.current) return; + const { width, height } = nodeRef.current.getBoundingClientRect(); + reportNode(id, { width, height, disabled }); + }, [reportNode, id, disabled]); - const nodeRect = nodeRef.current.getBoundingClientRect(); - let startRect: RectLike = nodeRect; - let endRect: RectLike = nodeRect; - - if (startAnchorRef.current) { - startRect = startAnchorRef.current.getBoundingClientRect(); - } - if (endAnchorRef.current) { - endRect = endAnchorRef.current.getBoundingClientRect(); - } - - setMeasurements((m) => { - const newVal = { start: startRect, end: endRect }; - if (JSON.stringify(m) === JSON.stringify(newVal)) return m; - return newVal; - }); - - reportNodeSize(idRef.current, nodeRect.width, nodeRect.height); - }, [reportNodeSize]); - - /** - * Observe the node element for size changes so that connectors update even - * when FlowNode itself does not re-render (e.g. an expandable render-prop - * child toggling its own state). - * - * When this node resizes, we also notify siblings via `notifySizeChange` - * so they remeasure their (potentially shifted) positions. - */ useLayoutEffect(() => { if (!nodeRef.current) return; - - const onResize = () => { - remeasure(); - notifySizeChange(); - }; - - const observer = new ResizeObserver(onResize); + const observer = new ResizeObserver(reportSize); observer.observe(nodeRef.current); - return () => observer.disconnect(); - }, [remeasure, notifySizeChange]); - - /** - * Remeasure when siblings change (enter/exit/resize). The epoch counter - * increments on every registration change and every size-change - * notification, so this effect picks up cases (2) and (3) from the spec. - */ - useLayoutEffect(() => { - remeasure(); - }, [measurementEpoch, remeasure]); - - /** - * When the page scrolls or the window resizes, the node's viewport position - * changes without ResizeObserver firing. Re-running remeasure here ensures - * the stored rect is always fresh before connectors are recomputed. - * - * This is needed at the node level (rather than relying on the epoch chain - * from FlowNodeList) because nodes inside FlowParallel belong to a nested - * DescendantsProvider — their measurementEpoch is independent of the - * top-level one, so they would not be reached by a single scroll listener - * placed only at the FlowNodeList level. - */ - useEffect(() => { - const onLayoutShift = () => { - remeasure(); - notifySizeChange(); - }; - window.addEventListener("scroll", onLayoutShift, { - capture: true, - passive: true, - }); - window.addEventListener("resize", onLayoutShift, { passive: true }); + reportSize(); return () => { - window.removeEventListener("scroll", onLayoutShift, { capture: true }); - window.removeEventListener("resize", onLayoutShift); + observer.disconnect(); + removeNode(id); }; - }, [remeasure, notifySizeChange]); + }, [reportSize, removeNode, id]); + const position = nodePositions[id]; const mergedRef = mergeRefs(ref, nodeRef); + const positionStyle: React.CSSProperties = position + ? { position: "absolute", top: position.y, left: position.x } + : { opacity: 0 }; + let element: ReactElement; if (render && isValidElement(render)) { - // When render prop is provided, clone it with ref and data attributes const renderProps = render.props as { children?: ReactNode; style?: React.CSSProperties; @@ -193,24 +103,24 @@ export const FlowNode = forwardRef( "data-node-index": index, "data-node-id": id, "data-testid": renderProps["data-testid"] ?? id, + "aria-hidden": position ? undefined : true, style: { - position: "absolute", - top: 0, - left: 0, + ...positionStyle, cursor: "default", ...renderProps.style, }, children: renderProps.children ?? children, } as React.HTMLAttributes & { ref: React.Ref }); } else { - // Default element element = (
  • {children}
  • @@ -218,19 +128,7 @@ export const FlowNode = forwardRef( } return ( - ({ - registerStartAnchor: (anchorRef) => { - startAnchorRef.current = anchorRef; - }, - registerEndAnchor: (anchorRef) => { - endAnchorRef.current = anchorRef; - }, - }), - [], - )} - > + {element} ); @@ -239,6 +137,10 @@ export const FlowNode = forwardRef( FlowNode.displayName = "Flow.Node"; +// ============================================================================= +// FlowAnchor +// ============================================================================= + type FlowNodeAnchorContextType = { registerStartAnchor: (ref: HTMLElement | null) => void; registerEndAnchor: (ref: HTMLElement | null) => void; @@ -248,6 +150,12 @@ const FlowNodeAnchorContext = createContext( null, ); +// Stable no-op context value — anchors are not used for layout yet (spec: TBD) +const NOOP_ANCHOR_CONTEXT: FlowNodeAnchorContextType = { + registerStartAnchor: () => {}, + registerEndAnchor: () => {}, +}; + /** * FlowAnchor component props. * @@ -277,49 +185,22 @@ export type FlowAnchorProps = { }; export const FlowAnchor = forwardRef( - function FlowAnchor({ type, render, children }, ref) { + function FlowAnchor({ render, children }, ref) { const context = useContext(FlowNodeAnchorContext); - const anchorRef = useRef(null); if (!context) { throw new Error("Flow.Anchor must be used within Flow.Node"); } - useEffect(() => { - if (!anchorRef.current) { - return; - } - - if (type === "start" || type === undefined) { - context.registerStartAnchor(anchorRef.current); - } - if (type === "end" || type === undefined) { - context.registerEndAnchor(anchorRef.current); - } - - return () => { - if (type === "start" || type === undefined) { - context.registerStartAnchor(null); - } - if (type === "end" || type === undefined) { - context.registerEndAnchor(null); - } - }; - }, [type, context.registerStartAnchor, context.registerEndAnchor]); - - const mergedRef = mergeRefs(ref, anchorRef); - if (render && isValidElement(render)) { - // When render prop is provided, clone it with ref const renderProps = render.props as { children?: ReactNode }; return cloneElement(render, { - ref: mergedRef, + ref, children: renderProps.children ?? children, } as React.HTMLAttributes & { ref: React.Ref }); } - // Default element - return
    {children}
    ; + return
    }>{children}
    ; }, ); diff --git a/packages/kumo/src/components/flow/parallel.tsx b/packages/kumo/src/components/flow/parallel.tsx index 783a5448cb..51e0d1545a 100644 --- a/packages/kumo/src/components/flow/parallel.tsx +++ b/packages/kumo/src/components/flow/parallel.tsx @@ -1,451 +1,46 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; -import { cn } from "../../utils/cn"; -import { Connectors, type Connector } from "./connectors"; -import { - getNodeRect, - useDiagramContext, - useNode, - useNodeGroup, - type NodeData, - type RectLike, -} from "./diagram"; -import { DescendantsProvider, useDescendantsContext } from "./use-children"; - -function getStartAndEndPoints({ - container, - previous, - next, - orientation, -}: { - container: RectLike; - previous: RectLike | null; - next: RectLike | null; - orientation: "vertical" | "horizontal"; -}): { - start: { x: number; y: number }; - end: { x: number; y: number }; -} { - if (orientation === "vertical") { - // we ignore previous/next calculations for vertical orientations for now - return { - start: { - x: container.width / 2, - y: 0, - }, - end: { - x: container.width / 2, - y: container.height, - }, - }; - } - // Default to midpoints - let start = { - x: 0, - y: container.height / 2, - }; - let end = { - x: container.width, - y: container.height / 2, - }; - if (previous) { - start.y = previous.top - container.top + previous.height / 2; - } - if (next) { - end.y = next.top - container.top + next.height / 2; - } - return { start, end }; -} +import { useEffect, useMemo, type ReactNode } from "react"; +import { useNode, useNodeGroup, useFlowStateContext } from "./diagram"; +import { DescendantsProvider } from "./use-children"; type FlowParallelNodeProps = { children: ReactNode; - /** - * Controls alignment of nodes within the parallel group. - * - `start`: Nodes align to the left (default) - * - `end`: Nodes align to the right - */ - align?: "start" | "end"; }; export function FlowParallelNode({ children }: FlowParallelNodeProps) { - const { orientation } = useDiagramContext(); const descendants = useNodeGroup(); - - const { measurementEpoch, notifySizeChange } = - useDescendantsContext(); - - const containerRef = useRef(null); - const contentRef = useRef(null); - const [measurements, setMeasurements] = useState(null); - - // Use the first branch's anchors (similar to FlowNodeList in diagram.tsx) - // so that incoming/outgoing connectors align with the first branch. - const firstBranch = descendants.descendants[0]; - const endAnchor = firstBranch?.props?.end ?? measurements; - const startAnchor = firstBranch?.props?.start ?? measurements; - - const { index, getPrevious, getNext } = useNode( - useMemo( - () => ({ - kind: "parallel" as const, - children: descendants.descendants.map((d) => d.id), - start: startAnchor, - end: endAnchor, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - JSON.stringify(startAnchor), - JSON.stringify(endAnchor), - JSON.stringify(descendants.descendants.map((d) => d.id)), - ], - ), + const { reportDescendants } = useFlowStateContext(); + + // Only structural info (kind, id, children) is keyed — not DOM rects — + // to avoid re-computing on every measurement update. + const structuralKey = JSON.stringify( + descendants.descendants.map((d) => ({ + id: d.id, + kind: d.props.kind, + children: d.props.kind !== "node" ? d.props.children : undefined, + })), ); - const remeasure = useCallback(() => { - if (!contentRef.current) return; - const rect = contentRef.current.getBoundingClientRect(); - setMeasurements((m) => { - if (JSON.stringify(m) === JSON.stringify(rect)) return m; - return rect; - }); - }, []); - - /** - * Observe the content element for size changes so that connectors update even - * when child nodes resize without triggering a FlowParallelNode re-render. - * - * When this parallel group resizes, notify siblings so they remeasure their - * (potentially shifted) positions. - */ - useLayoutEffect(() => { - if (!contentRef.current) return; - - const onResize = () => { - remeasure(); - notifySizeChange(); - }; - - const observer = new ResizeObserver(onResize); - observer.observe(contentRef.current); - return () => observer.disconnect(); - }, [remeasure, notifySizeChange]); - - /** - * Remeasure when siblings change (enter/exit/resize). Picks up cases (2) - * and (3) from the spec. - */ - useLayoutEffect(() => { - remeasure(); - }, [measurementEpoch, remeasure]); - - /** - * Re-measure the parallel group's own bounding rect on scroll/resize so - * that the rect passed up to the parent context stays current. - */ - useEffect(() => { - const onLayoutShift = () => { - remeasure(); - notifySizeChange(); - }; - window.addEventListener("scroll", onLayoutShift, { - capture: true, - passive: true, - }); - window.addEventListener("resize", onLayoutShift, { passive: true }); - return () => { - window.removeEventListener("scroll", onLayoutShift, { capture: true }); - window.removeEventListener("resize", onLayoutShift); - }; - }, [remeasure, notifySizeChange]); - - type LinksResult = { - connectors: Connector[]; - junctions: { - start?: { x: number; y: number }; - end?: { x: number; y: number }; - }; - containerRect: DOMRect; - }; - - const [links, setLinks] = useState(null); - - /** - * Compute connector positions after the DOM has settled. Running this in - * useLayoutEffect (rather than during render) ensures that both the - * container rect and the node rects stored in `descendants` are read from - * the same, up-to-date layout — preventing stale-coordinate mismatches - * when the page scrolls or a sidebar shifts the layout. - */ - const computeLinks = useCallback(() => { - const container = containerRef.current; - if (!container) return; - - const containerRect = container.getBoundingClientRect(); - - const [prevNode, nextNode] = [getPrevious(), getNext()]; - const previousNodeRect = getNodeRect(prevNode, { type: "start" }); - const nextNodeRect = getNodeRect(nextNode, { type: "end" }); - - const { start, end } = getStartAndEndPoints({ - container: containerRect, - previous: previousNodeRect, - next: nextNodeRect, - orientation, - }); - - // First pass: collect all branch points to determine directions - const incomingBranchPoints: { y: number }[] = []; - const outgoingBranchPoints: { y: number }[] = []; - - for (const descendant of descendants.descendants) { - const { props } = descendant; - const [endAnchorRect, startAnchorRect] = [props.end, props.start]; - - if (previousNodeRect && endAnchorRect) { - const anchorCenter = - orientation === "horizontal" - ? endAnchorRect.top - containerRect.top + endAnchorRect.height / 2 - : endAnchorRect.left - containerRect.left + endAnchorRect.width / 2; - incomingBranchPoints.push({ y: anchorCenter }); - } - - if (nextNodeRect && startAnchorRect) { - const anchorCenter = - orientation === "horizontal" - ? startAnchorRect.top - - containerRect.top + - startAnchorRect.height / 2 - : startAnchorRect.left - - containerRect.left + - startAnchorRect.width / 2; - outgoingBranchPoints.push({ y: anchorCenter }); - } - } - - // Determine if we need junctions based on branch directions - // A junction is needed if connections branch in different directions - // (including inline vs above/below) - const FLAT_THRESHOLD = 2; - - const hasIncomingJunction = (() => { - if (incomingBranchPoints.length <= 1) return false; - const hasAbove = incomingBranchPoints.some( - (p) => p.y < start.y - FLAT_THRESHOLD, - ); - const hasBelow = incomingBranchPoints.some( - (p) => p.y > start.y + FLAT_THRESHOLD, - ); - const hasInline = incomingBranchPoints.some( - (p) => Math.abs(p.y - start.y) <= FLAT_THRESHOLD, - ); - // Junction needed if connections go in different directions - const directions = [hasAbove, hasBelow, hasInline].filter(Boolean).length; - return directions > 1; - })(); - - const hasOutgoingJunction = (() => { - if (outgoingBranchPoints.length <= 1) return false; - const hasAbove = outgoingBranchPoints.some( - (p) => p.y < end.y - FLAT_THRESHOLD, - ); - const hasBelow = outgoingBranchPoints.some( - (p) => p.y > end.y + FLAT_THRESHOLD, - ); - const hasInline = outgoingBranchPoints.some( - (p) => Math.abs(p.y - end.y) <= FLAT_THRESHOLD, - ); - // Junction needed if connections go in different directions - const directions = [hasAbove, hasBelow, hasInline].filter(Boolean).length; - return directions > 1; - })(); - - // Second pass: create connectors with single prop based on junction presence - const newConnectors = descendants.descendants.flatMap((descendant) => { - const { props } = descendant; - const connectors: Connector[] = []; - - const [endAnchorRect, startAnchorRect] = [props.end, props.start]; - const isDescendantDisabled = props.disabled; - - if (previousNodeRect && endAnchorRect) { - let branchStart: { x: number; y: number }; - switch (orientation) { - case "vertical": { - const anchorCenter = - endAnchorRect.left - containerRect.left + endAnchorRect.width / 2; - branchStart = { - x: anchorCenter, - y: endAnchorRect.top - containerRect.top, - }; - break; - } - case "horizontal": { - const anchorCenter = - endAnchorRect.top - containerRect.top + endAnchorRect.height / 2; - branchStart = { - x: endAnchorRect.left - containerRect.left, - y: anchorCenter, - }; - break; - } - default: - throw new Error(`Unknown orientation: ${orientation as string}`); - } - connectors.push({ - x1: start.x, - y1: start.y, - x2: branchStart.x, - y2: branchStart.y, - isBottom: false, - disabled: prevNode?.props.disabled || isDescendantDisabled, - single: !hasIncomingJunction, - fromId: prevNode?.id, - toId: descendant.id, - }); - } - - if (nextNodeRect && startAnchorRect) { - let branchEnd: { x: number; y: number }; - switch (orientation) { - case "vertical": { - const anchorCenter = - startAnchorRect.left - - containerRect.left + - startAnchorRect.width / 2; - branchEnd = { - x: anchorCenter, - y: startAnchorRect.bottom - containerRect.top, - }; - break; - } - case "horizontal": { - const anchorCenter = - startAnchorRect.top - - containerRect.top + - startAnchorRect.height / 2; - branchEnd = { - x: startAnchorRect.right - containerRect.left, - y: anchorCenter, - }; - break; - } - default: - throw new Error(`Unknown orientation: ${orientation as string}`); - } - connectors.push({ - x1: branchEnd.x, - y1: branchEnd.y, - x2: end.x, - y2: end.y, - isBottom: true, - disabled: isDescendantDisabled || nextNode?.props.disabled, - single: !hasOutgoingJunction, - fromId: descendant.id, - toId: nextNode?.id, - }); - } - - return connectors; - }); - - setLinks({ - connectors: newConnectors, - junctions: { - start: - previousNodeRect && hasIncomingJunction - ? { - x: orientation === "vertical" ? start.x : start.x + 32, - y: orientation === "vertical" ? start.y + 32 : start.y, - } - : undefined, - end: - nextNodeRect && hasOutgoingJunction - ? { - x: orientation === "vertical" ? end.x : end.x - 32, - y: orientation === "vertical" ? end.y - 32 : end.y, - } - : undefined, - }, - containerRect, - }); - }, [ - containerRef, - getPrevious, - getNext, - orientation, - descendants.descendants, - ]); + const nodeProps = useMemo( + () => ({ + kind: "parallel" as const, + children: descendants.descendants.map((d) => d.id), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [structuralKey], + ); - useLayoutEffect(() => { - computeLinks(); - }, [computeLinks]); + const { index, id } = useNode(nodeProps); - /** - * Recompute connector positions when the page scrolls or the window resizes. - * Scroll/resize moves the container in the viewport without triggering - * ResizeObserver on the nodes, making stored rects stale. - */ + // Report our immediate descendants upward so FlowDiagram can reconstruct + // the full tree for this parallel branch. useEffect(() => { - const onLayoutShift = () => computeLinks(); - window.addEventListener("scroll", onLayoutShift, { - capture: true, - passive: true, - }); - window.addEventListener("resize", onLayoutShift, { passive: true }); - return () => { - window.removeEventListener("scroll", onLayoutShift, { capture: true }); - window.removeEventListener("resize", onLayoutShift); - }; - }, [computeLinks]); - - return ( -
    -
    - {links && ( - - {links.junctions?.start && ( - - - - )} - {links.junctions?.end && ( - - - - )} - - )} -
    -
      - - {children} - -
    -
    - ); -} + reportDescendants(id, descendants.descendants); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [structuralKey, reportDescendants, id]); -function JunctionBox({ size = 6 }) { - const halfSize = size / 2; return ( - +
  • + {children} +
  • ); } diff --git a/packages/kumo/src/components/flow/use-children.tsx b/packages/kumo/src/components/flow/use-children.tsx index 44083d0ab8..46cbf8e97b 100644 --- a/packages/kumo/src/components/flow/use-children.tsx +++ b/packages/kumo/src/components/flow/use-children.tsx @@ -27,17 +27,6 @@ type DescendantsContextType> = { ) => { unregister: () => void }; descendants: DescendantInfo[]; claimRenderOrder: (id: string) => number; - /** - * Counter that increments whenever any descendant registers, unregisters, or - * reports a size change. Nodes can depend on this value to know when they - * should remeasure their `getBoundingClientRect`. - */ - measurementEpoch: number; - /** - * Call this when a node's own size changes (e.g. from a ResizeObserver) so - * that sibling nodes know to remeasure their positions. - */ - notifySizeChange: () => void; }; // ============================================================================ @@ -50,12 +39,6 @@ const DescendantsContext = createContext(null); // Hook // ============================================================================ -/** - * Hook that manages descendant registration and provides access to all registered descendants. - * This hook contains all the logic for tracking and managing descendants. - * - * @returns The descendants context value with register function and descendants array - */ export function useDescendants< DescendantType extends Record, >(): DescendantsContextType { @@ -66,18 +49,14 @@ export function useDescendants< new Map(), ); - const [measurementEpoch, setMeasurementEpoch] = useState(0); - - // Track render order - resets each render cycle + // Track render order — resets each render cycle const renderOrderCounterRef = useRef(0); const renderOrderMapRef = useRef>(new Map()); // Reset counter at the start of each render cycle - // This runs synchronously during render, before any children claim their order renderOrderCounterRef.current = 0; renderOrderMapRef.current.clear(); - // Called during render to claim a slot in the render order const claimRenderOrder = useCallback((id: string): number => { if (!renderOrderMapRef.current.has(id)) { renderOrderMapRef.current.set(id, renderOrderCounterRef.current++); @@ -85,19 +64,16 @@ export function useDescendants< return renderOrderMapRef.current.get(id) as number; }, []); - const notifySizeChange = useCallback(() => { - setMeasurementEpoch((prev) => prev + 1); - }, []); - const register = useCallback( ( id: string, renderOrder: number, props: DescendantType = {} as DescendantType, ) => { - const isNewDescendant = !descendantsRef.current.has(id); + const existing = descendantsRef.current.get(id); + const isNew = existing === undefined; + const orderChanged = !isNew && existing.renderOrder !== renderOrder; - // Add descendant to the map with render order const descendantInfo: DescendantInfo = { id, props, @@ -105,28 +81,25 @@ export function useDescendants< }; descendantsRef.current.set(id, descendantInfo); - // Update state with all descendants sorted by render order - const sortedDescendants = Array.from( - descendantsRef.current.values(), - ).sort((a, b) => a.renderOrder - b.renderOrder); - setRegisteredDescendants(sortedDescendants); - - // Bump the epoch when a new node enters so siblings remeasure their - // positions. We intentionally skip this for prop-only updates to - // avoid infinite remeasure loops. - if (isNewDescendant) { - setMeasurementEpoch((prev) => prev + 1); + // Only re-sort and notify React when the list structure changed (new + // entry or render-order shift). Prop-only updates on an already-registered + // descendant must NOT trigger setRegisteredDescendants — that would cause + // every consumer of the descendants array to re-render, which fans out + // into reportTree → setFlowState → context change → children re-render + // → register again → infinite loop. + if (isNew || orderChanged) { + const sorted = Array.from(descendantsRef.current.values()).sort( + (a, b) => a.renderOrder - b.renderOrder, + ); + setRegisteredDescendants(sorted); } - // Return unregister function const unregister = () => { descendantsRef.current.delete(id); const remainingDescendants = Array.from( descendantsRef.current.values(), ).sort((a, b) => a.renderOrder - b.renderOrder); setRegisteredDescendants(remainingDescendants); - // Bump the epoch so siblings remeasure after a node exits. - setMeasurementEpoch((prev) => prev + 1); }; return { unregister }; @@ -139,16 +112,8 @@ export function useDescendants< register, descendants: registeredDescendants, claimRenderOrder, - measurementEpoch, - notifySizeChange, }), - [ - register, - registeredDescendants, - claimRenderOrder, - measurementEpoch, - notifySizeChange, - ], + [register, registeredDescendants, claimRenderOrder], ); return contextValue; @@ -177,16 +142,9 @@ export function DescendantsProvider>({ } // ============================================================================ -// Context Hook +// Context Hooks // ============================================================================ -/** - * Hook to access the descendants context from within a DescendantsProvider. - * This allows callers to access the descendants data and register function. - * - * @returns The descendants context value - * @throws Error if used outside of DescendantsProvider - */ export function useDescendantsContext< T extends Record, >(): DescendantsContextType { @@ -201,12 +159,6 @@ export function useDescendantsContext< return context as DescendantsContextType; } -/** - * Hook to optionally access the descendants context. - * Returns null if not within a DescendantsProvider (does not throw). - * - * @returns The descendants context value or null - */ export function useOptionalDescendantsContext< T extends Record, >(): DescendantsContextType | null { @@ -214,38 +166,6 @@ export function useOptionalDescendantsContext< return context as DescendantsContextType | null; } -// ============================================================================ -// Hook -// ============================================================================ - -/** - * Hook that allows a descendant component to register itself with a parent - * and returns the index of the descendant in the parent's list. - * - * @example - * ```tsx - * function Parent() { - * return ( - * - * - * - * - * - * ); - * } - * - * function Descendant() { - * const index = useDescendantIndex(); - * return
    I am descendant {index}
    ; - * } - * - * // With props - * function Descendant() { - * const index = useDescendantIndex({ name: "Descendant 1", type: "primary" }); - * return
    I am descendant {index}
    ; - * } - * ``` - */ // ============================================================================ // Descendant Index Hook // ============================================================================ @@ -258,48 +178,48 @@ export function useDescendantIndex>( const generatedId = useId(); const id = customId ?? generatedId; - // Claim render order during render (synchronously, not in useEffect) - // This captures the order in which descendants render const renderOrder = context.claimRenderOrder(id); - const unregisterRef = useRef<(() => void) | null>(null); + // Keep mutable refs so the mount/unmount effect always has current values + // without needing to re-run (which would cause the unregister/re-register + // cycle that triggers infinite parent re-renders). const registerRef = useRef(context.register); - - // Keep refs in sync with context registerRef.current = context.register; + const propsRef = useRef(props); + propsRef.current = props; + const renderOrderRef = useRef(renderOrder); + renderOrderRef.current = renderOrder; + // Mount: register. Unmount: unregister. Never runs again for the same id. useEffect(() => { - // Register or update this descendant with its render order - const { unregister } = registerRef.current(id, renderOrder, props); - - // Store unregister function if not already stored - if (!unregisterRef.current) { - unregisterRef.current = unregister; - } - - // Cleanup: unregister when component unmounts - return () => { - if (unregisterRef.current) { - unregisterRef.current(); - unregisterRef.current = null; - } - }; - }, [id, renderOrder, props]); + const { unregister } = registerRef.current( + id, + renderOrderRef.current, + propsRef.current, + ); + return unregister; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + // Prop / order updates: keep the stored entry fresh without removing it + // first. register() skips setRegisteredDescendants when the entry already + // exists at the same order, so this never triggers a parent re-render for + // pure prop changes. + // + // `props` is intentionally excluded from the dependency array: it is an + // object recreated on every render, so including it would cause this effect + // to fire every render → register() → setRegisteredDescendants() → parent + // re-render → new props object → infinite loop. propsRef.current is kept + // up-to-date synchronously (line above), so the effect always reads the + // latest props without needing it as a dep. + useEffect(() => { + registerRef.current(id, renderOrder, propsRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, renderOrder]); - // Derive index from sorted descendants array const index = useMemo(() => { return context.descendants.findIndex((descendant) => descendant.id === id); }, [context.descendants, id]); - const getPrevious = useCallback((): DescendantInfo | undefined => { - if (index <= 0) return undefined; - return context.descendants[index - 1]; - }, [context.descendants, index]); - - const getNext = useCallback((): DescendantInfo | undefined => { - if (index < 0 || index >= context.descendants.length - 1) return undefined; - return context.descendants[index + 1]; - }, [context.descendants, index]); - - return { index, id, getPrevious, getNext }; + return { index, id }; } From 236a21fea75b3ad5474c6d21ec6c51107740fd1d Mon Sep 17 00:00:00 2001 From: nanda Date: Mon, 20 Apr 2026 12:40:19 -0700 Subject: [PATCH 4/7] fix(flow): add back anchor support --- .../kumo/src/components/flow/connectors.tsx | 8 +- packages/kumo/src/components/flow/diagram.tsx | 20 +++- .../kumo/src/components/flow/flow-layout.ts | 4 + .../src/components/flow/flow.browser.test.tsx | 59 ++++++++++++ packages/kumo/src/components/flow/node.tsx | 93 ++++++++++++++++--- .../2026-04-17-flow-layout.md} | 60 ++++++++++-- 6 files changed, 218 insertions(+), 26 deletions(-) rename packages/kumo/src/components/flow/{flow-layout.spec.md => specs/2026-04-17-flow-layout.md} (85%) diff --git a/packages/kumo/src/components/flow/connectors.tsx b/packages/kumo/src/components/flow/connectors.tsx index 5340352283..ef38d01943 100644 --- a/packages/kumo/src/components/flow/connectors.tsx +++ b/packages/kumo/src/components/flow/connectors.tsx @@ -188,12 +188,12 @@ export function FlowConnectors({ if (!fromPos || !toPos || !fromNode || !toNode) continue; connectors.push({ - // right-center of the source node + // right edge of the source node; Y uses anchor midpoint when available x1: fromPos.x + fromNode.width, - y1: fromPos.y + fromNode.height / 2, - // left-center of the target node + y1: fromPos.y + (fromNode.startAnchorOffset ?? fromNode.height / 2), + // left edge of the target node; Y uses anchor midpoint when available x2: toPos.x, - y2: toPos.y + toNode.height / 2, + y2: toPos.y + (toNode.endAnchorOffset ?? toNode.height / 2), fromId, toId, single: true, diff --git a/packages/kumo/src/components/flow/diagram.tsx b/packages/kumo/src/components/flow/diagram.tsx index 2872554db4..2067506168 100644 --- a/packages/kumo/src/components/flow/diagram.tsx +++ b/packages/kumo/src/components/flow/diagram.tsx @@ -111,14 +111,22 @@ export function FlowDiagram({ const reportNode = useCallback( ( id: string, - props: { width: number; height: number; disabled?: boolean }, + props: { + width: number; + height: number; + disabled?: boolean; + startAnchorOffset?: number; + endAnchorOffset?: number; + }, ) => { setNodes((prev) => { const existing = prev[id]; if ( existing?.width === props.width && existing?.height === props.height && - existing?.disabled === props.disabled + existing?.disabled === props.disabled && + existing?.startAnchorOffset === props.startAnchorOffset && + existing?.endAnchorOffset === props.endAnchorOffset ) return prev; return { ...prev, [id]: props }; @@ -416,7 +424,13 @@ export type NodeData = type FlowStateContextValue = { reportNode: ( id: string, - props: { width: number; height: number; disabled?: boolean }, + props: { + width: number; + height: number; + disabled?: boolean; + startAnchorOffset?: number; + endAnchorOffset?: number; + }, ) => void; removeNode: (id: string) => void; /** diff --git a/packages/kumo/src/components/flow/flow-layout.ts b/packages/kumo/src/components/flow/flow-layout.ts index 69b4534dca..db90d637c7 100644 --- a/packages/kumo/src/components/flow/flow-layout.ts +++ b/packages/kumo/src/components/flow/flow-layout.ts @@ -12,6 +12,10 @@ export type FlowState = { width: number; height: number; disabled?: boolean; + /** Y offset from the node's top edge to the outgoing anchor's midpoint. */ + startAnchorOffset?: number; + /** Y offset from the node's top edge to the incoming anchor's midpoint. */ + endAnchorOffset?: number; }; }; tree: TreeNode; diff --git a/packages/kumo/src/components/flow/flow.browser.test.tsx b/packages/kumo/src/components/flow/flow.browser.test.tsx index 77b2937beb..14e1c77172 100644 --- a/packages/kumo/src/components/flow/flow.browser.test.tsx +++ b/packages/kumo/src/components/flow/flow.browser.test.tsx @@ -262,6 +262,65 @@ describe("Flow Integration", () => { assertNoPathEndingWith(container, "branch-b"); }); + test("connector endpoints use anchor midpoint instead of node center", async () => { + const { container, getByTestId } = await render( + + + +
    + Header +
    +
    +
    Body content
    +

  • + } + /> + Next + , + ); + + await Promise.all([ + expect.element(getByTestId("anchored")).toBeVisible(), + expect.element(getByTestId("next")).toBeVisible(), + ]); + + const anchorEl = getByTestId("anchor-el").element(); + const svgContainer = container + .querySelector(`path[data-testid="anchored-next"]`)! + .closest("svg")! + .closest("[class*='relative']")!; + const containerRect = svgContainer.getBoundingClientRect(); + + const anchorRect = anchorEl.getBoundingClientRect(); + const anchorMidY = + anchorRect.top - containerRect.top + anchorRect.height / 2; + + const { start } = getPathEndpoints( + container + .querySelector(`path[data-testid="anchored-next"]`)! + .getAttribute("d")!, + ); + + expect( + Math.abs(start.y - anchorMidY) <= 10, + `connector start Y (${start.y}) should be close to anchor midpoint Y (${anchorMidY})`, + ).toBe(true); + + // Also verify it does NOT match the full node's vertical center + const nodeRect = getByTestId("anchored") + .element() + .getBoundingClientRect(); + const nodeCenterY = + (nodeRect.top + nodeRect.bottom) / 2 - containerRect.top; + expect( + Math.abs(start.y - nodeCenterY) > 10, + `connector start Y (${start.y}) should NOT be the node's center (${nodeCenterY}) when an anchor is present`, + ).toBe(true); + }); + test("does not render outgoing connectors when there is no node after a parallel group", async () => { const { container, getByText } = await render( diff --git a/packages/kumo/src/components/flow/node.tsx b/packages/kumo/src/components/flow/node.tsx index 6e8e2d34f6..01df909626 100644 --- a/packages/kumo/src/components/flow/node.tsx +++ b/packages/kumo/src/components/flow/node.tsx @@ -13,6 +13,8 @@ import { } from "react"; import { useFlowStateContext, useNode, type NodeData } from "./diagram"; +type AnchorType = "start" | "end" | "both"; + // Utility to merge refs function mergeRefs( ...refs: (React.Ref | undefined)[] @@ -67,10 +69,23 @@ export const FlowNode = forwardRef( const { index, id } = useNode(nodeProps, idProp); const { reportNode, removeNode, nodePositions } = useFlowStateContext(); + // Refs that FlowAnchor children write into. Read by reportSize so that + // anchor offsets are always included in the same reportNode call — + // avoiding the state-batching race where reportAnchor fires before the + // node entry exists in the nodes map. + const startAnchorOffsetRef = useRef(undefined); + const endAnchorOffsetRef = useRef(undefined); + const reportSize = useCallback(() => { if (!nodeRef.current) return; const { width, height } = nodeRef.current.getBoundingClientRect(); - reportNode(id, { width, height, disabled }); + reportNode(id, { + width, + height, + disabled, + startAnchorOffset: startAnchorOffsetRef.current, + endAnchorOffset: endAnchorOffsetRef.current, + }); }, [reportNode, id, disabled]); useLayoutEffect(() => { @@ -84,6 +99,43 @@ export const FlowNode = forwardRef( }; }, [reportSize, removeNode, id]); + const registerAnchor = useCallback( + (type: AnchorType, el: HTMLElement | null) => { + const writeOffsets = (offset: number | undefined) => { + if (type === "start" || type === "both") + startAnchorOffsetRef.current = offset; + if (type === "end" || type === "both") + endAnchorOffsetRef.current = offset; + }; + + if (!el) { + writeOffsets(undefined); + reportSize(); + return; + } + + const measure = () => { + if (!nodeRef.current) return; + const anchorRect = el.getBoundingClientRect(); + const nodeRect = nodeRef.current.getBoundingClientRect(); + writeOffsets(anchorRect.top - nodeRect.top + anchorRect.height / 2); + reportSize(); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, + // reportSize is stable within a render cycle; it changes only when + // id/disabled/reportNode change, which also triggers FlowNode's own + // ResizeObserver to re-report. Safe to omit here. + // eslint-disable-next-line react-hooks/exhaustive-deps + [id], + ); + + const anchorContext = useMemo(() => ({ registerAnchor }), [registerAnchor]); + const position = nodePositions[id]; const mergedRef = mergeRefs(ref, nodeRef); @@ -128,7 +180,7 @@ export const FlowNode = forwardRef( } return ( - + {element} ); @@ -142,20 +194,16 @@ FlowNode.displayName = "Flow.Node"; // ============================================================================= type FlowNodeAnchorContextType = { - registerStartAnchor: (ref: HTMLElement | null) => void; - registerEndAnchor: (ref: HTMLElement | null) => void; + registerAnchor: ( + type: AnchorType, + el: HTMLElement | null, + ) => (() => void) | undefined; }; const FlowNodeAnchorContext = createContext( null, ); -// Stable no-op context value — anchors are not used for layout yet (spec: TBD) -const NOOP_ANCHOR_CONTEXT: FlowNodeAnchorContextType = { - registerStartAnchor: () => {}, - registerEndAnchor: () => {}, -}; - /** * FlowAnchor component props. * @@ -185,22 +233,41 @@ export type FlowAnchorProps = { }; export const FlowAnchor = forwardRef( - function FlowAnchor({ render, children }, ref) { + function FlowAnchor({ type, render, children }, ref) { const context = useContext(FlowNodeAnchorContext); if (!context) { throw new Error("Flow.Anchor must be used within Flow.Node"); } + const anchorRef = useRef(null); + const mergedRef = mergeRefs(ref, anchorRef); + + const { registerAnchor } = context; + const anchorType = type ?? "both"; + + useLayoutEffect(() => { + const el = anchorRef.current; + if (!el) return; + const cleanup = registerAnchor(anchorType, el); + return () => { + cleanup?.(); + registerAnchor(anchorType, null); + }; + // registerAnchor is stable (memoized in FlowNode); anchorType is + // unlikely to change at runtime but including it is correct. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [anchorType, registerAnchor]); + if (render && isValidElement(render)) { const renderProps = render.props as { children?: ReactNode }; return cloneElement(render, { - ref, + ref: mergedRef, children: renderProps.children ?? children, } as React.HTMLAttributes & { ref: React.Ref }); } - return
    }>{children}
    ; + return
    }>{children}
    ; }, ); diff --git a/packages/kumo/src/components/flow/flow-layout.spec.md b/packages/kumo/src/components/flow/specs/2026-04-17-flow-layout.md similarity index 85% rename from packages/kumo/src/components/flow/flow-layout.spec.md rename to packages/kumo/src/components/flow/specs/2026-04-17-flow-layout.md index 4afbcf0316..e9b463d749 100644 --- a/packages/kumo/src/components/flow/flow-layout.spec.md +++ b/packages/kumo/src/components/flow/specs/2026-04-17-flow-layout.md @@ -19,16 +19,15 @@ By maunally calculating layout, we get to control exactly where each Flow node s - **Width and height of flow nodes are NOT known ahead of time**. Flow nodes can be arbitrarily rendered using the `render` prop, so we cannot make any assumptions on the size of the flow node without measuring the DOM. - ### Phases -There are two phases to this implementation: +There are three phases to this implementation: 1. **Measurement**, where both the width and height of each flow node is recorded and the tree of nodes is derived from the React component structure. 2. **Layout**, where the positions of each flow node is computed based on the results of (a) and (b) in the measurement phase. 3. **Render**, where the edges between each node is drawn as an SVG path based on the computed coordinates from the layout phase. -### Measurement +## Measurement **Measuring flow nodes** @@ -182,7 +181,7 @@ Tree: } ``` -### Layout +## Layout In the layout phase, the edges and positions of the nodes are derived from the tree stored in state. **Derived is the keyword here**—neither edges nor node positions should be stored in state: @@ -494,9 +493,58 @@ Output: } ``` -**Anchors** +### Anchors + +By default, arrows will link to the center point of every `Flow.Node`. Users can adjust this by using the `Flow.Anchor` component: + +```tsx + + Header +
    Some body content
    +
    +``` + +Now, arrows will link to the midpoint of the "Header" text instead of the entire node. + +Anchors can accept a `type` prop that can either be `start`, `end`, or `both`, defaulting to `both`. + +- `type === "start"` — arrows starting at the node will be positioned against the anchor, but arrows ending at the node will remain at the node's centerpoint. +- `type === "end"` — arrows ending at the node will be positioned against the anchor, but arrows starting at the node will remain at the node's centerpoint. +- `type === "both"` — both incoming _and_ outgoing arrows will be positioned against the anchor. + +```tsx +A + +
    Header
    +
    Body
    + Footer +
    +B +``` + +``` +A -| Header |-> B + |-> Body | + Footer -| +``` + +```tsx +A + + Header +
    Body
    + Footer +
    +B +``` + +``` +A --> Header |-> B + Body | + Footer -| +``` -TBD +To implement this, we need to differentiate between the node's _size_ and _anchor points_. ### Render From 8ac8b69ba903e2fbd426789f9ead72518f359ccc Mon Sep 17 00:00:00 2001 From: nanda Date: Mon, 20 Apr 2026 12:49:45 -0700 Subject: [PATCH 5/7] fix(flow): add back align center support --- packages/kumo/src/components/flow/diagram.tsx | 12 +++++- .../kumo/src/components/flow/flow-layout.ts | 41 +++++++++++++++---- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/kumo/src/components/flow/diagram.tsx b/packages/kumo/src/components/flow/diagram.tsx index 2067506168..16b5060e0c 100644 --- a/packages/kumo/src/components/flow/diagram.tsx +++ b/packages/kumo/src/components/flow/diagram.tsx @@ -29,11 +29,12 @@ import { computeEdges, computePositions, computeDiagramRect, + type FlowAlign, type FlowState, type TreeNode, } from "./flow-layout"; -export type { FlowState, TreeNode }; +export type { FlowAlign, FlowState, TreeNode }; const DEFAULT_PADDING = { y: 64, @@ -54,6 +55,12 @@ interface FlowDiagramProps { * - `false`: Renders only the node list without canvas wrapper */ canvas?: boolean; + /** + * Vertical alignment of nodes within each row. + * - `"start"`: Nodes align to the top of the row (default) + * - `"center"`: Nodes are vertically centered within the row + */ + align?: FlowAlign; /** * Padding around the diagram content within the canvas. * - `x`: Horizontal padding in pixels (default: 16) @@ -71,6 +78,7 @@ interface FlowDiagramProps { export function FlowDiagram({ canvas = true, + align = "start", padding: requestedPadding, onOverflowChange, className, @@ -167,7 +175,7 @@ export function FlowDiagram({ // Derive the tree from root descendants synchronously — never stored in state. const tree = descendantsToTree(rootDescendants, childrenByParent); - const flowState: FlowState = { nodes, tree }; + const flowState: FlowState = { nodes, tree, align }; // Derive edges, positions, and diagram size synchronously — never stored in state. const edges = computeEdges(flowState); diff --git a/packages/kumo/src/components/flow/flow-layout.ts b/packages/kumo/src/components/flow/flow-layout.ts index db90d637c7..e18b4ce0f8 100644 --- a/packages/kumo/src/components/flow/flow-layout.ts +++ b/packages/kumo/src/components/flow/flow-layout.ts @@ -6,6 +6,8 @@ export type TreeNode = | { kind: "list" | "parallel"; children: TreeNode[] } | { kind: "node"; id: string }; +export type FlowAlign = "start" | "center"; + export type FlowState = { nodes: { [id: string]: { @@ -19,6 +21,7 @@ export type FlowState = { }; }; tree: TreeNode; + align: FlowAlign; }; export type Edges = [string, string][]; @@ -129,9 +132,10 @@ export function computePositions( { columnGap = 64, rowGap = 16 } = {}, ): NodePositions { const positions: NodePositions = {}; + const align = flowState.align; /** - * Recursively lay out a subtree, writing absolute positions into `positions`. + * Recursively lay out a subtree, writing absolute positions into `out`. * * @returns `{ width, height }` — the bounding box of this subtree */ @@ -139,23 +143,45 @@ export function computePositions( node: TreeNode, originX: number, originY: number, + out: NodePositions, ): { width: number; height: number } { if (node.kind === "node") { const measured = flowState.nodes[node.id]; const w = measured?.width ?? 0; const h = measured?.height ?? 0; - positions[node.id] = { x: originX, y: originY }; + out[node.id] = { x: originX, y: originY }; return { width: w, height: h }; } if (node.kind === "list") { - // Place children left-to-right + if (align === "center") { + // Two-pass: measure each child into a scratch map to get heights, + // then position with vertical centering into `out`. + const sizes = node.children.map((child) => layout(child, 0, 0, {})); + const rowHeight = sizes.reduce((max, s) => Math.max(max, s.height), 0); + + let cursorX = originX; + for (let i = 0; i < node.children.length; i++) { + const childY = originY + (rowHeight - sizes[i].height) / 2; + layout(node.children[i], cursorX, childY, out); + cursorX += sizes[i].width; + if (i < node.children.length - 1) cursorX += columnGap; + } + + return { width: cursorX - originX, height: rowHeight }; + } + + // Default (align === "start"): place children left-to-right at originY let cursorX = originX; let totalHeight = 0; for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - const { width, height } = layout(child, cursorX, originY); + const { width, height } = layout( + node.children[i], + cursorX, + originY, + out, + ); cursorX += width; if (i < node.children.length - 1) cursorX += columnGap; totalHeight = Math.max(totalHeight, height); @@ -169,8 +195,7 @@ export function computePositions( let maxWidth = 0; for (let i = 0; i < node.children.length; i++) { - const child = node.children[i]; - const { width, height } = layout(child, originX, cursorY); + const { width, height } = layout(node.children[i], originX, cursorY, out); maxWidth = Math.max(maxWidth, width); cursorY += height; if (i < node.children.length - 1) cursorY += rowGap; @@ -179,7 +204,7 @@ export function computePositions( return { width: maxWidth, height: cursorY - originY }; } - layout(flowState.tree, 0, 0); + layout(flowState.tree, 0, 0, positions); return positions; } From 5e9b59cb916752ab5cedff1293b2d5759a4bf2be Mon Sep 17 00:00:00 2001 From: nanda Date: Mon, 20 Apr 2026 12:58:56 -0700 Subject: [PATCH 6/7] fix(flow): add back align end support --- packages/kumo/src/components/flow/diagram.tsx | 15 ++++++++------- .../kumo/src/components/flow/flow-layout.ts | 19 ++++++++++++++++++- .../kumo/src/components/flow/parallel.tsx | 7 +++++-- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/kumo/src/components/flow/diagram.tsx b/packages/kumo/src/components/flow/diagram.tsx index 16b5060e0c..0327190665 100644 --- a/packages/kumo/src/components/flow/diagram.tsx +++ b/packages/kumo/src/components/flow/diagram.tsx @@ -422,7 +422,7 @@ export function FlowDiagram({ export type NodeData = | { kind: "node"; disabled?: boolean } - | { kind: "parallel"; disabled?: boolean; children: string[] } + | { kind: "parallel"; disabled?: boolean; children: string[]; align?: "end" } | { kind: "list"; disabled?: boolean; children: string[] }; // ============================================================================ @@ -541,12 +541,13 @@ function descendantToTreeNode( ): TreeNode { if (d.props.kind === "node") return { kind: "node", id: d.id }; const ownDescendants = childrenByParent.get(d.id) ?? []; - return { - kind: d.props.kind, - children: ownDescendants.map((child) => - descendantToTreeNode(child, childrenByParent), - ), - }; + const children = ownDescendants.map((child) => + descendantToTreeNode(child, childrenByParent), + ); + if (d.props.kind === "parallel") { + return { kind: "parallel", children, align: d.props.align }; + } + return { kind: "list", children }; } export function FlowNodeList({ children }: { children: ReactNode }) { diff --git a/packages/kumo/src/components/flow/flow-layout.ts b/packages/kumo/src/components/flow/flow-layout.ts index e18b4ce0f8..0fc19d91d2 100644 --- a/packages/kumo/src/components/flow/flow-layout.ts +++ b/packages/kumo/src/components/flow/flow-layout.ts @@ -3,7 +3,8 @@ // ============================================================================= export type TreeNode = - | { kind: "list" | "parallel"; children: TreeNode[] } + | { kind: "list"; children: TreeNode[] } + | { kind: "parallel"; children: TreeNode[]; align?: "end" } | { kind: "node"; id: string }; export type FlowAlign = "start" | "center"; @@ -191,6 +192,22 @@ export function computePositions( } // node.kind === "parallel": place children top-to-bottom + if (node.align === "end") { + // Two-pass: measure widths first, then position right-aligned. + const sizes = node.children.map((child) => layout(child, 0, 0, {})); + const maxWidth = sizes.reduce((max, s) => Math.max(max, s.width), 0); + + let cursorY = originY; + for (let i = 0; i < node.children.length; i++) { + const childX = originX + maxWidth - sizes[i].width; + layout(node.children[i], childX, cursorY, out); + cursorY += sizes[i].height; + if (i < node.children.length - 1) cursorY += rowGap; + } + + return { width: maxWidth, height: cursorY - originY }; + } + let cursorY = originY; let maxWidth = 0; diff --git a/packages/kumo/src/components/flow/parallel.tsx b/packages/kumo/src/components/flow/parallel.tsx index 51e0d1545a..48ab01f330 100644 --- a/packages/kumo/src/components/flow/parallel.tsx +++ b/packages/kumo/src/components/flow/parallel.tsx @@ -4,9 +4,11 @@ import { DescendantsProvider } from "./use-children"; type FlowParallelNodeProps = { children: ReactNode; + /** When "end", each branch is right-aligned to the widest branch. */ + align?: "end"; }; -export function FlowParallelNode({ children }: FlowParallelNodeProps) { +export function FlowParallelNode({ children, align }: FlowParallelNodeProps) { const descendants = useNodeGroup(); const { reportDescendants } = useFlowStateContext(); @@ -24,9 +26,10 @@ export function FlowParallelNode({ children }: FlowParallelNodeProps) { () => ({ kind: "parallel" as const, children: descendants.descendants.map((d) => d.id), + align, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [structuralKey], + [structuralKey, align], ); const { index, id } = useNode(nodeProps); From 2c120feaebe78a36eceb0fe356fbb2a9b22a8f5b Mon Sep 17 00:00:00 2001 From: nanda Date: Wed, 22 Apr 2026 09:43:29 -0700 Subject: [PATCH 7/7] chore: changeset + breaking change fix --- .changeset/flow-layout-engine.md | 7 +++++++ packages/kumo/src/components/flow/diagram.tsx | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 .changeset/flow-layout-engine.md diff --git a/.changeset/flow-layout-engine.md b/.changeset/flow-layout-engine.md new file mode 100644 index 0000000000..d3ed11f051 --- /dev/null +++ b/.changeset/flow-layout-engine.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/kumo": minor +--- + +Rewrite `Flow` to use computed layout over relying on DOM layout. + +`Flow` now measures node sizes, reconstructs the flow tree, and computes node positions and connector paths from that derived state instead of chaining DOM rect reads. This keeps connectors aligned through resize and scroll changes, supports nested flow structures more predictably, and makes anchor-based connector placement follow the anchor midpoint. diff --git a/packages/kumo/src/components/flow/diagram.tsx b/packages/kumo/src/components/flow/diagram.tsx index 0327190665..077f41d9d6 100644 --- a/packages/kumo/src/components/flow/diagram.tsx +++ b/packages/kumo/src/components/flow/diagram.tsx @@ -41,6 +41,9 @@ const DEFAULT_PADDING = { x: 16, }; +// Vertical orientation remains a no-op and is kept for backwards compatibility. +type Orientation = "horizontal" | "vertical"; + function isEventFromNode(target: EventTarget | null): boolean { return target instanceof Element && target.closest("[data-node-id]") !== null; } @@ -49,6 +52,7 @@ function isEventFromNode(target: EventTarget | null): boolean { const MIN_SCROLLBAR_THUMB_SIZE = 10; interface FlowDiagramProps { + orientation?: Orientation; /** * Whether to render the pannable canvas wrapper. * - `true`: Renders with pannable canvas, scrollbars, and pan gestures (default) @@ -77,6 +81,7 @@ interface FlowDiagramProps { } export function FlowDiagram({ + orientation = "horizontal", canvas = true, align = "start", padding: requestedPadding, @@ -84,6 +89,8 @@ export function FlowDiagram({ className, children, }: FlowDiagramProps) { + void orientation; + const wrapperRef = useRef(null); const contentRef = useRef(null);