rasterflow
v0.2.1
Published
High-performance canvas mipmap rasterization for React Flow, a drop-in replacement that keeps huge graphs smooth by rendering nodes as impostors on a single canvas.
Downloads
397
Maintainers
Readme
rasterflow
High-performance, canvas-first rendering for React Flow.
npm install rasterflow @xyflow/react react react-domESM only, Node 18+. @xyflow/react, react and react-dom are peer
dependencies, so the package always uses the versions your app already has.
Types ship with the package and resolve under both bundler and nodenext
module resolution.
It replaces @xyflow/react as your import: the whole React Flow API is
re-exported unchanged, you swap <ReactFlow> for <RasterFlow> and wrap your
node components in withRasterization. Nodes are automatically rasterized into
mipmap chains and drawn as ImageBitmaps on a single canvas layer in
front of React Flow. The DOM keeps only a small placeholder per node, so it
never bloats and pan/zoom stays smooth even with thousands of heavy nodes.
The canvas always wins. Live DOM is used only when it must be: while the
user interacts with a node (hover, click, text selection), while a node is
selected, while its updating flag is set, or before the first capture lands.
As the viewport zooms in, nodes are silently re-captured at a higher
resolution, so the canvas stays sharp at close zoom too.
Quick start
import '@xyflow/react/dist/style.css';
// Single import source, all of React Flow is re-exported from here.
import { RasterFlow, withRasterization, Handle, Position, type NodeProps } from 'rasterflow';
// 1) Wrap your node component
function MyNodeBase({ data }: NodeProps) {
return (
<div className="my-node">
<Handle type="target" position={Position.Left} />
{/* heavy content: charts, gradients, editors... */}
<Handle type="source" position={Position.Right} />
</div>
);
}
const MyNode = withRasterization(MyNodeBase, {
// Minimal skeleton left in the DOM while rastered. Keep the handles here
// so edges stay anchored, otherwise you get an empty correctly-sized div.
placeholder: () => (
<>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
</>
),
});
// 2) RasterFlow instead of ReactFlow, every prop passes through unchanged
export default function App() {
return (
<RasterFlow
nodes={nodes}
edges={edges}
nodeTypes={{ my: MyNode }}
fitView
onNodesChange={onNodesChange}
/>
);
}That is all. <RasterFlow> forwards every ReactFlow prop untouched and
children (<Background>, <MiniMap>, <Controls>, your own panels) land
inside the flow exactly as before. A <ReactFlowProvider> is optional, just as
with plain <ReactFlow>.
Performance recipes
1. Provide a placeholder, actually empty the DOM
While rastered, a node's DOM contains only what placeholder returns. Put the
handles there so edges stay connected, everything else (charts, tables, images)
leaves the DOM and is drawn from the canvas.
2. keepLiveMounted for nodes with internal state
Nodes that keep their content in component state, such as rich-text editors (Lexical, ProseMirror), lose it when unmounted. For those:
const DocNode = withRasterization(DocBase, { keepLiveMounted: true });The subtree stays mounted hidden and frozen (no re-renders) and captures still run. Use it only where genuinely needed, it brings the DOM cost back.
3. Control invalidation with rasterKey
The default key is the data reference, which is correct as long as React Flow
updates data immutably. If anything else affects the node's appearance, such
as its size, fold it into the key:
const MyNode = withRasterization(MyNodeBase, {
rasterKey: (p) => `${p.data.rev}:${p.width}x${p.height}`,
});With size in the key, resizing through <NodeResizer> triggers a re-capture
automatically.
4. The updating flag, a hands-off mode
While a node is being updated live (streaming data, an editing session) you may want the rasterizer to stay away entirely:
setNodes((ns) => ns.map((n) => n.id === id
? { ...n, data: { ...n.data, updating: true } }
: n));No capture is scheduled for a node with data.updating === true, no raster is
shown, the node keeps its original DOM, and it re-rasterizes automatically once
the flag clears. For a different field:
withRasterization(C, { isUpdating: (p) => ... }).
5. useRasterNodeData for changes the user made
Keeping the previous raster visible until a fresh one is ready is a deliberate
choice for background data churn: the DOM stays small and nothing flickers. But
when the user made the change, a like click, an inline edit, a status flip,
the stale image must not appear even for a frame. useRasterNodeData combines
updateNodeData with invalidation in a single call:
const updateNodeData = useRasterNodeData();
<button onClick={() => updateNodeData(id, { likes: likes + 1 })}>A dirty node renders live DOM until the fresh capture lands (the stale mipmap
is never shown) and that capture jumps to the front of the queue instead of
waiting for idle time. To invalidate without a data change there is also
useRasterInvalidate(): invalidate(id) or invalidate(['n1', 'n2']).
For global visual changes such as a theme switch, use
useRasterInvalidateAll(). Every raster goes dirty in one call, disk cache
entries are dropped and shared chains rebuild under new keys. Expect a brief
DOM and capture surge on large graphs.
6. Paint outside the box: captureBleed
Everything painted outside a node's border box, <Handle>s straddling the
edge, box-shadow, outline, focus rings, is captured together with a default
16 px bleed margin and drawn back to the canvas at the same offset. Raise it
if your nodes carry decorations that reach further out or cast large shadows,
or set 0 to save bitmap area when nothing overflows:
<RasterFlow rasterizer={{ captureBleed: 24 }} ... />An insufficient bleed is easy to spot: the outer edge of handles or shadows looks cut off in the raster.
7. Box color emoji and icons at a fixed width
Color emoji glyphs (👍, 🔥) are laid out with a different advance width
in the SVG capture context than on the page. Measured at 11 px text the
difference is about 2.5 px, and everything after the emoji shifts in the
raster. Fixing the width in CSS removes the drift entirely:
<span style={{ display: 'inline-block', width: 16, textAlign: 'center' }}>👍</span>
{likes}The same technique applies to icon fonts that may load late.
8. Memory budget: maxRasterMemoryMB
Mipmap chains hold RAM, roughly width × height × pixelRatio² × 4 × 1.33 bytes
per node. When maxRasterMemoryMB (default 256) is exceeded, offscreen chains
are evicted least-recently-drawn first. An evicted node stays as a chainless
placeholder at its remembered size (it does not return to the DOM) and is
re-captured with visibility priority once it approaches the viewport again.
Eviction and zoom-driven pixel-ratio re-sampling never run until the viewport
has been completely still, same zoom and position, for viewportSettleMs
(4 s), so rapid zoom in/out cycles trigger no re-captures at all. Protection is
geometric: a region wider than the viewport plus the re-capture margin (with
hysteresis) is never evicted, which keeps nodes at the boundary out of an
evict/re-capture loop. Since visible nodes are never evicted, the budget is a
soft ceiling whose floor is the size of the protected region. Infinity
disables eviction. Track current usage with useRasterStats().bytes.
9. Persistent cache: persistentCache
With persistentCache: true the base PNG of every successful capture is
written to IndexedDB, and on reload mip chains are rebuilt from those PNGs with
no DOM capture at all, so a previously visited graph rasterizes within seconds
(315 nodes in about 8 s in the demo). Requirements: the node's rasterKey must
return a string or number that stays the same across reloads and fully
determines its pixels. invalidate(id) also deletes the node's disk entries,
and records are pruned oldest-first past roughly 800.
rasterKey cannot know that you shipped new markup or styles, so wire
cacheVersion to your build id and bump it on every release. Otherwise the
cache keeps serving bitmaps captured from the previous build:
<RasterFlow rasterizer={{ persistentCache: true, cacheVersion: __BUILD_ID__ }} ... />10. Template sharing: shareRasters
When many nodes look identical (status badges, icon nodes) they can share a single mip chain:
const StatusNode = withRasterization(StatusBase, {
shareRasters: true,
rasterKey: (p) => `${p.data.status}:${p.width}x${p.height}`,
});Nodes producing the same rasterKey hold one refcounted bitmap, which takes 60
badges down to 3 chains in the demo (visible through
useRasterStats().uniqueChains). The requirement matches the persistent cache:
the key must fully determine the node's pixels. Any visual input missing
from the key (a color, a title) makes differently-looking nodes share the wrong
bitmap. Whoever finishes the first capture publishes it and waiters adopt it
instantly, and the loser of a concurrent race disposes its copy.
11. zoomBands, one table that decides quality and memory
Instead of tuning knobs, write down what each zoom range should render:
<RasterFlow
rasterizer={{
zoomBands: [
[1, 'live'], // 100% and closer: the real DOM
[0.5, 0.5], // 100% to 50%: bitmap at half the node's CSS size
[0.25, 0.25],
[0, 0.125], // the last row covers everything below it
],
}}
/>Rows are [minZoom, quality], matched highest zoom first. 'live' means the
real DOM node, a number is the capture ratio in bitmap pixels per CSS pixel of
the node.
The table is the entire specification, which is what makes it powerful: a
chain holds exactly the ratios named here and nothing else. There is no
halving chain to trim, no full-resolution level kept for a zoom the board
never reaches, and no zoom-driven re-capture, because one capture at the
sharpest listed ratio serves every band. It also replaces zoomThreshold,
since the 'live' rows say where rasters stop.
Measured on the demo, 375 nodes: without a table a chain costs 0.83 MB and the board holds 313 MB; with the four rows above a chain costs 0.058 MB and the board holds 10.5 MB, 14x less, drawing the 0.125 ratio at zoom 0.21 exactly as the table asks. While the viewport is moving the next level down is used automatically, which is cheaper and invisible in motion.
Pick ratios by asking what a node's CSS pixel is worth on screen in that band: at zoom 0.5 on a retina display a CSS pixel covers one device pixel, so ratio 1 is pixel-perfect there and 0.5 is a deliberate, usually invisible saving.
12. Show progress### 12. Show progress
Captures run one at a time during idle periods and nodes stay in live DOM until theirs is ready. To surface that:
function RasterProgress() {
const { total, ready } = useRasterStats();
return ready < total ? <span>raster {ready}/{total}</span> : null;
}13. Tune the options for your workload
<RasterFlow
rasterizer={{
pixelRatio: 2, // base capture resolution
maxCapturePixelRatio: 3, // upper bound for zoom-in sharpening
captureBleed: 16, // margin for paint outside the node box (px)
concurrency: 2, // concurrent captures
captureDelayAfterMoveMs: 50, // wait after pan/zoom stops
zoomBands: undefined, // quality table, see recipe 11
viewportSettleMs: 4000, // stillness required for re-sampling + eviction
canvasEdges: true, // draw edges on the canvas too
maxRasterMemoryMB: 256, // soft ceiling for chain memory
persistentCache: false, // store base PNGs in IndexedDB (faster reloads)
cacheVersion: '1', // bump on every release that changes node looks
interactive: true, // swap to live on hover + forward events
liveWhenSelected: true, // keep selected nodes live
zoomThreshold: Infinity, // finite value: live DOM above that zoom
renderMode: 'canvas', // 'img': per-node PNG <img> mode
}}
...
/>How it works
- Capture. The node's DOM is serialized to SVG with
html-to-imageand drawn to a canvas without depending on rAF, so it also works in background tabs. Captures are queued for idle time with bounded concurrency and served by visibility priority: explicitly invalidated nodes first, then nodes currently in the viewport, then everything offscreen, so the screen fills first. Cancelled entries are purged at no cost. Nothing is captured while the viewport is zooming or panning, and a node is never captured while selected or hovered, so selection borders and resize handles are not baked into the bitmap. It is captured in its normal form once that state ends. The capture area extendscaptureBleedbeyond the border box on every side, so handles, shadows and outlines that overflow are not clipped. - Mipmap. The base bitmap is halved step by step with high-quality
smoothing. Each level carries an
ImageBitmapin canvas mode or a PNG blob in img mode, and the smallest level that covers the current zoom is drawn. - Canvas layer. A single pointer-transparent canvas draws every impostor
with viewport culling, so offscreen nodes cost nothing. Each impostor is
shifted up-left by the bleed so the node's box lands exactly on its layout
position, and the DOM keeps only the placeholder. Edges are drawn on the
canvas too (
canvasEdges, on by default): the SVG edge container is hidden while rastered, built-in edge types (bezier, straight, step, smoothstep) are reproduced exactly through aPath2Dcache, and live nodes' rects are clipped out. Custom edge types fall back to a bezier line and labels/markers are not drawn, so setcanvasEdges: falseif you need them. Hidden capture hosts are slot-gated: only a handful of nodes mount into the DOM for re-capture at a time. - Interaction. The moment the pointer enters a node, the canvas impostor swaps for live DOM, so clicks, text selection and inputs reach real elements. Pointer-down and click events that arrive before the swap are re-dispatched to the real target.
Demo
From the repository root:
npm install
npm run devOpens a demo with 300 metric nodes, 15 Lexical rich-text nodes and 60 shared status badges. Three debug checkboxes sit in the top right (all off by default): raster render, updating mode (30 nodes) and random data flow (3-10 s). The status bar shows zoom, render mode, live raster readiness, memory and FPS. Measured example: with raster on, 375 heavy nodes take the DOM from about 7,700 elements down to about 3,300, and even at zoom 1.3 the whole screen is drawn sharply from the canvas.
Source layout
| File | Contents |
| ----------------------------------------------------------| ------------------------------------------------------------------|
| src/index.ts | Library entry point, including the React Flow re-exports |
| src/RasterFlow.tsx | Drop-in replacement for <ReactFlow> |
| src/withRasterization.tsx | Node HOC: raster/live swapping, interactivity, event forwarding |
| src/RasterCanvasLayer.tsx | Canvas impostor layer, registry, useRasterStats |
| src/RasterDevtools.tsx | Diagnostics overlay |
| src/RasterizerProvider.tsx | Options context, viewport motion tracking, useRasterInvalidate |
| src/invalidation.ts | Per-node dirty counters for explicit invalidation |
| src/capture.ts | Capture queue with priorities, host slots, movement deferral |
| src/rasterCache.ts | IndexedDB persistent cache |
| src/chainPool.ts | Refcounted pool for shared chains |
| src/edges.ts | Edge geometry and Path2D construction |
| src/mipmap.ts | Mipmap chain construction and level selection |
| src/useNodeRaster.ts | Low-level capture hook |
License
MIT, see LICENSE.
