Widget Tree Diffing & Patch Updates
Neon features a high-performance Widget Tree Diffing and Patch Update Engine. Rather than transmitting the entire serialized UI tree across the platform bridge after every user action, the engine computes minimal structured JSON deltas and streams only necessary patch operations to the native shell.
1. Why Widget Tree Diffing Exists
In decoupled server-driven UI architectures, transmitting the entire widget tree on every interaction introduces three fundamental bottlenecks:
- Network & Payload Bloat: Serializing a tree of hundreds of UI nodes over HTTP generates payloads between 10 KB and 100 KB per interaction.
- Native View Churn: Completely destroying and recreating native
UIView(iOS) orView(Android) trees introduces GC pressure, layout recalculation passes, and frame drops. - Loss of Native View State: Full re-renders destroy ephemeral platform state, including:
- Text input cursor position, active soft keyboard, and input selection.
- Scroll offsets in
ScrollView,ListView, andRecyclerView. - Accessibility focus and platform animations.
Widget Tree Diffing solves all three issues:
- Reduces JSON payload sizes by up to 98% (from ~7.2 KB to ~186 bytes for small updates).
- Mutates existing native views in place without recreation.
- Preserves all native view state, including text focus, cursor offsets, and scroll positions.
2. Full-Tree vs. Incremental Patch Rendering
| Dimension | Full-Tree Rendering (Legacy / Fallback) | Incremental Patch Rendering (Default) |
| :--- | :--- | :--- |
| Response Type | type: "full" | type: "patch" |
| Payload Size | Full serialized tree (~5 KB – 50 KB+) | Atomic operations (~150 B – 500 B) |
| Native Views | All native views destroyed & rebuilt | Only affected views updated or replaced; unaffected views reused |
| Text Focus & Cursor | Destroyed on every keystroke | Preserved seamlessly |
| Scroll Offsets | Reset to (0, 0) unless manually cached | 100% preserved natively |
| When Used | Initial load, recovery, diffing disabled | Every valid interaction after initial load |
3. End-to-End Request & Response Flow
┌────────────────────────────────────────────────────────┐
│ Initial Request │
│ GET /api/tree │
└──────────────────────────┬─────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ Full Tree Response (Revision: 1) │
│ { "type": "full", "version": 1, ... } │
│ Client sets: lastAppliedRevision = 1 │
└──────────────────────────┬─────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ User Interaction (Tap, Input, Toggle) │
│ POST /action │
│ Headers: │
│ X-Neon-Client-Revision: 1 │
│ X-Neon-Patch-Version: 1 │
│ Body: │
│ { "id": "counter_btn", "clientRevision": 1 } │
└──────────────────────────┬─────────────────────────────┘
│
┌──────────────┴──────────────┐
Revision Match Revision Mismatch /
(Client Rev == Base Rev) Recovery Requested
│ │
▼ ▼
┌────────────────────────┐ ┌─────────────────────────┐
│ Patch Response │ │ Full-Tree Fallback │
│ type: "patch", rev: 2 │ │ type: "full", rev: n │
│ ops: [update/replace] │ │ resets synchronization │
└────────────────────────┘ └─────────────────────────┘
- Initial Hydration: The native shell sends
GET /api/tree. The Dart backend responds withtype: "full",version: 1, andrevision: 1. The native client renders the view hierarchy and recordslastAppliedRevision = 1. - Action Dispatch: When the user taps a button or types in an input, the native shell dispatches
POST /actionpassingclientRevision: 1in both request headers and JSON payload. - Engine Rebuild & Diff: The Dart runtime triggers
setState(), flushes microtasks, and generates the new widget tree. IfclientRevision == currentRevision - 1,NeonWidgetTreeDiff.diff()computes the delta between the previous tree snapshot and the new tree. - Patch Response: A lightweight
type: "patch"payload containing atomic operations (update,replace,remove,insert) is returned withrevision: 2andbaseRevision: 1. - Transactional Application: The native shell applies patch operations in place. Unaffected native views are untouched.
lastAppliedRevisionadvances to2.
4. Widget Identity & Stable Keys
Widget identity determines whether an existing native view can be safely reused or must be replaced.
Key Rules
- Type Compatibility: A native view is never reused if its widget
typechanges (e.g.,Textreplaced byButton). - Key Compatibility: If a widget declares a
key, the native view is only reused if the key matches the previous tree node. - Unkeyed Positional Matching: When widgets lack explicit keys, the diff engine matches children by their zero-indexed position in the child list.
// ✅ RECOMMENDED: Always provide stable keys for dynamic or interactable widgets
FilledButton(
key: 'submit-order-button',
onPressed: _handleSubmit,
child: const Text('Submit Order'),
)
Keyed Reordering
When child items in a list are reordered (e.g. sorted or shuffled):
- Keyed matching identifies matching keys between old and new trees.
- If order cannot be preserved with safe sequential moves, the diff engine emits
replaceon non-matching nodes, preventing corrupt index state.
5. Supported Patch Operations
All patch operations use standard RFC 6901 JSON Pointer paths (e.g. "" for root, "/children/0", "/children/1/children/2").
| Operation | Path | Payload | Behavior |
| :--- | :--- | :--- | :--- |
| update | /children/0 | properties: { ... } | Modifies existing native view attributes (text, color, state) in place without recreating views. |
| replace | /children/1 | value: { ... } | Removes old view and mounts a new widget hierarchy at the target index. |
| remove | /children/2 | None | Removes child from parent view. Emitted in descending index order to prevent index shifting. |
| insert | /children/3 | value: { ... } | Instantiates and inserts a new native view at the target index. |
6. Patch Format & Versioning
Patch Response (type: "patch")
{
"type": "patch",
"version": 1,
"revision": 2,
"baseRevision": 1,
"operations": [
{
"op": "update",
"path": "/children/0",
"properties": {
"text": "Count: 1"
}
},
{
"op": "replace",
"path": "/children/1",
"value": {
"id": "status_badge",
"key": "badge_key",
"type": "Badge",
"label": "Active"
}
},
{
"op": "remove",
"path": "/children/2"
},
{
"op": "insert",
"path": "/children/3",
"value": {
"id": "continue_btn",
"type": "FilledButton",
"text": "Continue"
}
}
]
}
Full-Tree Response (type: "full")
{
"type": "full",
"version": 1,
"revision": 3,
"fallbackReason": "revision_mismatch",
"tree": {
"id": "root",
"type": "Column",
"axis": "vertical",
"children": [ ... ]
}
}
7. Revision Tracking & Recovery Protocol
To guarantee UI consistency across asynchronous network calls, Neon implements a deterministic synchronization protocol:
- Client Tracking: The client maintains
lastAppliedRevision, updated on every full render or successful patch. - Base Revision Validation: When receiving a patch with
baseRevision, the client validates:if (patch.baseRevision != lastAppliedRevision || patch.version != 1) { // Stale or out-of-order patch detected: initiate full tree recovery fetchUiTree() return } - Out-of-Order & Delayed Responses: If a delayed HTTP response arrives from an older user action,
baseRevisionwill not matchlastAppliedRevision, and the client safely rejects the patch, preventing UI corruption. - Transactional Failure Recovery: If any operation in a patch fails due to a missing path or native error, the client logs the issue and immediately recovers by requesting a full tree.
8. Native Shell Implementations
Android (MainActivity.kt)
The Android renderer applies patches directly to native ViewGroup and View instances:
update: CallstextView.text = newText,switch.isChecked = newVal, orslider.value = newFloatwithout replacing views.EditTextstate preservation: Readsview.selectionStartbefore updating text and restores the cursor position after updating:val cursor = editText.selectionStart if (editText.text.toString() != newText) { editText.setText(newText) editText.setSelection(minOf(cursor, newText.length)) }ScrollViewpreservation: The outerScrollViewremains active across updates, so scroll offsets are not reset.
iOS (ViewController.swift)
The iOS renderer executes patches on UIView hierarchies:
update: UpdatesUILabel.text,UISwitch.setOn(val, animated: true), orUIButton.setTitle()in place.UITextFieldstate preservation: CachestextField.selectedTextRangebefore updating text and restores it immediately.replace/insert/remove: CallsparentView.insertSubview(newView, at: index)andsubview.removeFromSuperview()safely.
9. Configuration
Diffing is enabled by default in Neon. You can configure diffing behavior through NeonConfig:
// In lib/main.dart
void main() {
NeonApp.run(
const MyApp(),
config: const NeonConfig(
port: 8080,
enableDiffing: true, // Set to false to disable diffing and force full-tree responses
),
);
}
Diffing can also be toggled via feature flags:
config: NeonConfig(
featureFlags: NeonFeatureFlags({
'disable_diffing': true, // Disables diffing dynamically
}),
)
10. Performance Benchmarks
The benchmark utility (tool/benchmark_diff.dart) measures payload sizes, computation latency, and view reuse on representative 79-node and 304-node screens:
| Scenario | Full Tree Payload | Patch Payload | Payload Reduction | Diff Latency | View Reuse | | :--- | :--- | :--- | :--- | :--- | :--- | | Small Update (Single Label) | 7,223 bytes | 186 bytes | 97.4% | 4.1 ms | 98.7% (0 recreated) | | Medium Update (3 Properties) | 7,223 bytes | 250 bytes | 96.5% | 0.9 ms | 96.2% (0 recreated) | | Large Update (100 Items + Inserts) | 27,961 bytes | 349 bytes | 98.8% | 2.7 ms | 98.4% (~5 recreated) |
11. Known Limitations & Best Practices
- Explicit Keys on Dynamic Lists: Always provide unique
keyparameters for items in dynamic collections (e.g.key: 'user_${user.id}'). Without keys, positional matching may cause unnecessary property updates when items are inserted at the beginning. - Structural Container Changes: Replacing a
Columnwith aRowchanges the widget type, causing the diff engine to emit areplaceoperation on that container. - Dart State vs. Native State: Native view state (like cursor positions) is ephemeral and tied to the platform UI. All canonical application state must reside in Dart (
NeonStateorNeonSignal).
12. Troubleshooting
Problem: Stale Revision Warnings in Logs
⚠️ Revision mismatch: client=1, expected=2. Returning full tree fallback.
Cause: The client dispatched an action based on an older revision, or two actions were triggered concurrently before the first response arrived.
Resolution: This is normal self-healing behavior. The server detects the mismatch and returns a full tree (type: "full"), automatically re-synchronizing the client to the latest revision.
Problem: Text Field Loses Focus
Cause: The TextField widget does not have a stable key, causing positional replacement if preceding siblings are inserted or removed.
Resolution: Assign an explicit key: 'search_input' to the TextField.