A node body is an ordinary widget
The canvas under Ripple Effect had to draw hundreds of cards at sixty frames a second without giving up text fields inside them. The usual answer gives up the text fields. Here is the one that does not.
Every node editor arrives at the same fork. You are drawing a few hundred cards on an infinite canvas, they all move when the user pans, and they have to keep up. The obvious answer is to stop asking the framework to draw them and draw them yourself.
That is what fl_nodes, the
predecessor to the canvas under Ripple Effect, did. It isolated rebuilds with a
custom MultiChildRenderObject. It worked, and it bought node count, and it
cost the node.
A render object that paints its own content cannot host a text field, a platform view, or anything else that needs the framework's own machinery. Native channel synchronisation broke. So a node was a picture of a form rather than a form.
For a general-purpose graph editor that is a survivable trade. For this one it is fatal: a passage node in Ripple Effect is a form. It has a title you type into, ports you name, fields you edit in place. The whole product is people typing inside those cards.
The isolation was one layer up the whole time
The thing a custom render object was being used for — do not rebuild this subtree — already exists in the element tree, and it is one line of the framework:
// Element.updateChild, roughly
if (child.widget == newWidget) {
// nothing to do
}
For a default Widget, == is identity. Hand back the same widget instance
and the subtree is skipped without building. No render object, no fighting
the framework, no losing text fields.
So the editor keeps one slot per on-screen node, holding that node's view instance and its callbacks, and hands the same instance back whenever its inputs have not changed. Everything else about the node is an ordinary widget, which means text fields, sliders, dropdowns and forms all work inside one because nothing was ever taken away from them.
The callbacks have to live in the slot, and that is the part that is easy to get wrong. A closure is the one input you cannot compare against the last one: two closures that do exactly the same thing are never equal. So a closure rebuilt per frame makes every node look different from the node it was a frame ago, and hands the entire optimisation back without a single error.
Measured on thirty-six drawn nodes, node bodies built per frame:
| before | after | |
|---|---|---|
| Dragging one node | 26 | 1 |
| Panning the canvas | 36 | 0 |
| Changing the selection | 36 | 2 |
Panning is the one to look at. The canvas moves, every node moves with it, and nothing rebuilds — because moving a node is a transform, not a new widget.
The trap on the other side of the seam
A host can give all of that back from outside, in one line, and the line looks completely reasonable:
ListenableBuilder(
listenable: controller,
builder: (context, _) => NodeEditor(controller: controller, …),
)
NodeEditor already listens to its own controller. Wrapping it in a builder on
that same controller rebuilds it on every notification, which hands it a fresh
nodeBuilder closure every frame — and per the rule above, a fresh closure
means every node rebuilds.
The demo did exactly this. It cost 59 ms a frame at 500 drawn nodes, where it now costs 5.
What makes that worth writing down rather than merely fixing is how it failed. It did not throw, it did not warn, and every feature carried on working. The isolation is invisible when it breaks. You cannot notice its absence by using the thing; you notice it by measuring, or by shipping something that feels bad for a reason nobody can name. That is why it is pinned by a test that counts builds rather than by a comment asking people to be careful.
The rule for a host is one sentence: wrap the parts that read controller state, not the editor.
What is still painted, and why that is not a contradiction
Node bodies are widgets. Connections, port handles, the grid and the selection overlays are painters. That is not the old trade sneaking back in — none of those things has ever wanted to be a text field.
The reason is subtler than cost, and it is about level of detail. When you
zoom out far enough, a port handle should stop being drawn. As a painter that
is an if at paint time. As widgets it is widgets appearing and disappearing,
which means rebuilding every node on screen at the exact moment the threshold
is crossed — which is to say, stuttering precisely while the user is moving.
Doing LOD with widgets makes the canvas worst exactly when it is most watched.
So: the grid is a fragment shader, one drawRect, costing per pixel rather
than per line, with a line-drawing fallback if the shader will not compile.
Port handles were forty-odd render objects each and are now batched by colour
into one pass. Connection curves batch by colour into one Path per colour,
and their direction markers are sampled once when the curve is built rather
than measured every frame.
One rule holds the painted half together: what is drawn and what is picked read the same geometry. The minimum scale at which a port is drawn is the same number that gates whether a press can hit it, because an invisible dot that still starts a wire is worse than one that is plainly not there yet.
Caches, and the bug that taught the most
Connection geometry is built once and cached. The interesting part is what the cache keys on.
The obvious key is the controller's revision number, which moves whenever anything is edited. It is also wrong, and wrong in a way that looks right: dragging a node bumps the revision on every pointer event, so a memo keyed on it misses on every frame of every drag — the one situation where you needed it. Keying the connected-ports lookup on the connections map's identity instead took it from 26.4 node bodies rebuilt per frame down to 0.7.
There is a second half to that fix which is easy to miss: the memo also has to
hand back the previous Set instance for any node whose set is unchanged.
A freshly constructed Set with identical contents is never identical to the
last one, and that alone was enough to rebuild every node.
| rebuilt every frame | cached | |
|---|---|---|
| Connection geometry | 4055 µs | 0.02 µs |
| Hover pick | 2627 µs | 0.5 µs |
| One pass while dragging, 4928 links | 4368 µs | 750 µs |
What it costs
Nothing here is free, and the package's own notes keep a list rather than a sales pitch. The graph is immutable and edited copy-on-write, so an edit copies the node map — about 390 µs a frame while dragging at five thousand nodes. Removing that means trading the immutable snapshot for in-place mutation, and the model's guarantees along with it, which has not looked worth it yet. There are no resize handles: comments size themselves to their text and group frames size themselves to their members, so nothing on the canvas is resized by hand. Groups do not nest, and a node belongs to at most one.
Where it is
fl_nodes_v2 is public,
MIT, and takes pull requests. It knows nothing about narrative: it supplies a
canvas — geometry, wiring, selection, navigation, undo, execution — and the
application decides what a node is. Ripple Effect's passages, choices and
components live entirely on our side of that line, which is what makes the
package worth publishing rather than merely worth extracting. dart_read_time
came out of the app the same way, and
has a post of its own.
Same distinction as always, and worth repeating because it is the one people get backwards: the package is open source, and Ripple Effect is not.