djust docs
Browse documentation
advanced

VDOM Architecture

How djust's Rust-powered Virtual DOM diffing and morphdom-style DOM patching deliver sub-millisecond updates.

On this page

djust uses a Rust-powered Virtual DOM to diff server-rendered HTML and send minimal patches to the browser over WebSocket. This architecture delivers sub-millisecond updates while keeping the developer-facing API in pure Python.

Overview

Python LiveView           Rust VDOM (PyO3)           Browser
    |                          |                        |
    |-- render_with_diff() --> |                        |
    |                     parse HTML                    |
    |                     diff(old, new)                |
    |                     emit patches                  |
    |  <-- patches JSON ---   |                        |
    |                          |   --- WS patches --->  |
    |                          |              apply patches
    |                          |              (DOM morph)

Rust Crate Structure

The VDOM lives in crates/djust_vdom/ and is organized into these modules:

  • parser.rs -- Parses HTML into a VNode tree using html5ever. Filters out HTML comment nodes and indentation-only text nodes, and keeps the space between two inline elements as a single " " text node, so the server VDOM matches the browser DOM.
  • diff.rs -- Compares two VNode trees and emits a minimal list of Patch operations. Supports both indexed (positional) and keyed child diffing.
  • patch.rs -- Applies patches to a VNode tree (used server-side in tests). The browser applies patches via JavaScript.
  • lis.rs -- Longest-increasing-subsequence computation used by keyed diffing to emit the fewest MoveChild patches.

The VNode Tree

Every element, text node, and attribute is represented as a VNode:

pub struct VNode {
    pub tag: String,               // "div", "span", etc.
    pub attrs: HashMap<String, String>,  // serialized in sorted key order
    pub children: Vec<VNode>,
    pub text: Option<String>,      // Text content (None for elements)
    pub key: Option<String>,       // For keyed list diffing
    pub djust_id: Option<String>,  // Compact base62 ID for O(1) lookup
    pub cached_html: Option<String>, // Cached HTML for dj-update="ignore" subtrees (not serialized)
}

Each element node receives a compact djust_id (base62-encoded, e.g. "1a", "2B") during parsing. These IDs are stamped as dj-id attributes in the HTML sent to the browser, enabling O(1) element lookup during patch application.

Parsing: HTML to VNode

The parser converts server-rendered HTML into a VNode tree:

let vdom = parse_html("<div class=\"counter\"><span>0</span></div>");

Key behaviors during parsing:

  1. Comment filtering -- <!-- ... --> nodes are skipped entirely, matching browser behavior where comments are not visible to JavaScript DOM traversal.
  2. Whitespace filtering -- A text node made only of whitespace is dropped when it is indentation: between block-level siblings (</div> <div>, </li> <li>) or at the start or end of an element. Between two inline siblings it is the space between two words (<strong>Lead.</strong> <code>x</code> reads "Lead. x"), so it is kept, collapsed to exactly one " " text node (#2999). Neighbours are found by looking through comments. Inside pre, code, textarea, script and style every text node is kept verbatim. NBSP and other non-ASCII spaces are content and are always kept.
  3. ID assignment -- Every element gets a unique djust_id via a thread-local counter with base62 encoding.

The Diff Algorithm

diff() compares old and new VNode trees top-down and emits patches:

let patches: Vec<Patch> = diff(&old_vdom, &new_vdom);

Patch Types

PatchDescription
SetTextUpdate a text node's content
SetAttrSet or update an attribute
RemoveAttrRemove an attribute
ReplaceReplace an entire node
InsertChildInsert a new child at an index
RemoveChildRemove a child at an index
MoveChildMove a child from one index to another
RemoveSubtreeRemove a keyed {% if %} subtree (the range between its boundary markers)
InsertSubtreeInsert a keyed {% if %} subtree, with its boundary markers, at a parent and index
MoveSubtreeMove a keyed {% if %} subtree to a new position

The enum also has VirtualUpdate, VirtualInsert, VirtualMove and VirtualRemove, which address keyed items in a dj-virtual container by key rather than by index.

Every patch carries both a path (index-based array) and a d (djust_id) field. The client tries ID-based resolution first for O(1) lookup, falling back to path traversal.

Indexed vs. Keyed Diffing

By default, children are compared by position (indexed diffing). When children have key attributes (via dj-key in templates), the algorithm uses keyed diffing:

<!-- Keyed list: moves are detected instead of replacements -->
{% for item in items %}
<li dj-key="{{ item.id }}">{{ item.name }}</li>
{% endfor %}

Keyed diffing maps old keys to new keys and emits MoveChild patches instead of remove-then-insert pairs, preserving DOM state (focus, scroll position, animations) across reorders.

ID Synchronization

After diffing, sync_ids() copies old djust_ids to matched nodes in the new tree. This ensures subsequent diffs use IDs that match what the client currently has in its DOM. Only replaced nodes (tag mismatch) and newly inserted nodes keep their fresh IDs.

Client-Side Patch Application

Patches are serialized as JSON and sent over WebSocket. The client-side JavaScript applies them in 12-vdom-patch.js:

// ID-based resolution (primary, O(1)):
const node = document.querySelector(`[dj-id="${CSS.escape(djustId)}"]`);

// Path-based traversal (fallback):
// Walks childNodes, filtering out non-dj-if comments and whitespace-only
// text nodes — except a text node that is exactly " ", which is the space the
// server kept between two inline siblings — to match the server's VNode tree.

Patch Application Order

A patch batch is applied in one model, which the client (_applyPatchBatch) and the server's reference patch::apply_patches share (#2999):

  1. Resolve removals first -- every RemoveChild is resolved against the DOM as it was before the batch (by child_d, else its old index), and so is every MoveChild's child.
  2. Remove -- RemoveSubtree spans (by boundary-marker id), then the resolved children.
  3. Place, per parent -- InsertChild, MoveChild, InsertSubtree and MoveSubtree all carry the final index of what they place. Every moved child and moved {% if %} span is detached first; then everything is placed by ascending final index. The differ guarantees that what stays in place is already in final relative order, so each placement lands after exactly its final predecessors.
  4. Node patches -- SetText, SetAttr, Replace and the rest, in emitted order, by dj-id when available, else by final-tree path.

The Rust round-trip tests (apply_patches(old, diff(old, new)) == new, including seeded {% if %} fuzzing) therefore check what the browser does.

The Render-Diff Lifecycle

  1. Mount (GET or WebSocket connect): render_with_diff() is called to produce initial HTML and establish the VDOM baseline. The baseline is stored server-side.
  2. Event (WebSocket message): The handler updates state, then render_with_diff() re-renders. Rust diffs the new VNode tree against the stored baseline and emits patches.
  3. Patch delivery: Patches are serialized to JSON and sent over WebSocket with a monotonically increasing version number.
  4. Client application: The browser applies patches to the live DOM, updating only the changed nodes.
  5. Baseline update: The new VNode tree becomes the baseline for the next diff.

Template Preprocessing

Before the Rust VDOM parser sees the template, djust strips HTML comments and normalizes whitespace (every whitespace run becomes one space; the space between two tags is removed unless both neighbours are inline, exactly the parser's rule). This is critical because:

  • The Rust parser filters comments and whitespace during parsing.
  • The browser DOM includes these nodes.
  • Stripping before baseline creation ensures server VDOM and client DOM are structurally identical.
# In get_template(), BEFORE Rust VDOM baseline is created:
extracted = self._strip_comments_and_whitespace(extracted)

Performance Characteristics

OperationTypical Time
HTML parsing (Rust)0.1 - 0.5 ms
VDOM diff (Rust)0.05 - 0.2 ms
Patch serialization< 0.1 ms
Client patch apply0.5 - 2 ms
Total round-trip2 - 10 ms

Targets for interactive updates:

  • Simple update (text change): 1-2 patches, < 1 ms client-side
  • Form input: 1-2 patches, < 1 ms
  • List update: 5-20 patches, < 5 ms
  • Full refresh: 50+ patches -- consider optimizing if > 10 ms

Template Attributes

dj-root — Reactive Boundary

dj-root marks the DOM subtree that djust manages. Only HTML inside dj-root is diffed and patched. Everything outside (static headers, navbars, footers) is never touched.

<body>
    <header>Static header — never patched</header>
    <div dj-root>
        {# Everything here is managed by the VDOM #}
        {{ count }}
        <button dj-click="increment">+</button>
    </div>
</body>

The VDOM baseline is established for the dj-root subtree at mount time. On every event, the Rust engine diffs the new render of this subtree against the stored baseline and emits minimal patches.

dj-key / data-key — Stable List Identity

Without a key, children are compared by position (indexed diffing). Insertions and deletions cause cascading replacements for all subsequent siblings.

With data-key or dj-key, the diff algorithm uses keyed diffing: it maps old keys to new keys and emits MoveChild patches instead of remove-then-insert pairs. This preserves DOM state (input focus, scroll position, CSS animations) across reorders.

{# Without key: position-based, produces extra mutations on reorder #}
{% for item in items %}
<div>{{ item.name }}</div>
{% endfor %}

{# With data-key: move-aware, O(n) keyed diff #}
{% for item in items %}
<div data-key="{{ item.id }}">{{ item.name }}</div>
{% endfor %}

{# dj-key is equivalent to data-key #}
{% for item in items %}
<li dj-key="{{ item.id }}">{{ item.name }}</li>
{% endfor %}

Keys must be unique within the parent element. Use the item's primary key or a stable string identifier.

Opting Out of VDOM Patching

For subtrees managed by external JavaScript (chart libraries, rich text editors, map widgets), use dj-update="ignore" to prevent djust from patching them:

<div dj-update="ignore" id="my-chart">
    {# Chart.js or similar owns this DOM — djust will not touch it #}
</div>

Known Pitfalls

{% if %} in Attribute Values

A one-sided {% if %} inside an HTML attribute value is supported (#380). When the condition is false, the renderer emits an empty string there rather than a placeholder comment, so this renders correctly:

<div class="card {% if active %}active{% endif %}">
    ...
</div>

Avoid the opposite pattern: opening the same element in two {% if %}/{% else %} branches with a single shared closing tag. Keep conditionals inside the attribute value instead.

Form Field Value Preservation

djust's VDOM preserves the live value of <input>, <textarea>, and <select> elements during patches. If the user has typed into a field and the server re-renders that field with the same value= attribute, the user's draft is kept.

If the server sends a different value=, it replaces the field's live value only when the field does not have focus. The focused field keeps what the user is typing, even when the server value differs.

To prevent djust from patching a form field entirely (e.g., a rich text editor), wrap it in dj-update="ignore":

<div dj-update="ignore">
    <textarea id="body"></textarea>
</div>

Debugging

Enable VDOM tracing to see every diff decision:

DJUST_VDOM_TRACE=1 make start

This logs node comparisons, attribute changes, child diffing decisions, and generated patches to stderr. Use the Debug Panel's VDOM Patches tab for a visual view of patches applied in the browser.