From 2250338b98d488c8558323f07f419bd96fd1b16c Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Tue, 2 Jun 2026 10:10:26 +0200 Subject: [PATCH 1/7] feat(sdk): expose edgeTemplates prop for custom edge renderers (WB-220) --- .changeset/wb-220-edge-templates.md | 5 ++ packages/sdk/README.md | 25 +++---- packages/sdk/src/data/edge-templates.ts | 18 +++++ packages/sdk/src/features/diagram/diagram.tsx | 8 ++- .../diagram/hooks/use-edge-types.spec.ts | 65 +++++++++++++++++++ .../features/diagram/hooks/use-edge-types.tsx | 44 +++++++++++++ packages/sdk/src/index.ts | 1 + .../sdk/src/workflow-builder-root/index.ts | 1 + .../workflow-builder-root.tsx | 3 + .../workflow-builder-root.types.ts | 23 +++++++ 10 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 .changeset/wb-220-edge-templates.md create mode 100644 packages/sdk/src/data/edge-templates.ts create mode 100644 packages/sdk/src/features/diagram/hooks/use-edge-types.spec.ts create mode 100644 packages/sdk/src/features/diagram/hooks/use-edge-types.tsx diff --git a/.changeset/wb-220-edge-templates.md b/.changeset/wb-220-edge-templates.md new file mode 100644 index 000000000..edc45a2b5 --- /dev/null +++ b/.changeset/wb-220-edge-templates.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +feat: add `edgeTemplates` prop on `` for custom edge renderers — the edge mirror of `nodeTemplates`. Pass a `{ [edgeType]: Component }` map where each component takes ReactFlow's `EdgeProps`; edges whose `type` matches a key render with your component, and unregistered types fall back to the built-in `labelEdge`. Unlike node templates, edge templates need no adapter (the built-in edges already take `EdgeProps` directly), so the consumer component drops straight into ReactFlow's edge-type map. Exports the new `WorkflowBuilderEdgeTemplates` type from the package barrel. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 75a4a55f2..bf5f78c7e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -87,18 +87,19 @@ Each subcomponent is also exported under a named alias (`WorkflowBuilderTopBar`, ## `` props -| Prop | Type | Description | -| ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `nodeTypes` | `PaletteItemOrGroup[]` | Node type definitions. Appear in the palette and drive validation. **Must be a stable reference** — declare at module scope or memoize. | -| `nodeTemplates` | `WorkflowBuilderNodeTemplates` | Per-node-type custom renderers. Map of `data.type` → React component, overriding the default node renderer for that type. **Stable reference required** (same as `nodeTypes`). | -| `diagramTemplates` | `TemplateModel[]` | Diagram templates available in the template selector. **Stable reference required** (same as `nodeTypes`). | -| `plugins` | `WorkflowBuilderPlugin[]` | Functions registering decorators. Synchronous, executed once. | -| `jsonForm` | `WorkflowBuilderJsonFormConfig` | Custom JsonForms renderers, cells, translations. | -| `integration` | `WorkflowBuilderIntegration` | Data source / sink. Defaults to `localStorage`. | -| `name` | `string` | Workflow name shown in the header. | -| `layoutDirection` | `'DOWN' \| 'RIGHT'` | Initial flow direction. | -| `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. | -| `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. | +| Prop | Type | Description | +| ------------------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `nodeTypes` | `PaletteItemOrGroup[]` | Node type definitions. Appear in the palette and drive validation. **Must be a stable reference** — declare at module scope or memoize. | +| `nodeTemplates` | `WorkflowBuilderNodeTemplates` | Per-node-type custom renderers. Map of `data.type` → React component, overriding the default node renderer for that type. **Stable reference required** (same as `nodeTypes`). | +| `edgeTemplates` | `WorkflowBuilderEdgeTemplates` | Per-edge-type custom renderers. Map of `edge.type` → React component taking ReactFlow `EdgeProps`, overriding the built-in `labelEdge`. Unregistered types fall back to the default edge. **Stable reference required** (same as `nodeTypes`). | +| `diagramTemplates` | `TemplateModel[]` | Diagram templates available in the template selector. **Stable reference required** (same as `nodeTypes`). | +| `plugins` | `WorkflowBuilderPlugin[]` | Functions registering decorators. Synchronous, executed once. | +| `jsonForm` | `WorkflowBuilderJsonFormConfig` | Custom JsonForms renderers, cells, translations. | +| `integration` | `WorkflowBuilderIntegration` | Data source / sink. Defaults to `localStorage`. | +| `name` | `string` | Workflow name shown in the header. | +| `layoutDirection` | `'DOWN' \| 'RIGHT'` | Initial flow direction. | +| `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. | +| `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. | Full reference (every public type, hook, and helper): . diff --git a/packages/sdk/src/data/edge-templates.ts b/packages/sdk/src/data/edge-templates.ts new file mode 100644 index 000000000..511581e9c --- /dev/null +++ b/packages/sdk/src/data/edge-templates.ts @@ -0,0 +1,18 @@ +import type { EdgeProps } from '@xyflow/react'; +import type { ComponentType } from 'react'; + +import type { WorkflowBuilderEdge } from '../node/node-data'; + +type EdgeTemplateComponent = ComponentType>; + +const EMPTY: Readonly> = Object.freeze({}); + +let customEdgeTemplates: Record = EMPTY; + +export function setCustomEdgeTemplates(templates: Record | null): void { + customEdgeTemplates = templates ?? EMPTY; +} + +export function getCustomEdgeTemplates(): Record { + return customEdgeTemplates; +} diff --git a/packages/sdk/src/features/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx index a90c47fb2..fbce3554c 100644 --- a/packages/sdk/src/features/diagram/diagram.tsx +++ b/packages/sdk/src/features/diagram/diagram.tsx @@ -25,8 +25,8 @@ import { useDeleteConfirmation } from '../modals/delete-confirmation/use-delete- import { withOptionalComponentPlugins } from '../plugins-core/adapters/adapter-components'; import { deleteKeyCode } from './const'; import { SNAP_GRID, SNAP_IS_ACTIVE } from './diagram.const'; -import { LabelEdge } from './edges/label-edge/label-edge'; import { TemporaryEdge } from './edges/temporary-edge/temporary-edge'; +import { useEdgeTypes } from './hooks/use-edge-types'; import { useNodeTypes } from './hooks/use-node-types'; import { callNodeChangedListeners } from './listeners/node-changed-listeners'; import { callNodeDragStartListeners } from './listeners/node-drag-start-listeners'; @@ -134,7 +134,11 @@ function DiagramContainerComponent({ edgeTypes = {} }: DiagramContainerProps) { [onSelectionChange], ); - const diagramEdgeTypes = useMemo(() => ({ labelEdge: LabelEdge, ...edgeTypes }), [edgeTypes]); + // `useEdgeTypes` resolves the built-in default plus any Root-level + // `edgeTemplates`. The local `edgeTypes` prop (direct DiagramContainer mount) + // is merged last so an explicit per-mount override still wins. + const resolvedEdgeTypes = useEdgeTypes(); + const diagramEdgeTypes = useMemo(() => ({ ...resolvedEdgeTypes, ...edgeTypes }), [resolvedEdgeTypes, edgeTypes]); const onBeforeDelete: OnBeforeDelete = useCallback( async ({ nodes, edges }) => { diff --git a/packages/sdk/src/features/diagram/hooks/use-edge-types.spec.ts b/packages/sdk/src/features/diagram/hooks/use-edge-types.spec.ts new file mode 100644 index 000000000..f4e9450ec --- /dev/null +++ b/packages/sdk/src/features/diagram/hooks/use-edge-types.spec.ts @@ -0,0 +1,65 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { setCustomEdgeTemplates } from '../../../data/edge-templates'; + +// Mock the built-in default edge so the test doesn't pull the full edge +// rendering stack (xyflow path math, CSS side effects). +vi.mock('../edges/label-edge/label-edge', () => ({ LabelEdge: () => null })); + +const { useEdgeTypes } = await import('./use-edge-types'); + +function Noop() { + return null; +} + +describe('useEdgeTypes', () => { + afterEach(() => { + setCustomEdgeTemplates(null); + vi.restoreAllMocks(); + }); + + it('registers the built-in labelEdge by default', () => { + const { result } = renderHook(() => useEdgeTypes()); + + expect(result.current['labelEdge']).toBeDefined(); + }); + + it('registers a custom edge template under its own key, unwrapped', () => { + setCustomEdgeTemplates({ animated: Noop }); + + const { result } = renderHook(() => useEdgeTypes()); + + // No adapter: the consumer's component is registered as-is (identity), + // unlike node templates which are wrapped to inject computed props. + expect(result.current['animated']).toBe(Noop); + expect(result.current['labelEdge']).toBeDefined(); + }); + + it('warns in dev when a custom key collides with the built-in labelEdge', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setCustomEdgeTemplates({ labelEdge: Noop }); + + renderHook(() => useEdgeTypes()); + + expect(spy).toHaveBeenCalledWith(expect.stringContaining('"labelEdge"')); + }); + + it('does not warn for non-colliding custom edge keys', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setCustomEdgeTemplates({ animated: Noop }); + + renderHook(() => useEdgeTypes()); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('returns a stable reference across re-renders when no custom templates exist', () => { + const { result, rerender } = renderHook(() => useEdgeTypes()); + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx b/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx new file mode 100644 index 000000000..274a0d23f --- /dev/null +++ b/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx @@ -0,0 +1,44 @@ +import type { EdgeTypes } from '@xyflow/react'; +import { useMemo } from 'react'; + +import { getCustomEdgeTemplates } from '../../../data/edge-templates'; +import { LabelEdge } from '../edges/label-edge/label-edge'; + +const BUILT_IN_KEYS: ReadonlySet = new Set(['labelEdge']); + +/** + * Resolves the ReactFlow `edgeTypes` map: the built-in `'labelEdge'` default + * merged with any consumer-provided `edgeTemplates`. Mirrors {@link useNodeTypes} + * but without an adapter — edge templates take ReactFlow's `EdgeProps` directly + * (the built-in edges do too), so there are no computed props to inject and the + * consumer's component drops straight into the map. + * + * An edge whose `type` matches no key falls back to ReactFlow's default edge, + * same as nodes fall back to their built-in renderers. + */ +export function useEdgeTypes(): EdgeTypes { + // Read outside useMemo so the dep stays referentially stable across renders. + // When no consumer templates exist, getCustomEdgeTemplates() returns the same + // frozen EMPTY object every call (see ../../../data/edge-templates), which is + // what keeps this memo from handing ReactFlow a fresh edgeTypes object — and + // emitting its "it looks like you've created a new edgeTypes object" warning — + // on every render. + const custom = getCustomEdgeTemplates(); + + return useMemo(() => { + if (import.meta.env.DEV) { + for (const key of Object.keys(custom)) { + if (BUILT_IN_KEYS.has(key)) { + console.warn( + `[workflow-builder] edgeTemplates key "${key}" overrides a built-in renderer. Pick a unique key unless the override is intentional.`, + ); + } + } + } + + return { + labelEdge: LabelEdge, + ...custom, + }; + }, [custom]); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 8aa54f376..8b0a5a11a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -55,6 +55,7 @@ export type { WorkflowBuilderIntegration, WorkflowBuilderJsonFormConfig, WorkflowBuilderNodeTemplates, + WorkflowBuilderEdgeTemplates, } from './workflow-builder-root'; // ============================================================================= diff --git a/packages/sdk/src/workflow-builder-root/index.ts b/packages/sdk/src/workflow-builder-root/index.ts index ad32d9d8a..4e58239d2 100644 --- a/packages/sdk/src/workflow-builder-root/index.ts +++ b/packages/sdk/src/workflow-builder-root/index.ts @@ -1,5 +1,6 @@ export { WorkflowBuilderRoot } from './workflow-builder-root'; export type { + WorkflowBuilderEdgeTemplates, WorkflowBuilderIntegration, WorkflowBuilderJsonFormConfig, WorkflowBuilderNodeTemplates, diff --git a/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx b/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx index e7152ce32..64cb9c02d 100644 --- a/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx +++ b/packages/sdk/src/workflow-builder-root/workflow-builder-root.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef } from 'react'; import { registerPluginTranslation } from '../features/plugins-core/adapters/adapter-i18n'; +import { setCustomEdgeTemplates } from '../data/edge-templates'; import { setCustomNodeTemplates } from '../data/node-templates'; import { setCustomPaletteNodes } from '../data/palette'; import { setCustomTemplates } from '../data/templates'; @@ -47,6 +48,7 @@ import type { export function WorkflowBuilderRoot({ nodeTypes, nodeTemplates, + edgeTemplates, diagramTemplates, plugins, jsonForm, @@ -112,6 +114,7 @@ export function WorkflowBuilderRoot({ setCustomPaletteNodes(nodeTypes ?? null); setCustomTemplates(diagramTemplates ?? null); setCustomNodeTemplates(nodeTemplates ?? null); + setCustomEdgeTemplates(edgeTemplates ?? null); const { strategy, endpoints, onDataSave } = resolveIntegration(integration); diff --git a/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts b/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts index be9884d61..3711f0d67 100644 --- a/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts +++ b/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts @@ -1,3 +1,4 @@ +import type { EdgeProps } from '@xyflow/react'; import type { ComponentType, PropsWithChildren } from 'react'; import type { WorkflowNodeTemplateProps } from '../features/diagram/nodes/workflow-node-template/workflow-node-template'; @@ -20,6 +21,20 @@ import type { OnSaveExternal } from '../types/integration'; */ export type WorkflowBuilderNodeTemplates = Record>; +/** + * Per-edge-type custom renderer registry. Keys are `edge.type` values; values + * are React components that take ReactFlow's {@link EdgeProps} (typed for + * {@link WorkflowBuilderEdge}) and replace the default edge renderer for + * matching edges. The mirror of {@link WorkflowBuilderNodeTemplates} for edges. + * + * Unlike node templates, edge templates need no adapter: the built-in edges + * already take `EdgeProps` directly, so a consumer component drops straight + * into ReactFlow's edge-type map with no wrapping. + * + * @category Components + */ +export type WorkflowBuilderEdgeTemplates = Record>>; + /** * Plugin initializer — a synchronous function invoked exactly once on the * first mount of ``. Inside the body call one of the @@ -96,6 +111,14 @@ export type WorkflowBuilderRootProps = PropsWithChildren<{ * **Must be a stable reference** (same rationale as `nodeTypes`). */ nodeTemplates?: WorkflowBuilderNodeTemplates; + /** + * Per-edge-type custom renderers — map of `edge.type` → React component. + * Overrides the built-in `'labelEdge'` for the matching edge type; edges + * whose type isn't registered fall back to the default edge. + * + * **Must be a stable reference** (same rationale as `nodeTemplates`). + */ + edgeTemplates?: WorkflowBuilderEdgeTemplates; /** * Diagram templates available in the template selector. * **Must be a stable reference** (same rationale as `nodeTypes`). From ab3cb35dd7266badf1777919dec400e2b3b62791 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Tue, 2 Jun 2026 10:10:45 +0200 Subject: [PATCH 2/7] feat(demo): custom edge example via edgeTemplates (WB-220) --- apps/demo/src/app/app.tsx | 4 +++ .../components/dashed-edge/dashed-edge.tsx | 33 +++++++++++++++++++ .../src/app/data/templates/simple-flow.ts | 4 ++- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 apps/demo/src/app/components/dashed-edge/dashed-edge.tsx diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index 79c837c0c..5c8bae6de 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -2,6 +2,7 @@ import { WorkflowBuilder } from '@workflowbuilder/sdk'; import '@workflowbuilder/sdk/style.css'; +import { DashedEdge } from './components/dashed-edge/dashed-edge'; import { MultiPortNodeTemplate } from './components/multi-port-node/multi-port-node-template'; import { demoPaletteItems } from './data/palette'; import { demoTemplates } from './data/templates'; @@ -26,6 +27,9 @@ export function App() { nodeTemplates={{ 'multi-port': MultiPortNodeTemplate, }} + edgeTemplates={{ + dashed: DashedEdge, + }} diagramTemplates={demoTemplates} plugins={[ demoPlugin, diff --git a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx new file mode 100644 index 000000000..58f3e0a02 --- /dev/null +++ b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx @@ -0,0 +1,33 @@ +import { EnhancedBaseEdge } from '@workflowbuilder/sdk'; +import { type EdgeProps, getSmoothStepPath } from '@xyflow/react'; + +/** + * Demo custom edge. Registered on `` under + * the `'dashed'` key, so any edge with `type: 'dashed'` renders with this + * dashed accent stroke instead of the built-in `labelEdge`. + * + * Mirrors the `multi-port` node-template example: a consumer component that + * takes ReactFlow's `EdgeProps` directly (no SDK adapter) and reuses the + * exported `EnhancedBaseEdge` for a wide hit target. + */ +export function DashedEdge({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + id, +}: EdgeProps) { + const [path] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }); + + return ( + + ); +} diff --git a/apps/demo/src/app/data/templates/simple-flow.ts b/apps/demo/src/app/data/templates/simple-flow.ts index b760d458f..4c5d4a687 100644 --- a/apps/demo/src/app/data/templates/simple-flow.ts +++ b/apps/demo/src/app/data/templates/simple-flow.ts @@ -299,7 +299,9 @@ const defaultDiagram: DiagramModel = { target: 'da47caa9-c695-47bb-be52-b30bb8a6be6d', targetHandle: 'target', zIndex: 0, - type: 'labelEdge', + // Custom edge type registered via `edgeTemplates` in app.tsx — renders + // the demo's DashedEdge instead of the built-in labelEdge. + type: 'dashed', id: 'xy-edge__440ccd46-0f50-4e35-ae74-64fee988a4f6440ccd46-0f50-4e35-ae74-64fee988a4f6:source-da47caa9-c695-47bb-be52-b30bb8a6be6dda47caa9-c695-47bb-be52-b30bb8a6be6d:target', }, { From 3e98d947db58e95e48b016f78addd7467711855f Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Wed, 3 Jun 2026 09:51:09 +0200 Subject: [PATCH 3/7] fix(demo): reflect selection/hover state in custom DashedEdge (WB-220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo custom edge painted a fixed stroke, so it didn't highlight on select/hover like the built-in edges — a misleading reference. Delegate styling to the SDK's exported useLabelEdgeHover (the same hook LabelEdge uses), keeping only strokeDasharray on top. Selected/hover now match the built-in edge while the edge stays visually dashed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/dashed-edge/dashed-edge.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx index 58f3e0a02..5d51bae5e 100644 --- a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx +++ b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx @@ -1,33 +1,29 @@ -import { EnhancedBaseEdge } from '@workflowbuilder/sdk'; +import { EnhancedBaseEdge, useLabelEdgeHover } from '@workflowbuilder/sdk'; import { type EdgeProps, getSmoothStepPath } from '@xyflow/react'; /** * Demo custom edge. Registered on `` under - * the `'dashed'` key, so any edge with `type: 'dashed'` renders with this - * dashed accent stroke instead of the built-in `labelEdge`. + * the `'dashed'` key, so any edge with `type: 'dashed'` renders dashed instead + * of the built-in `labelEdge`. * * Mirrors the `multi-port` node-template example: a consumer component that * takes ReactFlow's `EdgeProps` directly (no SDK adapter) and reuses the - * exported `EnhancedBaseEdge` for a wide hit target. + * exported `EnhancedBaseEdge` for a wide hit target. Selection and hover are + * delegated to the SDK's `useLabelEdgeHover` so this edge highlights exactly + * like the built-in one — it just keeps a dashed stroke on top. */ export function DashedEdge({ + id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, - markerEnd, - id, + selected, }: EdgeProps) { + const { style } = useLabelEdgeHover({ id, isSelected: selected }); const [path] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }); - return ( - - ); + return ; } From 0a6f4b7e85aab506a6cc3173d0a82b4919a6bed9 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Wed, 3 Jun 2026 12:37:23 +0200 Subject: [PATCH 4/7] docs(sdk): document customizing edge selection (WB-220) --- apps/demo/src/app/components/dashed-edge/dashed-edge.tsx | 4 ++++ packages/sdk/README.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx index 5d51bae5e..a5b828422 100644 --- a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx +++ b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx @@ -11,6 +11,10 @@ import { type EdgeProps, getSmoothStepPath } from '@xyflow/react'; * exported `EnhancedBaseEdge` for a wide hit target. Selection and hover are * delegated to the SDK's `useLabelEdgeHover` so this edge highlights exactly * like the built-in one — it just keeps a dashed stroke on top. + * + * To diverge from the built-in selection look, add a `selected` branch to the + * `style` below, or restyle every edge globally via the `--ax-public-edge-color-select` + * CSS variable. See the SDK README, "Custom edges and selection". */ export function DashedEdge({ id, diff --git a/packages/sdk/README.md b/packages/sdk/README.md index bf5f78c7e..b51dddcdc 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -101,6 +101,10 @@ Each subcomponent is also exported under a named alias (`WorkflowBuilderTopBar`, | `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. | | `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. | +### Custom edges and selection + +A custom edge is a plain component that receives ReactFlow `EdgeProps`, so you own how it looks in every state. To match the built-in selection / hover look, call the exported `useLabelEdgeHover({ id, isSelected })` hook and spread its `style` onto your path; add `strokeDasharray`, width, or your own `selected` branch on top. To restyle selection without touching code, redefine the CSS variable the built-in edges read: `--ax-public-edge-color-select` (globally, for every edge). Note the resolved `style` is inline, so a per-edge CSS class will not override it; use the CSS variable or merge into the `style` object instead. + Full reference (every public type, hook, and helper): . ## Persistence From 58ac6481582a0f996aa4a4b0a597ef12986bceca Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Wed, 3 Jun 2026 12:58:58 +0200 Subject: [PATCH 5/7] docs(sdk): address review findings on edgeTemplates (WB-220) --- apps/demo/src/app/app.tsx | 19 +++++++++++++------ packages/sdk/README.md | 9 ++++++++- packages/sdk/src/features/diagram/diagram.tsx | 7 ++++++- .../features/diagram/hooks/use-edge-types.tsx | 3 +-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index 5c8bae6de..03828292a 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -1,4 +1,5 @@ import { WorkflowBuilder } from '@workflowbuilder/sdk'; +import type { WorkflowBuilderEdgeTemplates, WorkflowBuilderNodeTemplates } from '@workflowbuilder/sdk'; import '@workflowbuilder/sdk/style.css'; @@ -19,17 +20,23 @@ import { plugin as undoRedoPlugin } from './plugins/undo-redo/plugin-exports'; import { plugin as validationPlugin } from './plugins/validation/plugin-exports'; import { plugin as widgetsPlugin } from './plugins/widgets/plugin-exports'; +// Declared at module scope so the references stay stable across renders, as +// `` requires for nodeTemplates / edgeTemplates. +const nodeTemplates = { + 'multi-port': MultiPortNodeTemplate, +} satisfies WorkflowBuilderNodeTemplates; + +const edgeTemplates = { + dashed: DashedEdge, +} satisfies WorkflowBuilderEdgeTemplates; + export function App() { return ( ; +``` + +Because that resolved `style` is applied inline, a per-edge CSS class will not override it; restyle in the `style` object instead. There is also a global escape hatch: the edges read the `--ax-public-edge-color-select` CSS variable for the selected stroke, so redefining it recolors selection for every edge at once. That token comes from the bundled `@synergycodes/overflow-ui` design system rather than this SDK, so treat it as a convenience that may change across dependency bumps; the per-component `style` route above is the stable contract. Full reference (every public type, hook, and helper): . diff --git a/packages/sdk/src/features/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx index fbce3554c..a880b3bc3 100644 --- a/packages/sdk/src/features/diagram/diagram.tsx +++ b/packages/sdk/src/features/diagram/diagram.tsx @@ -40,7 +40,12 @@ import { diagramStateSelector } from './selectors'; * @category Components */ export type DiagramContainerProps = { - /** Extra edge types forwarded to ReactFlow alongside the built-in `'labelEdge'`. */ + /** + * Extra edge types forwarded to ReactFlow alongside the built-in `'labelEdge'` + * and any Root-level `edgeTemplates`. Merged last, so a key here intentionally + * overrides those (this is the direct-mount escape hatch, hence no collision + * warning); prefer `` for app-wide edges. + */ edgeTypes?: EdgeTypes; }; diff --git a/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx b/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx index 274a0d23f..dcfee8194 100644 --- a/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx +++ b/packages/sdk/src/features/diagram/hooks/use-edge-types.tsx @@ -13,8 +13,7 @@ const BUILT_IN_KEYS: ReadonlySet = new Set(['labelEdge']); * (the built-in edges do too), so there are no computed props to inject and the * consumer's component drops straight into the map. * - * An edge whose `type` matches no key falls back to ReactFlow's default edge, - * same as nodes fall back to their built-in renderers. + * An edge whose `type` matches no key falls back to ReactFlow's default edge. */ export function useEdgeTypes(): EdgeTypes { // Read outside useMemo so the dep stays referentially stable across renders. From 7e02dfc607dd14d923dee654a2b5285ede2a8252 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Mon, 8 Jun 2026 14:14:32 +0200 Subject: [PATCH 6/7] docs(sdk): clarify edge-types naming in DiagramContainer (WB-220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the vague `resolvedEdgeTypes` local to `baseEdgeTypes` and correct the comment: the value is the built-in `labelEdge` renderer plus app-wide `edgeTemplates` from , which the per-mount `edgeTypes` prop overrides — not plugin- or ELK-injected edges. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/sdk/src/features/diagram/diagram.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/features/diagram/diagram.tsx b/packages/sdk/src/features/diagram/diagram.tsx index a880b3bc3..842263303 100644 --- a/packages/sdk/src/features/diagram/diagram.tsx +++ b/packages/sdk/src/features/diagram/diagram.tsx @@ -139,11 +139,11 @@ function DiagramContainerComponent({ edgeTypes = {} }: DiagramContainerProps) { [onSelectionChange], ); - // `useEdgeTypes` resolves the built-in default plus any Root-level - // `edgeTemplates`. The local `edgeTypes` prop (direct DiagramContainer mount) - // is merged last so an explicit per-mount override still wins. - const resolvedEdgeTypes = useEdgeTypes(); - const diagramEdgeTypes = useMemo(() => ({ ...resolvedEdgeTypes, ...edgeTypes }), [resolvedEdgeTypes, edgeTypes]); + // Built-in edge renderers (`labelEdge`) plus any app-wide `edgeTemplates` + // passed to ``. The local `edgeTypes` prop (direct + // DiagramContainer mount) is merged last so a per-mount override still wins. + const baseEdgeTypes = useEdgeTypes(); + const diagramEdgeTypes = useMemo(() => ({ ...baseEdgeTypes, ...edgeTypes }), [baseEdgeTypes, edgeTypes]); const onBeforeDelete: OnBeforeDelete = useCallback( async ({ nodes, edges }) => { From 7c7633e93cd5121824e7fda07acae3c2ea34fd91 Mon Sep 17 00:00:00 2001 From: Piotr Blaszczyk Date: Wed, 10 Jun 2026 18:55:26 +0200 Subject: [PATCH 7/7] docs(sdk): refine edgeTemplates docs, trim changeset --- .changeset/wb-220-edge-templates.md | 2 +- .../src/app/components/dashed-edge/dashed-edge.tsx | 8 ++++++++ packages/sdk/README.md | 11 ----------- .../workflow-builder-root.types.ts | 13 +++++++++++-- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.changeset/wb-220-edge-templates.md b/.changeset/wb-220-edge-templates.md index edc45a2b5..31f9d5665 100644 --- a/.changeset/wb-220-edge-templates.md +++ b/.changeset/wb-220-edge-templates.md @@ -2,4 +2,4 @@ '@workflowbuilder/sdk': minor --- -feat: add `edgeTemplates` prop on `` for custom edge renderers — the edge mirror of `nodeTemplates`. Pass a `{ [edgeType]: Component }` map where each component takes ReactFlow's `EdgeProps`; edges whose `type` matches a key render with your component, and unregistered types fall back to the built-in `labelEdge`. Unlike node templates, edge templates need no adapter (the built-in edges already take `EdgeProps` directly), so the consumer component drops straight into ReactFlow's edge-type map. Exports the new `WorkflowBuilderEdgeTemplates` type from the package barrel. +feat: add `edgeTemplates` prop on `` for custom edge renderers. Pass a `{ [edgeType]: Component }` map of components taking ReactFlow's `EdgeProps`; edges whose `type` matches a key render with your component, and unregistered types fall back to the built-in `labelEdge`. Also exports the `WorkflowBuilderEdgeTemplates` type. diff --git a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx index a5b828422..c39c0c21f 100644 --- a/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx +++ b/apps/demo/src/app/components/dashed-edge/dashed-edge.tsx @@ -12,6 +12,14 @@ import { type EdgeProps, getSmoothStepPath } from '@xyflow/react'; * delegated to the SDK's `useLabelEdgeHover` so this edge highlights exactly * like the built-in one — it just keeps a dashed stroke on top. * + * This is a minimal styling example, not a full `labelEdge` replacement. It + * deliberately skips two things the built-in does: the self-connecting loop + * when `source === target` (a self-loop drawn with this type collapses to a + * degenerate path), and rendering `data.label` / `data.icon`. If you need + * either, branch on `source === target` and delegate to the exported + * `SelfConnectingEdge`, and render your own label. See the SDK README, + * "Custom edges and selection". + * * To diverge from the built-in selection look, add a `selected` branch to the * `style` below, or restyle every edge globally via the `--ax-public-edge-color-select` * CSS variable. See the SDK README, "Custom edges and selection". diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 30ed4c799..bf5f78c7e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -101,17 +101,6 @@ Each subcomponent is also exported under a named alias (`WorkflowBuilderTopBar`, | `initialNodes` | `WorkflowBuilderNode[]` | Starting diagram nodes. | | `initialEdges` | `WorkflowBuilderEdge[]` | Starting diagram edges. | -### Custom edges and selection - -A custom edge is a plain component that receives ReactFlow `EdgeProps`, so you own how it looks in every state, including selection. The recommended way to customize selection is in the component itself: branch on the `selected` prop and set your own `style`. To also get the built-in hover feel for free, call the exported `useLabelEdgeHover({ id, isSelected })` hook, spread its `style` onto your path, then layer your own keys (`strokeDasharray`, width, a `selected` branch) on top. - -```tsx -const { style } = useLabelEdgeHover({ id, isSelected: selected }); -return ; -``` - -Because that resolved `style` is applied inline, a per-edge CSS class will not override it; restyle in the `style` object instead. There is also a global escape hatch: the edges read the `--ax-public-edge-color-select` CSS variable for the selected stroke, so redefining it recolors selection for every edge at once. That token comes from the bundled `@synergycodes/overflow-ui` design system rather than this SDK, so treat it as a convenience that may change across dependency bumps; the per-component `style` route above is the stable contract. - Full reference (every public type, hook, and helper): . ## Persistence diff --git a/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts b/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts index 3711f0d67..a2002bf98 100644 --- a/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts +++ b/packages/sdk/src/workflow-builder-root/workflow-builder-root.types.ts @@ -25,7 +25,7 @@ export type WorkflowBuilderNodeTemplates = Record