diff --git a/.changeset/flow-node-id-prop.md b/.changeset/flow-node-id-prop.md new file mode 100644 index 00000000..8ee5fd52 --- /dev/null +++ b/.changeset/flow-node-id-prop.md @@ -0,0 +1,6 @@ +--- +'@cloudflare/kumo': minor +--- + +Add optional `id` prop to `Flow.Node` for stable node identification and connector test IDs + diff --git a/packages/kumo/package.json b/packages/kumo/package.json index bc57c8f7..fe632800 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -448,6 +448,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@types/svg-path-parser": "^1.1.6", "@vitejs/plugin-react": "^5.1.4", "@vitest/browser-playwright": "^4.0.18", "@vitest/ui": "catalog:", @@ -461,6 +462,7 @@ "playwright": "catalog:", "plop": "4.0.4", "rollup-plugin-preserve-directives": "0.4.0", + "svg-path-parser": "^1.1.0", "tailwindcss": "catalog:", "ts-json-schema-generator": "2.4.0", "tsx": "catalog:", diff --git a/packages/kumo/src/components/flow/connectors.tsx b/packages/kumo/src/components/flow/connectors.tsx index 94358742..0163c261 100644 --- a/packages/kumo/src/components/flow/connectors.tsx +++ b/packages/kumo/src/components/flow/connectors.tsx @@ -8,6 +8,10 @@ export interface Connector { isBottom?: boolean; disabled?: boolean; single?: boolean; + /** Id of the source node this connector originates from. */ + fromId?: string; + /** Id of the target node this connector points to. */ + toId?: string; } type ConnectorsProps = { @@ -200,6 +204,11 @@ export const Connectors = forwardRef( strokeWidth="2" markerEnd={`url(#${id})`} data-index={index} + data-testid={ + connector.fromId && connector.toId + ? `${connector.fromId}-${connector.toId}` + : undefined + } /> ); diff --git a/packages/kumo/src/components/flow/diagram.tsx b/packages/kumo/src/components/flow/diagram.tsx index c7697513..0b3a13bc 100644 --- a/packages/kumo/src/components/flow/diagram.tsx +++ b/packages/kumo/src/components/flow/diagram.tsx @@ -261,7 +261,12 @@ export function FlowDiagram({ onPan={handlePan} onPanEnd={handlePanEnd} > - + {children} @@ -317,7 +322,8 @@ export type NodeData = { export const useNodeGroup = () => useDescendants(); -export const useNode = (props: NodeData) => useDescendantIndex(props); +export const useNode = (props: NodeData, id?: string) => + useDescendantIndex(props, id); /** * Hook to optionally register as a node if within a parent descendants context. @@ -347,7 +353,7 @@ export const useOptionalNode = (props: NodeData) => { unregisterRef.current = null; } }; - }, [id, renderOrder, props, parentContext]); + }, [id, renderOrder, props, parentContext?.register]); if (!parentContext) return null; @@ -395,6 +401,8 @@ export function FlowNodeList({ children }: { children: ReactNode }) { y2: nextRect.top - offsetY + nextRect.height / 2, disabled: isDisabled, single: true, + fromId: currentNode.id, + toId: nextNode.id, }); } } @@ -418,7 +426,7 @@ export function FlowNodeList({ children }: { children: ReactNode }) { start: startAnchor, end: endAnchor, }), - [startAnchor, endAnchor], + [JSON.stringify(startAnchor), JSON.stringify(endAnchor)], ); // Register with parent context if we're nested (e.g., inside Flow.Parallel) diff --git a/packages/kumo/src/components/flow/flow.browser.test.tsx b/packages/kumo/src/components/flow/flow.browser.test.tsx index d15c6635..6e8db5c5 100644 --- a/packages/kumo/src/components/flow/flow.browser.test.tsx +++ b/packages/kumo/src/components/flow/flow.browser.test.tsx @@ -1,5 +1,7 @@ import { describe, test, expect } from "vitest"; import { render } from "vitest-browser-react"; +import { parseSVG, makeAbsolute } from "svg-path-parser"; +import { forwardRef, useState, type ReactNode } from "react"; import { Flow } from "."; describe("Flow Integration", () => { @@ -15,4 +17,405 @@ describe("Flow Integration", () => { expect.element(getByText("Node 2")).toBeVisible(), ]); }); + + describe("paths", () => { + test("renders a link from node 1 to node 2", async () => { + const { container, getByText } = await render( + + Node 1 + Node 2 + , + ); + + await Promise.all([ + expect.element(getByText("Node 1")).toBeVisible(), + expect.element(getByText("Node 2")).toBeVisible(), + ]); + + assertPathConnects({ + container, + fromNode: getByText("Node 1").element(), + toNode: getByText("Node 2").element(), + fromId: "node-1", + toId: "node-2", + }); + }); + + test("updates connectors when an expandable node changes size", async () => { + const { container, getByText, getByTestId } = await render( + + Start + + +

Extra content that makes the node taller.

+ + } + /> + Sibling +
+ End +
, + ); + + // Wait for initial render + await Promise.all([ + expect.element(getByText("Toggle Me")).toBeVisible(), + expect.element(getByText("Sibling")).toBeVisible(), + expect.element(getByText("End")).toBeVisible(), + ]); + + // Capture the connector endpoint for the collapsed state + const pathBeforeExpand = getPathEndpointsForConnector( + container, + "start", + "expandable", + ); + + // Expand the node by clicking the button + await getByText("Toggle Me").click(); + await expect + .element(getByText("Extra content that makes the node taller.")) + .toBeVisible(); + + // Wait for paint to finish + await waitForNextFrame(); + + // The expandable node is now taller, so connectors should update. + // Re-assert that all connectors still point at the correct node + // positions after the resize. + const expandableNode = getByTestId("expandable").element(); + const endNode = getByTestId("end").element(); + const startNode = getByTestId("start").element(); + + assertPathConnects({ + container, + fromNode: startNode, + toNode: expandableNode, + fromId: "start", + toId: "expandable", + }); + + assertPathConnects({ + container, + fromNode: expandableNode, + toNode: endNode, + fromId: "expandable", + toId: "end", + }); + + // Verify the connector endpoint actually moved (the expanded node is + // taller so its vertical center shifts) + const pathAfterExpand = getPathEndpointsForConnector( + container, + "start", + "expandable", + ); + expect( + pathBeforeExpand.end.y !== pathAfterExpand.end.y, + `connector endpoint y should change after expand (before: ${pathBeforeExpand.end.y}, after: ${pathAfterExpand.end.y})`, + ).toBe(true); + }); + + test("renders connectors for parallel branches", async () => { + const { container, getByText, getByTestId } = await render( + + Start + + Branch A + Branch B + + End + , + ); + + await Promise.all([ + expect.element(getByText("Start")).toBeVisible(), + expect.element(getByText("Branch A")).toBeVisible(), + expect.element(getByText("Branch B")).toBeVisible(), + expect.element(getByText("End")).toBeVisible(), + ]); + + const cases = [ + { from: "start", to: "branch-a" }, + { from: "start", to: "branch-b" }, + { from: "branch-a", to: "end" }, + { from: "branch-b", to: "end" }, + ]; + + for (const { from, to } of cases) { + const [startNode, endNode] = [ + getByTestId(from).element(), + getByTestId(to).element(), + ]; + assertPathConnects({ + container, + fromNode: startNode, + toNode: endNode, + fromId: from, + toId: to, + }); + } + }); + + test("does not render incoming connectors when there is no node before a parallel group", async () => { + const { container, getByText } = await render( + + + Branch A + Branch B + + End + , + ); + + await Promise.all([ + expect.element(getByText("Branch A")).toBeVisible(), + expect.element(getByText("Branch B")).toBeVisible(), + expect.element(getByText("End")).toBeVisible(), + ]); + + // Outgoing connectors (branch → end) should still exist + assertPathExists(container, "branch-a", "end"); + assertPathExists(container, "branch-b", "end"); + + // No incoming connectors should exist since nothing precedes the + // parallel group. Query for any path whose testid ends with the + // branch ids as the target (i.e. "*-branch-a", "*-branch-b"). + assertNoPathEndingWith(container, "branch-a"); + assertNoPathEndingWith(container, "branch-b"); + }); + + test("does not render outgoing connectors when there is no node after a parallel group", async () => { + const { container, getByText } = await render( + + Start + + Branch A + Branch B + + , + ); + + await Promise.all([ + expect.element(getByText("Start")).toBeVisible(), + expect.element(getByText("Branch A")).toBeVisible(), + expect.element(getByText("Branch B")).toBeVisible(), + ]); + + // Incoming connectors (start → branch) should still exist + assertPathExists(container, "start", "branch-a"); + assertPathExists(container, "start", "branch-b"); + + // No outgoing connectors should exist since nothing follows the + // parallel group. Query for any path whose testid starts with the + // branch ids as the source (i.e. "branch-a-*", "branch-b-*"). + assertNoPathStartingWith(container, "branch-a"); + assertNoPathStartingWith(container, "branch-b"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Test components +// --------------------------------------------------------------------------- + +/** + * A simple expandable node used for testing dynamic node resizing. + * Clicking the button toggles a content panel, changing the node's height. + */ +const ExpandableNode = forwardRef< + HTMLLIElement, + { title: string; children: ReactNode } +>(function ExpandableNode({ title, children, ...props }, ref) { + const [open, setOpen] = useState(false); + return ( +
  • + + {open && ( +
    + {children} +
    + )} +
  • + ); }); + +// --------------------------------------------------------------------------- +// Test utilities for connector assertions +// --------------------------------------------------------------------------- + +const waitForNextFrame = async () => + new Promise((p) => requestAnimationFrame(p)); + +/** + * Parse an SVG path's `d` attribute and return the absolute start and end + * points. Uses `makeAbsolute` from svg-path-parser so the result is correct + * regardless of whether the path uses relative or absolute commands, curves, + * arcs, or any other SVG path syntax. + */ +function getPathEndpoints(d: string) { + // createRoundedPath joins nested arrays which inserts commas between + // subcommands (e.g. "L 3 4,Q 5 6 7 8"). Replace commas with spaces so + // the parser can handle it. + const commands = makeAbsolute(parseSVG(d.replace(/,/g, " "))); + const first = commands[0]; + const last = commands[commands.length - 1]; + return { + start: { x: first.x, y: first.y }, + end: { x: last.x, y: last.y }, + }; +} + +/** + * Returns true when `a` is within `tolerance` pixels of `b` in both axes. + */ +function isCloseTo( + a: { x: number; y: number }, + b: { x: number; y: number }, + tolerance = 10, +) { + return Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance; +} + +function rightCenter(rect: { right: number; top: number; bottom: number }) { + return { x: rect.right, y: (rect.top + rect.bottom) / 2 }; +} + +function leftCenter(rect: { left: number; top: number; bottom: number }) { + return { x: rect.left, y: (rect.top + rect.bottom) / 2 }; +} + +/** + * Translate a DOMRect into the local coordinate space of a container element. + */ +function toLocalRect(rect: DOMRect, container: DOMRect) { + return { + left: rect.left - container.left, + top: rect.top - container.top, + right: rect.right - container.left, + bottom: rect.bottom - container.top, + }; +} + +/** + * Look up the connector path between two nodes and return its parsed start/end + * points. Useful for comparing positions before and after a DOM change. + */ +function getPathEndpointsForConnector( + container: Element, + fromId: string, + toId: string, +) { + const path = container.querySelector(`path[data-testid="${fromId}-${toId}"]`); + expect( + path, + `expected path[data-testid="${fromId}-${toId}"] to exist`, + ).toBeTruthy(); + const d = path!.getAttribute("d")!; + expect(d).toBeTruthy(); + return getPathEndpoints(d); +} + +/** + * Assert that a connector path with `data-testid="{fromId}-{toId}"` exists. + */ +function assertPathExists(container: Element, fromId: string, toId: string) { + const path = container.querySelector(`path[data-testid="${fromId}-${toId}"]`); + expect( + path, + `expected path[data-testid="${fromId}-${toId}"] to exist`, + ).toBeTruthy(); +} + +/** + * Assert that no connector path has `toId` as its target. This checks that + * no `data-testid` attribute ends with `-{toId}`. + */ +function assertNoPathEndingWith(container: Element, toId: string) { + const paths = container.querySelectorAll("path[data-testid]"); + for (const path of paths) { + const testId = path.getAttribute("data-testid")!; + expect( + testId.endsWith(`-${toId}`), + `unexpected incoming connector "${testId}" targeting "${toId}"`, + ).toBe(false); + } +} + +/** + * Assert that no connector path has `fromId` as its source. This checks that + * no `data-testid` attribute starts with `{fromId}-`. + */ +function assertNoPathStartingWith(container: Element, fromId: string) { + const paths = container.querySelectorAll("path[data-testid]"); + for (const path of paths) { + const testId = path.getAttribute("data-testid")!; + expect( + testId.startsWith(`${fromId}-`), + `unexpected outgoing connector "${testId}" originating from "${fromId}"`, + ).toBe(false); + } +} + +/** + * Assert that a connector path starts at the right-center of `fromNode` and + * ends at the left-center of `toNode`. + * + * Looks up the `` element via its `data-testid="{fromId}-{toId}"` and + * resolves the SVG coordinate space from the closest relative container. + */ +function assertPathConnects({ + container, + fromNode, + toNode, + fromId, + toId, +}: { + container: Element; + fromNode: Element; + toNode: Element; + fromId: string; + toId: string; +}) { + const path = container.querySelector(`path[data-testid="${fromId}-${toId}"]`); + expect( + path, + `expected path[data-testid="${fromId}-${toId}"] to exist`, + ).toBeTruthy(); + + const d = path!.getAttribute("d")!; + expect(d).toBeTruthy(); + + const svgContainer = path!.closest("svg")!.closest("[class*='relative']")!; + const containerRect = svgContainer.getBoundingClientRect(); + + const localFrom = toLocalRect( + fromNode.getBoundingClientRect(), + containerRect, + ); + const localTo = toLocalRect(toNode.getBoundingClientRect(), containerRect); + + const { start, end } = getPathEndpoints(d); + + expect( + isCloseTo(start, rightCenter(localFrom)), + `path start ${JSON.stringify(start)} should be close to right-center of fromNode ${JSON.stringify(rightCenter(localFrom))}`, + ).toBe(true); + expect( + isCloseTo(end, leftCenter(localTo)), + `path end ${JSON.stringify(end)} should be close to left-center of toNode ${JSON.stringify(leftCenter(localTo))}`, + ).toBe(true); +} diff --git a/packages/kumo/src/components/flow/flow.test.tsx b/packages/kumo/src/components/flow/flow.test.tsx index 0c3d7b6a..af0e705f 100644 --- a/packages/kumo/src/components/flow/flow.test.tsx +++ b/packages/kumo/src/components/flow/flow.test.tsx @@ -77,6 +77,44 @@ describe("Flow", () => { const node = screen.getByText("Node A"); expect(node.getAttribute("data-node-id")).toBeTruthy(); }); + + it("uses a custom id prop as data-node-id when provided", () => { + render( + + Custom ID Node + , + ); + + const node = screen.getByText("Custom ID Node"); + expect(node.getAttribute("data-node-id")).toBe("my-custom-id"); + }); + + it("uses a custom id on render prop elements", () => { + render( + + Custom} + /> + , + ); + + const node = screen.getByTestId("custom-render"); + expect(node.getAttribute("data-node-id")).toBe("render-custom-id"); + }); + + it("falls back to a generated id when no id prop is provided", () => { + render( + + Auto ID + , + ); + + const node = screen.getByText("Auto ID"); + const nodeId = node.getAttribute("data-node-id"); + expect(nodeId).toBeTruthy(); + expect(nodeId).not.toBe(""); + }); }); it("renders parallel branches alongside sequential nodes", () => { @@ -229,6 +267,61 @@ describe("Flow", () => { }); }); + describe("Nested list in a parallel node", () => { + it("renders nested Flow.List branches inside Flow.Parallel", () => { + render( + + + + Client Users + Engineering Team Access + + + + All Authenticated Users + Client Users 2 + Site Users + + Contractor Access + + + Destinations + , + ); + + // All nodes from both lists are rendered + expect(screen.getByText("Client Users")).toBeTruthy(); + expect(screen.getByText("Engineering Team Access")).toBeTruthy(); + expect(screen.getByText("All Authenticated Users")).toBeTruthy(); + expect(screen.getByText("Client Users 2")).toBeTruthy(); + expect(screen.getByText("Site Users")).toBeTruthy(); + expect(screen.getByText("Contractor Access")).toBeTruthy(); + expect(screen.getByText("Destinations")).toBeTruthy(); + }); + + it("renders a nested parallel inside a list within a parallel", () => { + render( + + + + + Inner Branch A + Inner Branch B + + After Inner Parallel + + + Final + , + ); + + expect(screen.getByText("Inner Branch A")).toBeTruthy(); + expect(screen.getByText("Inner Branch B")).toBeTruthy(); + expect(screen.getByText("After Inner Parallel")).toBeTruthy(); + expect(screen.getByText("Final")).toBeTruthy(); + }); + }); + it("reindexes nodes when children appear asynchronously", async () => { function AsyncFlow() { const [showDelayed, setShowDelayed] = useState(false); diff --git a/packages/kumo/src/components/flow/node.tsx b/packages/kumo/src/components/flow/node.tsx index 261f0dfb..ad96a417 100644 --- a/packages/kumo/src/components/flow/node.tsx +++ b/packages/kumo/src/components/flow/node.tsx @@ -5,21 +5,22 @@ import { isValidElement, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, type ReactElement, - type ReactNode -} from 'react'; -import { useNode, type RectLike } from './diagram'; + type ReactNode, +} from "react"; +import { useNode, type RectLike } from "./diagram"; // Utility to merge refs function mergeRefs( ...refs: (React.Ref | undefined)[] ): React.RefCallback { - return value => { - refs.forEach(ref => { - if (typeof ref === 'function') { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === "function") { ref(value); } else if (ref != null) { (ref as React.MutableRefObject).current = value; @@ -42,6 +43,11 @@ function mergeRefs( * ``` */ export type FlowNodeProps = { + /** + * Optional identifier for the node. When provided, used as the + * `data-node-id` attribute instead of the auto-generated React id. + */ + id?: string; /** * Custom element to render instead of the default styled node. * When provided, completely replaces the default element. @@ -55,7 +61,7 @@ export type FlowNodeProps = { }; export const FlowNode = forwardRef( - function FlowNode({ render, children, disabled = false }, ref) { + function FlowNode({ id: idProp, render, children, disabled = false }, ref) { const nodeRef = useRef(null); const startAnchorRef = useRef(null); const endAnchorRef = useRef(null); @@ -68,22 +74,17 @@ export const FlowNode = forwardRef( () => ({ parallel: false, disabled, - ...measurements + ...measurements, }), - [measurements, disabled] + [measurements, disabled], ); - const { index, id } = useNode(nodeProps); + const { index, id } = useNode(nodeProps, idProp); - /** - * This effect intentionally has no dependencies because we want it to run on - * every render to ensure measurements are always up to date. - */ - useEffect(() => { + const remeasure = () => { if (!nodeRef.current) return; - const rect = nodeRef.current.getBoundingClientRect(); - const nodeRect = rect; + const nodeRect = nodeRef.current.getBoundingClientRect(); let startRect: RectLike = nodeRect; let endRect: RectLike = nodeRect; @@ -96,12 +97,30 @@ export const FlowNode = forwardRef( endRect = endAnchorRef.current.getBoundingClientRect(); } - setMeasurements(m => { + setMeasurements((m) => { const newVal = { start: startRect, end: endRect }; if (JSON.stringify(m) === JSON.stringify(newVal)) return m; return newVal; }); - }); + }; + + /** + * This effect intentionally has no dependencies because we want it to run on + * every render to ensure measurements are always up to date. + */ + useLayoutEffect(remeasure); + + /** + * 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). + */ + useLayoutEffect(() => { + if (!nodeRef.current) return; + const observer = new ResizeObserver(remeasure); + observer.observe(nodeRef.current); + return () => observer.disconnect(); + }, []); const mergedRef = mergeRefs(ref, nodeRef); @@ -111,13 +130,15 @@ export const FlowNode = forwardRef( const renderProps = render.props as { children?: ReactNode; style?: React.CSSProperties; + "data-testid"?: string; }; element = cloneElement(render, { ref: mergedRef, - 'data-node-index': index, - 'data-node-id': id, - style: { cursor: 'default', ...renderProps.style }, - children: renderProps.children ?? children + "data-node-index": index, + "data-node-id": id, + "data-testid": renderProps["data-testid"] ?? id, + style: { cursor: "default", ...renderProps.style }, + children: renderProps.children ?? children, } as React.HTMLAttributes & { ref: React.Ref }); } else { // Default element @@ -125,9 +146,10 @@ export const FlowNode = forwardRef(
  • {children}
  • @@ -138,23 +160,23 @@ export const FlowNode = forwardRef( ({ - registerStartAnchor: anchorRef => { + registerStartAnchor: (anchorRef) => { startAnchorRef.current = anchorRef; }, - registerEndAnchor: anchorRef => { + registerEndAnchor: (anchorRef) => { endAnchorRef.current = anchorRef; - } + }, }), - [] + [], )} > {element} ); - } + }, ); -FlowNode.displayName = 'Flow.Node'; +FlowNode.displayName = "Flow.Node"; type FlowNodeAnchorContextType = { registerStartAnchor: (ref: HTMLElement | null) => void; @@ -162,7 +184,7 @@ type FlowNodeAnchorContextType = { }; const FlowNodeAnchorContext = createContext( - null + null, ); /** @@ -184,7 +206,7 @@ export type FlowAnchorProps = { * _next_ connector or the "end" point for the _previous_ connector. * When omitted, it serves as both the start and end points. */ - type?: 'start' | 'end'; + type?: "start" | "end"; /** * Custom element to render instead of the default div. * When provided, completely replaces the default element. @@ -199,7 +221,7 @@ export const FlowAnchor = forwardRef( const anchorRef = useRef(null); if (!context) { - throw new Error('Flow.Anchor must be used within Flow.Node'); + throw new Error("Flow.Anchor must be used within Flow.Node"); } useEffect(() => { @@ -207,18 +229,18 @@ export const FlowAnchor = forwardRef( return; } - if (type === 'start' || type === undefined) { + if (type === "start" || type === undefined) { context.registerStartAnchor(anchorRef.current); } - if (type === 'end' || type === undefined) { + if (type === "end" || type === undefined) { context.registerEndAnchor(anchorRef.current); } return () => { - if (type === 'start' || type === undefined) { + if (type === "start" || type === undefined) { context.registerStartAnchor(null); } - if (type === 'end' || type === undefined) { + if (type === "end" || type === undefined) { context.registerEndAnchor(null); } }; @@ -231,13 +253,13 @@ export const FlowAnchor = forwardRef( const renderProps = render.props as { children?: ReactNode }; return cloneElement(render, { ref: mergedRef, - children: renderProps.children ?? children + children: renderProps.children ?? children, } as React.HTMLAttributes & { ref: React.Ref }); } // Default element return
    {children}
    ; - } + }, ); -FlowAnchor.displayName = 'Flow.Anchor'; +FlowAnchor.displayName = "Flow.Anchor"; diff --git a/packages/kumo/src/components/flow/parallel.tsx b/packages/kumo/src/components/flow/parallel.tsx index eaab70d2..20648e8a 100644 --- a/packages/kumo/src/components/flow/parallel.tsx +++ b/packages/kumo/src/components/flow/parallel.tsx @@ -1,4 +1,11 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { cn } from "../../utils/cn"; import { Connectors, type Connector } from "./connectors"; import { @@ -83,18 +90,31 @@ export function FlowParallelNode({ ), ); - /** - * This effect intentionally has no dependencies because we want it to run on - * every render to ensure measurements are always up to date. - */ - useEffect(() => { + const remeasure = () => { if (!contentRef.current) return; const rect = contentRef.current.getBoundingClientRect(); setMeasurements((m) => { if (JSON.stringify(m) === JSON.stringify(rect)) return m; return rect; }); - }); + }; + + /** + * This effect intentionally has no dependencies because we want it to run on + * every render to ensure measurements are always up to date. + */ + useLayoutEffect(remeasure); + + /** + * Observe the content element for size changes so that connectors update even + * when child nodes resize without triggering a FlowParallelNode re-render. + */ + useLayoutEffect(() => { + if (!contentRef.current) return; + const observer = new ResizeObserver(remeasure); + observer.observe(contentRef.current); + return () => observer.disconnect(); + }, []); const measure = () => { const container = containerRef.current; @@ -219,6 +239,8 @@ export function FlowParallelNode({ isBottom: false, disabled: prevNode?.props.disabled || isDescendantDisabled, single: !hasIncomingJunction, + fromId: prevNode?.id, + toId: descendant.id, }); } @@ -258,6 +280,8 @@ export function FlowParallelNode({ isBottom: true, disabled: isDescendantDisabled || nextNode?.props.disabled, single: !hasOutgoingJunction, + fromId: descendant.id, + toId: nextNode?.id, }); } diff --git a/packages/kumo/src/components/flow/use-children.tsx b/packages/kumo/src/components/flow/use-children.tsx index 9841550e..e9c0b182 100644 --- a/packages/kumo/src/components/flow/use-children.tsx +++ b/packages/kumo/src/components/flow/use-children.tsx @@ -216,9 +216,11 @@ export function useOptionalDescendantsContext< export function useDescendantIndex>( props?: T, + customId?: string, ) { const context = useDescendantsContext(); - const id = useId(); + const generatedId = useId(); + const id = customId ?? generatedId; // Claim render order during render (synchronously, not in useEffect) // This captures the order in which descendants render diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51102e0c..eaf63286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.4) + '@types/svg-path-parser': + specifier: ^1.1.6 + version: 1.1.6 '@vitejs/plugin-react': specifier: ^5.1.4 version: 5.1.4(vite@7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) @@ -213,6 +216,9 @@ importers: rollup-plugin-preserve-directives: specifier: 0.4.0 version: 0.4.0(rollup@4.53.2) + svg-path-parser: + specifier: ^1.1.0 + version: 1.1.0 tailwindcss: specifier: 'catalog:' version: 4.1.17 @@ -2147,6 +2153,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/svg-path-parser@1.1.6': + resolution: {integrity: sha512-3sw6pk91pEtW6W7hRrJ9ZkAgPiJSaNdh7iY8rVOy7buajpQuy2J9A0ZUaiOVcbFvl0p7J+Ne4012muCE/MB+hQ==} + '@types/through@0.0.33': resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} @@ -4911,6 +4920,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-path-parser@1.1.0: + resolution: {integrity: sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==} + svgo@4.0.0: resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==} engines: {node: '>=16'} @@ -7391,6 +7403,8 @@ snapshots: '@types/statuses@2.0.6': optional: true + '@types/svg-path-parser@1.1.6': {} + '@types/through@0.0.33': dependencies: '@types/node': 22.19.1 @@ -10845,6 +10859,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-path-parser@1.1.0: {} + svgo@4.0.0: dependencies: commander: 11.1.0