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:

  1. Network & Payload Bloat: Serializing a tree of hundreds of UI nodes over HTTP generates payloads between 10 KB and 100 KB per interaction.
  2. Native View Churn: Completely destroying and recreating native UIView (iOS) or View (Android) trees introduces GC pressure, layout recalculation passes, and frame drops.
  3. 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, and RecyclerView.
    • 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 │
└────────────────────────┘   └─────────────────────────┘
  1. Initial Hydration: The native shell sends GET /api/tree. The Dart backend responds with type: "full", version: 1, and revision: 1. The native client renders the view hierarchy and records lastAppliedRevision = 1.
  2. Action Dispatch: When the user taps a button or types in an input, the native shell dispatches POST /action passing clientRevision: 1 in both request headers and JSON payload.
  3. Engine Rebuild & Diff: The Dart runtime triggers setState(), flushes microtasks, and generates the new widget tree. If clientRevision == currentRevision - 1, NeonWidgetTreeDiff.diff() computes the delta between the previous tree snapshot and the new tree.
  4. Patch Response: A lightweight type: "patch" payload containing atomic operations (update, replace, remove, insert) is returned with revision: 2 and baseRevision: 1.
  5. Transactional Application: The native shell applies patch operations in place. Unaffected native views are untouched. lastAppliedRevision advances to 2.

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 type changes (e.g., Text replaced by Button).
  • 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 replace on 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:

  1. Client Tracking: The client maintains lastAppliedRevision, updated on every full render or successful patch.
  2. 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
    }
    
  3. Out-of-Order & Delayed Responses: If a delayed HTTP response arrives from an older user action, baseRevision will not match lastAppliedRevision, and the client safely rejects the patch, preventing UI corruption.
  4. 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: Calls textView.text = newText, switch.isChecked = newVal, or slider.value = newFloat without replacing views.
  • EditText state preservation: Reads view.selectionStart before 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))
    }
    
  • ScrollView preservation: The outer ScrollView remains active across updates, so scroll offsets are not reset.

iOS (ViewController.swift)

The iOS renderer executes patches on UIView hierarchies:

  • update: Updates UILabel.text, UISwitch.setOn(val, animated: true), or UIButton.setTitle() in place.
  • UITextField state preservation: Caches textField.selectedTextRange before updating text and restores it immediately.
  • replace / insert / remove: Calls parentView.insertSubview(newView, at: index) and subview.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

  1. Explicit Keys on Dynamic Lists: Always provide unique key parameters 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.
  2. Structural Container Changes: Replacing a Column with a Row changes the widget type, causing the diff engine to emit a replace operation on that container.
  3. 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 (NeonState or NeonSignal).

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.