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-docs-astro/src/components/demos/FlowDemo.tsx b/packages/kumo-docs-astro/src/components/demos/FlowDemo.tsx index 0b0cee0761..91f9fb4112 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 + + + 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..ef38d01943 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 edge of the source node; Y uses anchor midpoint when available + x1: fromPos.x + fromNode.width, + 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.endAnchorOffset ?? 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 ae519d6b20..077f41d9d6 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,12 +25,25 @@ import { useOptionalDescendantsContext, type DescendantInfo, } from "./use-children"; +import { + computeEdges, + computePositions, + computeDiagramRect, + type FlowAlign, + type FlowState, + type TreeNode, +} from "./flow-layout"; + +export type { FlowAlign, FlowState, TreeNode }; const DEFAULT_PADDING = { y: 64, 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; } @@ -40,43 +51,20 @@ 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) * - `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) @@ -94,13 +82,15 @@ interface FlowDiagramProps { export function FlowDiagram({ orientation = "horizontal", - align = "start", canvas = true, + align = "start", padding: requestedPadding, onOverflowChange, className, children, }: FlowDiagramProps) { + void orientation; + const wrapperRef = useRef(null); const contentRef = useRef(null); @@ -122,6 +112,103 @@ export function FlowDiagram({ const [isPanning, setIsPanning] = useState(false); const [canPan, setCanPan] = useState(false); + 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; + startAnchorOffset?: number; + endAnchorOffset?: number; + }, + ) => { + setNodes((prev) => { + const existing = prev[id]; + if ( + existing?.width === props.width && + existing?.height === props.height && + existing?.disabled === props.disabled && + existing?.startAnchorOffset === props.startAnchorOffset && + existing?.endAnchorOffset === props.endAnchorOffset + ) + return prev; + return { ...prev, [id]: props }; + }); + }, + [], + ); + + 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, align }; + + // 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( + () => ({ + reportNode, + removeNode, + reportDescendants, + nodePositions, + edges, + }), + // 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(() => { if (!canvas) return; if (!wrapperRef.current || !contentRef.current) return; @@ -273,16 +360,11 @@ 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 */} @@ -329,29 +423,54 @@ export function FlowDiagram({ )} -
+ ); } -// --- - -export type RectLike = { - x: number; - y: number; - top: number; - left: number; - right: number; - bottom: number; - width: number; - height: number; +export type NodeData = + | { kind: "node"; disabled?: boolean } + | { kind: "parallel"; disabled?: boolean; children: string[]; align?: "end" } + | { kind: "list"; disabled?: boolean; children: string[] }; + +// ============================================================================ +// FlowState context +// ============================================================================ + +type FlowStateContextValue = { + reportNode: ( + id: string, + props: { + width: number; + height: number; + disabled?: boolean; + startAnchorOffset?: number; + endAnchorOffset?: number; + }, + ) => 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][]; }; -export type NodeData = { - parallel?: boolean; - disabled?: boolean; - start?: RectLike | null; - end?: RectLike | null; -}; +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(); @@ -366,27 +485,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; @@ -394,123 +525,76 @@ 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) ?? []; + 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 }) { - const { orientation, align } = useDiagramContext(); const descendants = useNodeGroup(); - const containerRef = useRef(null); - const [connectors, setConnectors] = useState([]); - - 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?.parallel || nextNode.props?.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 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( () => ({ - 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 + [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.ts b/packages/kumo/src/components/flow/flow-layout.ts new file mode 100644 index 0000000000..0fc19d91d2 --- /dev/null +++ b/packages/kumo/src/components/flow/flow-layout.ts @@ -0,0 +1,256 @@ +// ============================================================================= +// Types +// ============================================================================= + +export type TreeNode = + | { kind: "list"; children: TreeNode[] } + | { kind: "parallel"; children: TreeNode[]; align?: "end" } + | { kind: "node"; id: string }; + +export type FlowAlign = "start" | "center"; + +export type FlowState = { + nodes: { + [id: string]: { + 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; + align: FlowAlign; +}; + +export type Edges = [string, string][]; +export type NodePositions = Record; +export type DiagramRect = { width: number; height: number }; + +// ============================================================================= +// computeEdges +// ============================================================================= + +/** + * Computes edges between flow nodes from the tree stored in FlowState. + * + * Rules (from spec): + * 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 entry/exit points. + * 3. Adjacent `parallel` groups are NOT connected to one another. + * 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(flowState: FlowState): Edges { + const edges: Edges = []; + collectEdges(flowState.tree, edges); + return edges; +} + +/** + * 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 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); + } + 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]); + } + } + } +} + +// ============================================================================= +// 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 = {}; + const align = flowState.align; + + /** + * Recursively lay out a subtree, writing absolute positions into `out`. + * + * @returns `{ width, height }` — the bounding box of this subtree + */ + function layout( + 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; + out[node.id] = { x: originX, y: originY }; + return { width: w, height: h }; + } + + if (node.kind === "list") { + 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 { width, height } = layout( + node.children[i], + cursorX, + originY, + out, + ); + cursorX += width; + if (i < node.children.length - 1) cursorX += columnGap; + totalHeight = Math.max(totalHeight, height); + } + + return { width: cursorX - originX, height: totalHeight }; + } + + // 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; + + for (let i = 0; i < node.children.length; i++) { + 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; + } + + return { width: maxWidth, height: cursorY - originY }; + } + + layout(flowState.tree, 0, 0, positions); + + 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.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/flow.test.tsx b/packages/kumo/src/components/flow/flow.test.tsx index 2a92132281..4d5d72f949 100644 --- a/packages/kumo/src/components/flow/flow.test.tsx +++ b/packages/kumo/src/components/flow/flow.test.tsx @@ -2,6 +2,8 @@ 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 { FlowState, TreeNode } from "./flow-layout"; function shouldHaveIndex(element: Element, index: number) { expect(element.getAttribute("data-node-index")).toBe(String(index)); @@ -352,3 +354,146 @@ describe("Flow", () => { shouldHaveIndex(screen.getByText("immediate"), 1); }); }); + +// ============================================================================ +// Helpers for computeEdges unit tests +// ============================================================================ + +function makeState(tree: TreeNode): FlowState { + return { nodes: {}, tree }; +} + +function node(id: string): TreeNode { + return { kind: "node", id }; +} + +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 = edgeSet(makeState(list([node("A"), node("B")]))); + expect(edges).toEqual(new Set(["A—B"])); + }); + + it("connects three sequential nodes", () => { + 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(edgeSet(makeState(list([node("A")])))).toEqual(new Set()); + }); + + it("returns no edges for an empty list", () => { + 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 = 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 = 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 = 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 = 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); + expect(edges.has("B2—C2")).toBe(false); + }); + + it("produces exactly the right edge set for A -> [B1,B2] | [C1,C2] -> 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 = 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 = 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 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 56836d9486..01df909626 100644 --- a/packages/kumo/src/components/flow/node.tsx +++ b/packages/kumo/src/components/flow/node.tsx @@ -5,16 +5,15 @@ import { isValidElement, useCallback, useContext, - useEffect, useLayoutEffect, useMemo, useRef, - useState, type ReactElement, type ReactNode, } from "react"; -import { useNode, type NodeData, type RectLike } from "./diagram"; -import { useDescendantsContext } from "./use-children"; +import { useFlowStateContext, useNode, type NodeData } from "./diagram"; + +type AnchorType = "start" | "end" | "both"; // Utility to merge refs function mergeRefs( @@ -65,110 +64,87 @@ 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 remeasure = useCallback(() => { - if (!nodeRef.current) return; - - 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(); - } + const nodeProps = useMemo((): NodeData => ({ kind: "node" }), []); + const { index, id } = useNode(nodeProps, idProp); + const { reportNode, removeNode, nodePositions } = useFlowStateContext(); - setMeasurements((m) => { - const newVal = { start: startRect, end: endRect }; - if (JSON.stringify(m) === JSON.stringify(newVal)) return m; - return newVal; - }); - }, []); + // 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 nodeProps = useMemo( - () => ({ - parallel: false, + const reportSize = useCallback(() => { + if (!nodeRef.current) return; + const { width, height } = nodeRef.current.getBoundingClientRect(); + reportNode(id, { + width, + height, disabled, - ...measurements, - }), - [measurements, disabled], - ); - - const { index, id } = useNode(nodeProps, idProp); + startAnchorOffset: startAnchorOffsetRef.current, + endAnchorOffset: endAnchorOffsetRef.current, + }); + }, [reportNode, id, disabled]); - /** - * 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(reportSize); + observer.observe(nodeRef.current); + reportSize(); + return () => { + observer.disconnect(); + removeNode(id); }; + }, [reportSize, removeNode, id]); - const observer = new ResizeObserver(onResize); - observer.observe(nodeRef.current); - return () => observer.disconnect(); - }, [remeasure, notifySizeChange]); + 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; + }; - /** - * 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]); + if (!el) { + writeOffsets(undefined); + reportSize(); + return; + } - /** - * 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 }); - return () => { - window.removeEventListener("scroll", onLayoutShift, { capture: true }); - window.removeEventListener("resize", onLayoutShift); - }; - }, [remeasure, notifySizeChange]); + 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); + 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; @@ -179,19 +155,24 @@ export const FlowNode = forwardRef( "data-node-index": index, "data-node-id": id, "data-testid": renderProps["data-testid"] ?? id, - style: { cursor: "default", ...renderProps.style }, + "aria-hidden": position ? undefined : true, + style: { + ...positionStyle, + cursor: "default", + ...renderProps.style, + }, children: renderProps.children ?? children, } as React.HTMLAttributes & { ref: React.Ref }); } else { - // Default element element = (
  • {children}
  • @@ -199,19 +180,7 @@ export const FlowNode = forwardRef( } return ( - ({ - registerStartAnchor: (anchorRef) => { - startAnchorRef.current = anchorRef; - }, - registerEndAnchor: (anchorRef) => { - endAnchorRef.current = anchorRef; - }, - }), - [], - )} - > + {element} ); @@ -220,9 +189,15 @@ export const FlowNode = forwardRef( FlowNode.displayName = "Flow.Node"; +// ============================================================================= +// FlowAnchor +// ============================================================================= + type FlowNodeAnchorContextType = { - registerStartAnchor: (ref: HTMLElement | null) => void; - registerEndAnchor: (ref: HTMLElement | null) => void; + registerAnchor: ( + type: AnchorType, + el: HTMLElement | null, + ) => (() => void) | undefined; }; const FlowNodeAnchorContext = createContext( @@ -260,38 +235,31 @@ export type FlowAnchorProps = { export const FlowAnchor = forwardRef( function FlowAnchor({ type, 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; - } + const anchorRef = useRef(null); + const mergedRef = mergeRefs(ref, anchorRef); - if (type === "start" || type === undefined) { - context.registerStartAnchor(anchorRef.current); - } - if (type === "end" || type === undefined) { - context.registerEndAnchor(anchorRef.current); - } + const { registerAnchor } = context; + const anchorType = type ?? "both"; + useLayoutEffect(() => { + const el = anchorRef.current; + if (!el) return; + const cleanup = registerAnchor(anchorType, el); return () => { - if (type === "start" || type === undefined) { - context.registerStartAnchor(null); - } - if (type === "end" || type === undefined) { - context.registerEndAnchor(null); - } + cleanup?.(); + registerAnchor(anchorType, null); }; - }, [type, context.registerStartAnchor, context.registerEndAnchor]); - - const mergedRef = mergeRefs(ref, anchorRef); + // 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)) { - // When render prop is provided, clone it with ref const renderProps = render.props as { children?: ReactNode }; return cloneElement(render, { ref: mergedRef, @@ -299,8 +267,7 @@ export const FlowAnchor = forwardRef( } 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 c96da5c95d..48ab01f330 100644 --- a/packages/kumo/src/components/flow/parallel.tsx +++ b/packages/kumo/src/components/flow/parallel.tsx @@ -1,470 +1,49 @@ -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"; + /** When "end", each branch is right-aligned to the widest branch. */ + align?: "end"; }; -export function FlowParallelNode({ - children, - align = "start", -}: FlowParallelNodeProps) { - const { orientation } = useDiagramContext(); +export function FlowParallelNode({ children, align }: FlowParallelNodeProps) { 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( - () => ({ parallel: true, start: startAnchor, end: endAnchor }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [JSON.stringify(startAnchor), JSON.stringify(endAnchor)], - ), + 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), + align, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [structuralKey, align], + ); - 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]); - - const previousIsParallel = getPrevious()?.props?.parallel === true; - - 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/specs/2026-04-17-flow-layout.md b/packages/kumo/src/components/flow/specs/2026-04-17-flow-layout.md new file mode 100644 index 0000000000..e9b463d749 --- /dev/null +++ b/packages/kumo/src/components/flow/specs/2026-04-17-flow-layout.md @@ -0,0 +1,551 @@ +# 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 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 + +**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 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; + } + }; + tree: TreeNode; +} + +type TreeNode = { + kind: "list" | "parallel" + children: TreeNode[] +} | { + kind: "node" + id: string; +} +``` + +**Examples** + +```tsx + + 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 + } + ] +} +``` + +--- + +```tsx + + A + + B1 + B2 + + C + +``` + +```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 + } + ] +} +``` + +--- + +```tsx + + A + + + B1 + B2 + + C1 + + 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 +A -> B2 +B1 -> C +B2 -> C +``` + +3. Adjacent parallel nodes will _not_ link to one another. + + +```tsx +{ + 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" }, + ] +} +``` + +``` +Edges: +A -> B1 +A -> B2 +C1 -> D +C2 -> D +``` + +4. Nodes adjacent to a list node will be connected to the _first_ and _last_ node in the list group. + +```tsx +{ + 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" }, + ] +} +``` + +``` +Edges: +A -> B1 +A -> C1 +B1 -> B2 +B2 -> D +C1 -> D +``` + +**Computing positions** + +```tsx +function computePositions( + flowState: FlowState, + { columnGap = 64, rowGap = 48 } = {} +): Record; +``` + +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 + +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 -| +``` + +To implement this, we need to differentiate between the node's _size_ and _anchor points_. + +### Render + +TBD \ No newline at end of file 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 }; }