TypePHP\Qt
Guide
Widgets
Advanced
Reference
FAQ
  • English
  • 简体中文
GitHub
Guide
Widgets
Advanced
Reference
FAQ
  • English
  • 简体中文
GitHub
  • Advanced

    • Advanced
    • AOT Notes
    • Hand-written Bridge
    • Diff Engine
    • Headless Verification

Diff Engine

This page explains why widget state survives a re-render — the core mechanism of the declarative approach.

The problem

view() runs once per frame (about 60 times a second), each time returning a brand-new widget tree.

What happens if every frame "deletes all the old widgets and rebuilds from the new tree"?

  • The caret in the text you are typing jumps back to the start
  • The table's selected row disappears
  • The scroll position snaps back to the top
  • Rebuilding 200 widgets 60 times a second is slow

So it has to be a difference update.

The approach: diff by id

The C++ side keeps an id → widget map. After receiving a new tree each frame:

id present in both the new tree and the old map  → update only the changed properties (the widget itself stays)
id present in the new tree but not the old map   → create the widget
id in the old map but not the new tree           → delete the widget (detach from the layout first, then deleteLater)

The widget is never rebuilt, so its internal state — caret, selection, scroll, input-method state — is preserved entirely.

Stable ids are the precondition

The diff keys on id, so an id must be stable across frames — the same logical control must get the same id this frame and the next.

Explicit ids

The ['id' => 'name_input'] you write is exactly that.

Automatic ids: structural path

A node without an id gets one derived from its structural position: _p0.1.2 (child 2 of child 1 of root 0).

Because it is derived from position, as long as the tree's structure holds, the same node gets the same id every frame.

Counter-example: an id that changes every frame

This was a real bug

An early implementation generated ids for id-less nodes with an auto-incrementing counter. Result: the id differed every frame → the diff saw "everything old vanished, everything is new" → the whole tree was rebuilt each frame.

The consequence was not just slowness: rebuilding deleted the old widgets while the layout still held dangling pointers to them — the second render crashed outright (0xC0000005).

Lesson: an automatic id must be derived from structure, never from execution order.

Preserving the selection

Table / list / tree selection is a special case: once the user clicks a row, the selection lives in the widget, not in your state.

What the diff engine does:

  1. Before re-rendering, remember the current selection's row id / node id.
  2. Rebuild the table data.
  3. Find that row by the remembered id and re-select it.

So inserting a row or swapping the data never misplaces the selection — provided you pass row_ids:

// with row_ids: the selection follows r2
WidgetTree::table(['Name'], $rows, ['id' => 'tbl', 'row_ids' => ['r0', 'r1', 'r2'], 'current' => 'r2']);

// without row_ids: the row id degrades to the index '0', '1', …, and inserting a row moves the selection
WidgetTree::table(['Name'], $rows, ['id' => 'tbl']);

That is why the data controls keep insisting on row_ids.

Property-level diff

Inside a single widget, comparison is also per property: a value is written back to Qt only when it actually changed.

So writing this in view():

WidgetTree::label('Clicks: ' . $state['clicks'], ['id' => 'count'])

constructs a fresh label node every frame, but the diff sees text unchanged and does nothing — no Qt repaint.

Why patch() is a "bypass"

patch() changes widgets directly, skipping the tree. After it runs, the diff engine's record of "that widget's last property signature" no longer matches reality.

So the framework invalidates the signature — the next render() re-syncs from the tree.

That explains the core constraint in Patching:

Appended rows and cleared content are only valid until that render. To persist, write back into state.

Cost and benefit

Declarative diffImperative handles
Application codeDescribe only "what the UI should look like"Maintain widget lifecycle and incremental sync yourself
State preservationAutomatic (via ids)Handled by hand
Per-frame costWalk the tree + compare propertiesOnly what you explicitly change moves
Failure modeUnstable id → rebuild (visible stutter)Missed sync → UI and data disagree (hard to trace)

The framework chose the former: the least application code, at the cost of one tree walk per frame. In practice that cost is far below the mental overhead of writing imperative sync code — and hot paths can be optimized individually with patch().

Related

  • Architecture — the three layers and the journey of a click
  • Properties — the full set of diffable properties
  • Patching — the price of bypassing the diff
Edit this page on GitHub
Last updated: 10/6/26, 3:13 AM
Prev
Hand-written Bridge
Next
Headless Verification