npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

rasterflow

High-performance, canvas-first rendering for React Flow.

npm install rasterflow @xyflow/react react react-dom

ESM 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

  1. Capture. The node's DOM is serialized to SVG with html-to-image and 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 extends captureBleed beyond the border box on every side, so handles, shadows and outlines that overflow are not clipped.
  2. Mipmap. The base bitmap is halved step by step with high-quality smoothing. Each level carries an ImageBitmap in canvas mode or a PNG blob in img mode, and the smallest level that covers the current zoom is drawn.
  3. 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 a Path2D cache, and live nodes' rects are clipped out. Custom edge types fall back to a bezier line and labels/markers are not drawn, so set canvasEdges: false if you need them. Hidden capture hosts are slot-gated: only a handful of nodes mount into the DOM for re-capture at a time.
  4. 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 dev

Opens 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.