v0.2.1-alpha Migration Guide
Focus: This is the single upgrade path from the v0.1.x line. It covers a broad correctness-and-performance pass across the whole canvas — the measurement/layout lifecycle, edge routing, viewport culling, undo/redo history semantics, interaction and input fixes, and an observable fitView — plus the end-to-end animation subsystem overhaul that was staged as a separate v0.2.0-alpha and never tagged (folded in below). Most of the surface is additive; the behavior shifts are alpha-acceptable and documented with an escape hatch wherever one exists.
Each behavior change explains what changed, why, a before/after, and an escape hatch for code that needs the old behavior. New capabilities are listed near the end — they don't require migration, but are worth knowing about.
New FlowNode properties
All optional; existing node definitions continue to work unchanged.
fixedDimensions?: boolean— opt-in to inlinestyle.heighton leaf nodes AND opt out of ResizeObserver updates. Use for decorative or fixed-size nodes. Auto-promoted when the system drives a height: resize drag, compute output, and animation ofdimensions.heightall set this totrueon the affected node.resizeObserver?: boolean— defaulttrue. Setfalseto exclude a node from the shared ResizeObserver (e.g., annotation nodes or decorative overlays where measurement is noise).minDimensions?: Partial<Dimensions>— lower bound for observed dimensions. Eitherwidthorheightmay be omitted ("no lower bound on that axis"). Applied by the ResizeObserver before updatingnode.dimensions.maxDimensions?: Partial<Dimensions>— upper bound. UseInfinityfor unbounded.
New canvas method
canvas.batch(fn)
Suspend layout reconciliation during fn, then run a single reconciliation pass after. Ref-counted (safe to nest — inner batches join the outer). Returns fn's return value. Reconciles on throw via try/finally so a throwing fn still reconciles before the error propagates.
// Adding 100 nodes that share a parent would previously trigger 100 layouts.
// With batch(), exactly one layout runs per affected parent.
$flow.batch(() => {
$flow.addNodes(allNodes);
$flow.addEdges(allEdges);
});
Access as canvas.batch(...) in static code, or $flow.batch(...) from Alpine scope inside <x-flow> (WireFlow) or x-data="flowCanvas(...)" elements.
Behavior changes — measurement & layout
Leaf nodes no longer get inline height
What changed. Before v0.2.1-alpha, every node received style.height = dimensions.height + 'px'. Leaf nodes with rich templated content (Alpine x-if / x-for templates, form controls) had their height locked to the initial (pre-render) measurement, clipping content that grew after templates populated.
Inline height is now applied only when one of three container signals is present — the node has childLayout (auto-layout container), the node is a parent (some other node references it via parentId), or fixedDimensions: true. Plain leaf nodes let content determine height; the shared ResizeObserver captures the natural rendered height back into node.dimensions for parent layout calculations.
{ id: 'note', position: { x: 0, y: 0 }, data: { label: 'long text…' } }
// BEFORE: inline height written → text clipped at the fixed height
// AFTER: content drives leaf height
{ id: 'note', fixedDimensions: true, /* … */ } // restore fixed sizing
Escape hatch. Add fixedDimensions: true to nodes whose explicit height must persist. Three system paths auto-promote it: resize drag, compute() output writing a height, and animation of dimensions.height.
Layout lifecycle is now reactive
- Shared ResizeObserver: one
ResizeObservertracks every node element. When rendered dimensions change,node.dimensionsupdates automatically and the parent re-lays out. No more stale measurements. - Reactive
childLayout: mutatingnode.childLayout.columns(orgap,padding,headerHeight,direction,stretch) triggers re-layout automatically — no manuallayoutChildren(parentId). addNodesauto-layouts parents:addNodesnow re-lays out affected parent containers, consistent withremoveNodes.
Escape hatch. If your code called canvas.layoutChildren(parentId) manually after mutations, you can remove those calls. Keep them only if they cover a path the new reactivity doesn't (rare).
Layout dedup — at most one layoutChildren per parent per frame
Multiple triggers in the same frame (add + observer callback) collapse to a single layoutChildren call per parent per animation frame. Frame-aligned, not time-based. Includes a cross-frame loop safety net: a parent laid out in >5 consecutive frames is suppressed with a console.warn until the next user mutation (addNodes, removeNodes) clears the counter.
Escape hatch. None — this is a strict de-duplication. If tests or tooling count layoutChildren calls and expected the old duplicate-call behavior, expect lower counts (assertions expecting 2× per mutation should now expect 1).
Dimensions are client-local, never synced
node.dimensions now updates reactively from the ResizeObserver. Different clients may measure different values (fonts, browser zoom, DPI). Dimensions are excluded from collab sync — they're local state, not part of the canonical document. This only matters if you're using Yjs collaboration.
Auto-layout excludes parented nodes
layout(), forceLayout(), treeLayout(), and elkLayout() now exclude nodes with parentId by default — their positions are managed by childLayout, not top-level auto-layout.
canvas.layout();
// BEFORE: children with parentId were re-laid-out by the top-level pass,
// fighting their parent's childLayout
// AFTER: skipped by layout()/forceLayout()/treeLayout()/elkLayout()
canvas.layout({ includeChildren: true }); // restore old behavior
Escape hatch. { includeChildren: true }.
Node-content centering moved from structural CSS to the default theme
What changed. structural.css no longer centers node content for every theme — only theme-default.css (and theme-flux.css) do. This keeps structural CSS opinion-free so custom themes control alignment.
/* BEFORE: structural.css centered node content for every theme */
/* AFTER: only theme-default.css does — custom themes declare it: */
.my-theme .flow-node-content { text-align: center; }
Escape hatch. Custom themes that relied on the structural rule must add the text-align: center declaration themselves.
Behavior changes — history & undo/redo
Selection is no longer undoable
What changed. Selecting nodes (clicks, row selection) no longer pushes history snapshots.
// user clicks node A, clicks node B, presses Ctrl+Z
// BEFORE: each click pushed a byte-identical snapshot — undo "did nothing"
// visibly, twice, before reaching the last real edit
// AFTER: clicks capture nothing; undo goes straight to the last structural
// change (move/add/remove/rename)
Escape hatch. None — selection was never a meaningful undo step. Structural operations (move/add/remove/rename) still capture as before.
Identical consecutive history states are deduped
canvas.updateNode('a', { position: { x: 100, y: 50 } }); // already there
// BEFORE: pushed a duplicate-of-top snapshot → one wasted undo step
// AFTER: snapshot equals stack top → no undo step added
Snapshots are stored as JSON strings and compared against the current stack top.
Node drag captures history on commit, not pointer-down
// user presses pointer on a node and releases without moving
// BEFORE: capture fired at pointer-down → click-to-select = undo entry
// AFTER: capture fires at drag commit, only if the node moved
Arrow-key nudge: one capture per physical keypress
// user holds ArrowRight — OS auto-repeat fires ~30 keydowns
// BEFORE: 30 undo entries; undoing the nudge = 30 × Ctrl+Z
// AFTER: 1 entry per physical press; empty selection captures nothing
Undo/redo now emit a restore event
What changed. undo() / redo() now dispatch the flow-restore DOM event (config callback onRestore), which previously fired only for fromObject(). External observers that synced on fromObject but drifted during undo/redo now stay in sync.
el.addEventListener('flow-restore', (e) => { /* sync sidebar, server… */ });
// BEFORE: fired only for fromObject() — undo()/redo() were silent
// AFTER: also fires for undo()/redo(), with
// e.detail = { nodes, edges, origin: 'undo' | 'redo' }
Behavior changes — change & restore event payloads
restore payload field: source → origin
What changed. A dev-only interim source tag (never shipped in a release) becomes public for the first time as origin. No released user has ever seen source.
el.addEventListener('flow-restore', (e) => {
// dev-only interim (never released): e.detail.source → 'undo' | 'redo';
// fromObject() carried no tag at all
// SHIPS AS: e.detail.origin → 'undo' | 'redo' | 'load'
// ('load' = fromObject / $reset / $clear)
});
nodes-change / edges-change gained origin
What changed. Both events now carry an origin discriminator so you can tell a user drop from your own API write. Runtime-additive — listeners ignoring the new key are unaffected.
el.addEventListener('flow-nodes-change', (e) => {
// BEFORE: e.detail = { type: 'add', nodes: [...] } — no way to tell a user
// drop from your own API write
// AFTER: e.detail = { type: 'add', nodes: [...],
// origin: 'drop' | 'paste' | 'api' | 'load' }
if (e.detail.origin !== 'api') saveToServer(e.detail.nodes);
});
canvas.addNodes(nodes, { source: 'load' }); // mutators can stamp it
Escape hatch. None needed. WireFlow handlers receive origin automatically through the wire bridge.
Behavior changes — viewport
canvas.viewport settles on the next animation frame
What changed. Zoom/pan side-effects coalesce to one flush per requestAnimationFrame. Coordinate helpers (screenToFlowPosition, flowToScreenPosition) read the live value internally, and gesture-end still commits synchronously — so this is mostly test-facing.
canvas.setViewport({ x: 0, y: 0, zoom: 2 });
// BEFORE: canvas.viewport.zoom === 2 immediately
// AFTER: canvas.viewport.zoom still holds the old value…
await new Promise(requestAnimationFrame);
canvas.viewport.zoom === 2; // …settles next frame
Escape hatch. None — read the live viewport through the coordinate helpers, or await one frame before asserting on canvas.viewport.
Behavior changes — edge routing
Avoidant edges: rounded corners replace the Catmull-Rom spline
What changed. Every avoidant edge's d string changes. Straight runs are kept and corners are rounded with bounded fillets that cannot overshoot the way the old spline bulged past corners. Catmull-Rom remains only for editable edges.
// BEFORE — one smooth spline through the waypoints (overshoots corners):
M219,79.5 C352,79.5 486,63 620,120 C640,146 655,300 660,530 …
// AFTER — straight runs + in-corner fillets (hugs the route):
M219,79.5 L620,79.5 C640,79.5 660,99.5 660,119.5 L660,941
C660,946.25 665.25,951.5 670.5,951.5 L681,951.5
Escape hatch. None — the fillets are strictly better geometry. Snapshot tests that pinned exact d strings for avoidant edges must be re-baselined.
Detours tie-break deterministically
Length is never traded; among equal-length candidates the router now prefers fewest corners / closest-to-corridor, so specific waypoints may sit elsewhere than on v0.1.2. Always-on, no knob.
Obstacles burying a route endpoint are excluded
What changed. When a node lands on top of an edge's endpoint handle (easy to hit after scramble()), that node is dropped from the edge's obstacle set so the edge still routes.
// scramble() lands node "sessions" on top of users' right-side handle;
// five edges target that handle
// BEFORE: routing failed for those edges → straight bezier through everything,
// stuck until the connected node was dragged
// AFTER: the burying node is dropped from those edges' obstacle set — they
// route around everything else immediately
Obstacle geometry is non-reactive
A programmatic node move that doesn't bump the layout tick won't re-route dependent edges until the next commit point; interactive gestures re-route as before.
Hidden nodes are no longer routing obstacles
canvas.updateNode('big-group', { hidden: true });
// BEFORE: edges still detoured around the invisible node's rect
// AFTER: edges may route straight through where it sits
Drag-simplified edges default on
What changed. During a node drag, incident avoidant/orthogonal edges render a simplified bezier for the duration of the gesture, snapping back to the routed path on drop — instead of re-running full obstacle pathfinding on every pointermove.
// BEFORE: full obstacle pathfinding re-ran for incident edges on every
// pointermove during a node drag
// AFTER: incident avoidant/orthogonal edges render a simplified bezier during
// the gesture, snap back to routed on drop
flowCanvas({ avoidantSimplifyOnDrag: false }); // restore old behavior
Escape hatch. avoidantSimplifyOnDrag: false.
animate() settle re-runs edge geometry
canvas.animate({ nodes: layoutTargets }, { duration: 600 });
// BEFORE: after settling, schema edges stayed detached at node centres with
// avoidant routing lost (worst on a no-op move)
// AFTER: settle re-measures + rebuilds obstacles once — edges snap back to
// real handles and routes
Behavior changes — viewport culling
viewportCulling default false → 'auto'
What changed. Culling now turns on automatically once the node count reaches the auto threshold (150), toggling display:none on off-screen nodes and edges.
flowCanvas({ nodes: twoHundredNodes });
// BEFORE: all 200 nodes rendered regardless of viewport
// AFTER: ≥150 nodes → off-screen nodes/edges get display:none
flowCanvas({ viewportCulling: false }); // always render everything
flowCanvas({ viewportCulling: true }); // cull at any node count
flowCanvas({ cullingAutoThreshold: 500 }); // raise the auto trip point
Escape hatch. viewportCulling: false.
Edges are culled too
An edge's SVG hides when both endpoints are off-screen and its last-routed corridor doesn't intersect the viewport. Edges without a recorded corridor stay visible.
Behavior changes — canvas lifecycle
destroy() now actually runs
What changed. A mixin-shadowing bug had replaced the canvas's own destroy() (~140 lines dead since 9855d68). It now runs: fullscreen exits, the flow store unregisters, and wire-bridge listeners and global observers dispose.
flowCanvas({
onDestroy(detail, ctx) {
cleanupMyIntegration();
// BEFORE: never invoked — destroy() was silently shadowed
// AFTER: runs on teardown. If you wrote this handler months ago, it
// executes for the FIRST TIME in this release.
},
});
A caller-supplied collab.provider is not destroyed — the app owns it.
Escape hatch. None — the teardown was always supposed to run. Audit any long-dormant onDestroy handler, since it fires for the first time now.
Handle pointerdown is delegated in the capture phase
What changed. One capture-phase listener per canvas now claims handle pointerdown at the viewport (~5,000 per-handle bubble-phase listeners → 1). Markup rendered inside a source handle no longer receives its own pointerdown.
// markup rendered INSIDE a source handle:
badgeInsideHandle.addEventListener('pointerdown', openMenu);
// BEFORE: openMenu fired (per-handle listeners, bubble phase)
// AFTER: never fires — the capture-phase listener claims the event first
// migration: attach outside the handle, or:
flowCanvas({ delegatedHandleEvents: false });
Escape hatch. Attach the listener outside the handle element, or delegatedHandleEvents: false.
Behavior changes — interaction & input
Double-click zoom can be a toggle
What changed. zoomOnDoubleClick accepts a mode. Default behavior is unchanged ('step'); the toggle is opt-in.
// BEFORE: zoomOnDoubleClick: boolean — dblclick = one d3 zoom step
// AFTER: 'step' (default, unchanged) | 'toggle' | false
flowCanvas({ zoomOnDoubleClick: 'toggle', dblClickZoomLevel: 2 });
// dblclick № 1 → jump to zoom 2 about the cursor
// dblclick № 2 → restore the exact prior viewport
Wheel zoom now works while the pointer is over a node
What changed. .nopan gates panning only; wheel is exempt and gated separately by .nowheel.
// user scrolls / pinches while the cursor sits on a node (.nopan)
// BEFORE: nothing happened — .nopan blocked EVERY gesture incl. wheel,
// which on a dense canvas is most of the surface
// AFTER: zooms. Panning is still gated by nopan; wheel is gated separately:
// <div class="nowheel">scrollable list inside a node</div>
Keyboard shortcuts no longer hijack contenteditable
What changed. isEditableTarget() now honors el.isContentEditable, not just INPUT/TEXTAREA.
// node body contains: <div contenteditable>Quarterly notes</div>
// user is typing and presses Backspace / arrows / Cmd+Z
// BEFORE: tagName check (INPUT/TEXTAREA only) missed it → Backspace DELETED
// THE NODE, arrows moved it, Cmd+Z hit canvas history
// AFTER: keys edit the text. Enter/Space activation is gated to the node
// wrapper, so nested buttons/inputs behave natively.
Drops over panels/controls/minimap/devtools no longer add nodes
// user drags from a palette, releases over the minimap
// BEFORE: a node was silently added underneath the overlay
// AFTER: the drop cancels; canvas/node drops are unchanged
Applies to .flow-panel, .flow-controls, .flow-minimap, and any .canvas-overlay.
Behavior changes — schema, export, fitView
schemaHandleGeometry defaults to 'auto'
What changed. Schema-edge endpoints are derived arithmetically from node position/dimensions/fields (zero getBoundingClientRect), parity-tested byte-identical, with automatic DOM fallback for rotated/culled/non-uniform cases.
// BEFORE: every schema edge measured its row handles from the DOM
// AFTER: computed from node position/dimensions/fields (2.3–3.8× faster)
flowCanvas({ schemaHandleGeometry: 'dom' }); // legacy path, if needed
Escape hatch. schemaHandleGeometry: 'dom'.
toImage() now includes edges
What changed. Edges used to rasterize invisible (their stylesheet paint was dropped). They now render with their computed stroke, markers, and dash.
const png = await canvas.toImage();
// BEFORE: edges rasterized INVISIBLE (stylesheet paint dropped)
// AFTER: edges render with their computed stroke/markers/dash
// new options (defaults preserve the old 1× PNG):
await canvas.toImage({ scale: 2 });
await canvas.toImage({ format: 'jpeg', quality: 0.9 });
await canvas.toImage({ format: 'svg' });
fitView() returns Promise<boolean>
What changed. fitView() used to return void and, if nodes were unmeasured, silently give up after ten frames — unobservable. It now returns a promise that resolves whether the fit actually ran.
// BEFORE: fitView(): void — unmeasured nodes → silent give-up after 10 frames
// AFTER:
const fitted = await canvas.fitView();
if (!fitted) console.warn('nodes still unmeasured after retry budget');
// runtime-non-breaking: ignoring the return works exactly as before
Escape hatch. None needed — ignoring the return value is unchanged. TypeScript callers who annotated : void see a compile note only.
Connect-drag snapshots handle state at drag start
Handle connectability/visibility is indexed once per connect gesture (163× faster snap); mid-gesture changes aren't re-measured until the next drag. Identical results for static layouts.
Type-level change
SchemaMetrics gained required fields
What changed. Hand-constructing the exported SchemaMetrics type no longer compiles — it gained required geometry fields. Reading canvas._schemaMetrics is unaffected and runtime behavior is unchanged. Note rowHeight is now a row stride (row top → next row top), not the visual row height.
// BEFORE: compiled
const m: SchemaMetrics = { headerHeight: 40, rowHeight: 28, /* … */ };
// AFTER: fails typecheck — missing required fields. Supply the new geometry:
const m: SchemaMetrics = {
headerHeight: 40,
rowHeight: 28, // now the STRIDE: row top → next row top
rowHeightLast: 28,
insetLeft: 0, insetRight: 0, insetTop: 0, insetBottom: 0,
handleOffsetY: 14, handleOffsetYLast: 14,
handleWidth: 8, handleHeight: 8,
/* …existing fields… */
};
Escape hatch. None — only affects code that constructs the type by hand (rare). Consumers that read the value are unaffected.
runState (D2)
New node.runState property
Nodes now accept an optional runState field that drives visual state without any per-project CSS boilerplate:
{ id: 'step-1', position: { x: 0, y: 0 }, data: { label: 'Step 1' }, runState: 'running' }
Valid values: 'pending' | 'running' | 'completed' | 'failed' | 'skipped'
The 'pending' value (or absent runState) is the baseline — no class applied, no visual change.
Auto-applied CSS classes
When runState is set, the node directive automatically adds the matching class to the node element:
| runState | CSS class |
|---|---|
running |
.flow-node-running |
completed |
.flow-node-completed |
failed |
.flow-node-failed |
skipped |
.flow-node-skipped |
Classes are toggled reactively — setting node.runState = 'completed' instantly swaps .flow-node-running for .flow-node-completed.
Theme provides default animations
Both shipped themes (theme-default.css, theme-flux.css) include default visual treatments out of the box:
- running — violet border + repeating pulse ring
- completed — teal border + one-shot background flash
- failed — red border + repeating pulse ring
- skipped — dimmed (0.45 opacity) + slight grayscale
All colors are overridable via CSS variables on .flow-container:
.flow-container {
--flow-node-running-border-color: #8b5cf6;
--flow-node-running-pulse-color: rgba(139, 92, 246, 0.55);
--flow-node-completed-border-color: #14b8a6;
--flow-node-completed-flash-color: rgba(20, 184, 166, 0.12);
--flow-node-failed-border-color: #ef4444;
--flow-node-failed-pulse-color: rgba(239, 68, 68, 0.55);
--flow-node-skipped-opacity: 0.45;
}
$flow.setNodeState() + $flow.resetStates()
Two canvas helpers manage runState from Alpine scope:
// Set state on one or more nodes
$flow.setNodeState(['step-1', 'step-2'], 'running');
$flow.setNodeState('step-3', 'completed');
// Clear all runState values (reset to pending/baseline)
$flow.resetStates();
Wire-bridge commands
WireFlow consumers dispatch runState changes from PHP — no client JS required:
// Set state server-side
$this->flowSetNodeState(['step-1', 'step-2'], 'running');
$this->flowSetNodeState('step-3', 'completed');
// Reset all
$this->flowResetStates();
Both methods keep the server-side $nodes array in sync before dispatching, so Livewire re-renders reflect the current runState without manual array manipulation.
Not yet reachable (planned)
patchConfigdoes not accept anodeskey — it's canvas-level config only. Server-side node/childLayout mutation uses direct reactive assignment (via the wire-bridgeflow:updateNodecommand or$wire.entangle).
Drop zone enhancements
Four related improvements that unblock builder-style consumers (e.g. AlpineForm) who previously had to reimplement most of the drop zone behavior themselves.
New dropMimeTypes config
flowCanvas({
dropMimeTypes: ['application/alpineflow', 'application/alpineform-field'],
onDrop({ data, mimeType, position, targetNode }) { /* ... */ },
})
The default is ['application/alpineflow'], so existing consumers are unaffected. Pass an array of accepted MIME types; the first matching MIME is reported as mimeType in the onDrop detail.
onDrop detail now includes mimeType
// Before
onDrop({ data, position, targetNode }) { /* ... */ }
// After (mimeType added — existing destructuring still works)
onDrop({ data, mimeType, position, targetNode }) { /* ... */ }
targetNode is now the deepest container
targetNode now returns the deepest FlowNode under the cursor instead of the outermost. For nested containers (page → section → column), a drop on the column reports the column. If you need the outermost ancestor, traverse node.parentId upward:
function outermost(node, flow) {
let n = node;
while (n?.parentId) n = flow.getNode(n.parentId);
return n;
}
.flow-canvas-drag-over CSS class
The canvas container element receives the .flow-canvas-drag-over class during a valid drag-over (i.e., when at least one MIME type in dropMimeTypes is present). The class is removed on dragleave and on drop. Use it for drag-over feedback styling:
.flow-container.flow-canvas-drag-over {
outline: 2px dashed var(--color-accent);
}
New canvas.getNodeAtPoint(clientX, clientY)
Returns the deepest FlowNode at the given client coordinates, or null if no node is found. Uses the same elementsFromPoint + deepest-container logic as the drop zone. Useful for context menus, tooltips, and custom pointer interactions beyond the built-in drop zone.
canvas.addEventListener('contextmenu', (e) => {
const node = $flow.getNodeAtPoint(e.clientX, e.clientY);
if (node) openContextMenu(node, e);
});
Workflow addon
New optional addon: @getartisanflow/alpineflow/workflow. Provides $flow.run() for structured workflow execution. See the Workflow addon reference for the complete API.
Key features:
$flow.run(startId, handlers, options)walks the graph with state transitions, handlers, and pacingflow-conditionnodes with declarative condition evaluation (10 operators, dot-path field access)flow-waitnodes for pacing- Edge state CSS classes (
.flow-edge-entering,.flow-edge-completed,.flow-edge-taken,.flow-edge-untaken) - Reactive
$flow.executionLogwith structured events FlowRunHandlewith pause/resume/stop controls
Requires: Alpine.plugin(AlpineFlowWorkflow) after Alpine.plugin(AlpineFlow).
Animation subsystem overhaul
The animation subsystem was overhauled end-to-end — transactions, tagged groups, state-aware cancellation, physics motion, record & replay, and a new beam renderer. This work was originally staged as a separate v0.2.0-alpha that was never tagged, so it reaches you as part of this single v0.1.x → v0.2.1-alpha upgrade. Most of it is additive; the behavior changes below are worth auditing.
Breaking changes
Beam renderer duration now includes follow-through
What changed. The beam particle renderer used to interpret duration as "time for the head to reach the target" — the beam vanished the instant it arrived. It now interprets duration as the total beam lifetime: the tail continues past the target after the head arrives, fading off naturally. onComplete fires after the tail exits, not when the head arrives.
Why. The old behavior looked abrupt on curved paths — the beam would disappear mid-stride. Follow-through is the visually-correct default for tracer/laser-style effects and matches Framer Motion conventions.
Before:
$flow.sendParticle('e1', {
renderer: 'beam',
duration: 1200,
onComplete: () => triggerHitEffect(), // fired at head arrival
});
After (if you rely on onComplete firing at head-arrival time):
$flow.sendParticle('e1', {
renderer: 'beam',
duration: 1200,
followThrough: false, // restore pre-v0.2.0 behavior
onComplete: () => triggerHitEffect(),
});
Escape hatch. followThrough: false on ParticleOptions restores the old head-arrival semantics. See Particles → Beam → Follow-through.
loop: 'reverse' renamed to loop: 'ping-pong'
What changed. AnimateOptions.loop accepts true (loop forever, restart from start) or a string form for bounce-back behavior. The string form was renamed 'reverse' → 'ping-pong' for clarity.
Why. 'reverse' was ambiguous — readers expected it meant "play backward once," not "bounce back and forth forever." 'ping-pong' matches common animation-library convention.
Before:
$flow.animate(targets, { duration: 800, loop: 'reverse' });
After:
$flow.animate(targets, { duration: 800, loop: 'ping-pong' });
Escape hatch. 'reverse' still works as an alias for backwards compatibility. No urgent action needed — both forms produce identical behavior. Over time, move toward 'ping-pong' in new code.
handle.reverse() on finished handles now plays backward
What changed. Calling reverse() on a FlowAnimationHandle after the animation had already completed used to be a silent no-op. It now revives the handle and plays from target back to start.
Why. The no-op behavior was a trap — callers wrote "rewind" UIs assuming reverse() did something on completed animations. It didn't, and the bug surfaced only when users actually completed an animation and clicked reverse. Playing backward is the natural semantic.
Before:
const handle = $flow.animate(targets, { duration: 500 });
await handle.finished;
handle.reverse(); // no-op, animation stayed at target
After:
const handle = $flow.animate(targets, { duration: 500 });
await handle.finished;
handle.reverse(); // now plays from target back to start
Escape hatch. No revert flag — this is strictly a bug fix. If you relied on the old no-op to short-circuit, add an explicit if (handle.isFinished) return; guard before calling reverse().
onComplete no longer fires when an animation is superseded
What changed. When you call $flow.animate() on keys that are already being animated by a prior call, the existing animation is stopped to let the new one take over (the "blend/compose" behavior). That stop now uses a new internal 'superseded' stop mode that does not fire the superseded animation's onComplete. Previously it did.
Why. Firing onComplete for an animation that didn't actually complete was misleading. It caused downstream side effects (mark-as-done, sound effects, analytics pings) to fire for animations that were interrupted, not finished.
Before:
// onComplete fires even though animation 1 was interrupted by animation 2
$flow.animate({ nodes: { a: { x: 100 } } }, { duration: 1000, onComplete: ping });
setTimeout(() => {
$flow.animate({ nodes: { a: { x: 500 } } }, { duration: 1000 });
// `ping` fires here, even though animation 1 was superseded
}, 200);
After:
// Same code — animation 1's `onComplete` does NOT fire. Only animation 2's will.
Escape hatch. None needed — this is the correct behavior. If you want to know when an animation is superseded, check handle.isFinished after starting a new one on the same keys, or track handles manually via $flow.getHandles({ tag }).
structuredClone → safeClone with JSON fallback
What changed. Internally, the animator, timeline, and recorder used structuredClone() to snapshot state. structuredClone throws DataCloneError on values containing functions or Alpine reactive proxies — which caused silent failures in real canvas usage where a node's data had callbacks or was wrapped by Alpine's reactivity system. We now use a safeClone helper that falls back to JSON roundtrip (stripping functions) with a one-time console.warn.
Why. $flow.record() and $flow.timeline() both threw DataCloneError on any canvas using Alpine reactive data with function callbacks. The fallback makes the common case work; the console.warn surfaces that functions were stripped.
Before: Code using $flow.record() or $flow.timeline() with node/edge data containing functions would throw DataCloneError.
After: Same code works. First time safeClone falls back (per session), a warning logs:
[AlpineFlow] Cloning fell back to JSON roundtrip because structuredClone
rejected the input. Any functions or non-cloneable values in the data
were stripped. Consider replacing these with serializable primitives.
Escape hatch. No action required. If you see the warning and want to eliminate it, replace callbacks in node.data / edge.data with string keys that resolve to registered handlers elsewhere.
ParticleHandle.getCurrentPosition() reads from internal state
What changed. ParticleHandle.getCurrentPosition() used to parse the SVG element's DOM attributes (cx/cy for circles, transform for orbs/beams) to derive position. It now returns a cached position updated by the particle engine each frame.
Why. DOM parsing was fragile — it broke for custom renderers that didn't use the expected attributes, and it returned null during the brief window after destroy() removed the element. Internal state is source-of-truth.
Before / After. No code changes needed — the return type and behavior are unchanged. The fix is purely an implementation swap. The only observable change: custom renderers that relied on getCurrentPosition() returning null immediately after destruction may now see the final position for one more frame.
Escape hatch. None needed. If you have a custom renderer that writes position to a non-standard DOM attribute and expected getCurrentPosition() to parse it, switch to tracking position in the renderer's own state.
bounceStiffness/bounceDamping recomputation on inertia motion
What changed. The inertia motion type's bounceStiffness and bounceDamping options were normalized internally. Previously they fed directly into the integrator with surprising scaling. They now use a bounciness × (1 - damping/100) model matching Framer Motion's semantics.
Why. Configs that looked reasonable (bounceStiffness: 300, bounceDamping: 30) produced wildly different bounce behavior depending on the initial velocity. The new model behaves predictably across velocities.
Before: Existing inertia configs may bounce differently than before (usually less, since damping now has a tamping effect on stiffness).
After: If your previous config felt right, bump bounceStiffness up 20-40% and/or lower bounceDamping to recover similar visual behavior.
Escape hatch. None — the old semantics were inconsistent. Tune the new config empirically. Starting point: { bounceStiffness: 300, bounceDamping: 30 } for noticeable-but-quick bouncing.
See Physics → Inertia.
FlowTimeline constructor now takes (canvas, engine?)
What changed. Direct construction of FlowTimeline (not via $flow.timeline()) required a different signature before. new FlowTimeline(canvas, engine?) is the current form — an explicit canvas reference and an optional animation engine override.
Why. Previously the constructor had an implicit engine lookup via global state, which made it hard to run multiple isolated animator instances (needed for tests and the new record/replay system).
Before:
const tl = new FlowTimeline(canvas);
After:
// Typical usage — no change, prefer the factory:
const tl = $flow.timeline();
// Direct construction (rare, usually for testing):
const tl = new FlowTimeline(canvas, customEngine);
Escape hatch. Use $flow.timeline() instead of new FlowTimeline(...). The factory wires the engine automatically and is the supported entry point. Direct construction is considered internal.
FlowTimeline<TContext> generic parameter
What changed. The FlowTimeline class now takes an optional TContext generic for typed context objects. Default is Record<string, any>, so no migration is needed for JS users or TS users who don't care about context typing.
Why. Context-aware timelines (.setContext({...}).step((ctx) => ...)) are much more useful with autocomplete and type-checking on ctx.context.
Before:
const tl = $flow.timeline();
tl.setContext({ winner: '' });
tl.step((ctx) => {
ctx.context.winner // untyped, no autocomplete
return { nodes: [ctx.context.winner], position: { x: 400 }, duration: 300 };
});
After (opt-in typing):
interface MyCtx { winner: string; }
const tl = $flow.timeline<MyCtx>();
tl.setContext({ winner: '' });
tl.step((ctx) => {
ctx.context.winner // typed as string, autocompletes
return { nodes: [ctx.context.winner], position: { x: 400 }, duration: 300 };
});
Escape hatch. None needed. Omitting the generic defaults to Record<string, any> and behaves exactly as before.
See Timeline → Context.
New animation capabilities (no migration needed)
None of these require changes to existing code — they're all new animation surface you can opt into.
Stop modes
handle.stop({ mode }) and $flow.cancelAll(filter, { mode }) now accept three modes:
'jump-end'(default) — snap to target values'rollback'— revert to values captured when the animation started'freeze'— leave at current interpolated value
See Animate → Stop modes.
Transactions
Group several animations with rollback-as-a-unit behavior:
const tx = $flow.transaction(async () => { /* multiple animate calls */ });
tx.rollback(); // stops all tracked handles and reverts touched properties
Tagged groups
Tag animations for bulk control:
const g = $flow.group('ambient');
g.animate(targets, options);
g.cancelAll({ mode: 'rollback' });
g.pauseAll();
g.resumeAll();
See Animate → Groups.
State-aware cancellation (while: predicate)
Auto-terminate an animation when a predicate flips to false:
$flow.animate(targets, {
while: () => userStillHovering,
whileStopMode: 'freeze',
});
See Animate → State-aware cancellation.
Direction state machine
FlowAnimationHandle now exposes direction, play(), playForward(), playBackward(), restart(), isFinished, and currentValue (a Map of per-key interpolated values).
See Animate → Direction state machine.
Physics motion
Four new motion types via motion: option (replaces duration+easing when set):
spring— natural settle behavior with 5 presetsdecay— coasting/deceleration from initial velocityinertia— decay with bounds, bounceback, and snapTokeyframes— multi-waypoint tours with optional per-segment easing
See Physics.
Record & replay
Capture any sequence of canvas API calls and replay it with scrub, speed, reverse, loop, and thumbnail generation:
const recording = await $flow.record(async () => { /* ... */ });
const handle = $flow.replay(recording, { paused: true });
handle.scrubTo(1500);
See Record & Replay.
New particle renderer: beam (with gradient + follow-through)
The beam particle renderer follows the backing SVG path's curvature (no more clipping past corners), supports multi-stop linear gradients painted tail→head, and defaults to natural follow-through behavior. See the beam breaking change above and Particles → Beam renderer.
Expanded particle firing methods
sendParticleAlongPath, sendParticleBetween, sendParticleBurst, sendConverging all now expose particle handles and complete the emission API. See Particles → Firing methods.
Known gaps in record & replay
A handful of record/replay items remain tracked as known gaps (still open as of v0.2.1-alpha):
- User drag capture in recordings — drags mutate
node.positiondirectly; currently not captured. Fix is a per-frame interaction sampler. - Direct viewport methods in recordings —
$flow.setViewport/fitView/zoomIn/setCenter/panBybypass the recorder. Same sampler would cover these. - Particle burst
variantcallback replay — the per-index callback is stripped at capture time; all particles in a replayed burst share base options. recorder.stop()for rolling buffers —$flow.record(fn)requiresfnto resolve naturally before the Recording is available. Planned:AbortSignalsupport or explicit stop.
See Record & Replay → Known gaps.
New capabilities (no migration needed)
None of these require changes to existing code — they're all new surface you can opt into. Full details live in the reference docs and the CHANGELOG.
replaceNodes()/setNodes()— atomic whole-graph replace on the identity-preserving path; resolves once the new nodes are measured, so an immediatefitView()fits. Server-callable viaflow:replaceNodes/flow:setNodes.data-flow-target— out-of-canvas directive targeting via a shared canvas resolver, sox-flow-action,x-flow-snapshot,x-flow-edge-toolbar, and the rest work from a toolbar/sidebar.- Config callbacks receive the canvas context as an optional second argument —
onConnect(detail, ctx),onDrop(detail, ctx), etc. interactiveconfig — start a canvas locked; a master overlay on top ofpannable/zoomable.- Schema-addon methods typed on
CanvasContext— the elevenaddField/schemaToJSON/ … methods are now typed when you import@getartisanflow/alpineflow/schema(module augmentation). - ELK
rectpacking+aspectRatio+ rawlayoutOptionsescape hatch on the ELK wrapper. avoidantCrossingReduction+setCrossingReduction()— opt-in corridor lanes for edges sharing a gap.avoidantEndpointSpread— opt-in canvas/per-node endpoint fanning.noWheelClassName;dblClickZoomLevel;ChangeOrigintype export.- Custom edge generators receive the
edgeas a second argument. - Schema render hooks — row/node class reconcilers plus imperative decorators.
schemaHandleGeometryescape hatch ('auto' | 'dom').
Looking ahead to v0.3.0-alpha
Two structural moves are planned for the next chapter. No action is required this release — they're flagged here so alpha users aren't surprised.
- The Livewire wire-bridge moves from alpineflow to wireflow. The auto-registered bridge leaves alpineflow core; a generic command-dispatch hook replaces auto-registration, and wireflow ships the adapter. Raw alpineflow users shed dead Livewire code; wireflow users see no change.
- The animation engine becomes
@getartisanflow/alpineflow/animate. Timeline, particles, recorder, and physics move to an opt-in subpath addon (mirroring the existing/schemaand/workflowaddons), shrinking the core bundle substantially. A single migration guide will cover both moves.
Installation / upgrade
npm install @getartisanflow/alpineflow@^0.2.1-alpha
npm run build
For WireFlow consumers, a follow-up WireFlow bump pulls the new dist — no code change if you accept the default wireflow:install output.
Questions or issues?
File an issue at github.com/getartisanflow/alpineflow/issues.