---
title: "VDOM Architecture"
slug: vdom-architecture
section: advanced
order: 2
level: advanced
description: "How djust's Rust-powered Virtual DOM diffing and morphdom-style DOM patching deliver sub-millisecond updates."
---

# VDOM Architecture

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`:

```rust
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:

```rust
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:

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

### Patch Types

| Patch         | Description                            |
| ------------- | -------------------------------------- |
| `SetText`     | Update a text node's content           |
| `SetAttr`     | Set or update an attribute             |
| `RemoveAttr`  | Remove an attribute                    |
| `Replace`     | Replace an entire node                 |
| `InsertChild` | Insert a new child at an index         |
| `RemoveChild` | Remove a child at an index             |
| `MoveChild`   | Move a child from one index to another |
| `RemoveSubtree` | Remove a keyed `{% if %}` subtree (the range between its boundary markers) |
| `InsertSubtree` | Insert a keyed `{% if %}` subtree, with its boundary markers, at a parent and index |
| `MoveSubtree` | Move 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:

```html
<!-- 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`:

```javascript
// 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.

```python
# In get_template(), BEFORE Rust VDOM baseline is created:
extracted = self._strip_comments_and_whitespace(extracted)
```

## Performance Characteristics

| Operation           | Typical Time  |
| ------------------- | ------------- |
| HTML parsing (Rust) | 0.1 - 0.5 ms  |
| VDOM diff (Rust)    | 0.05 - 0.2 ms |
| Patch serialization | < 0.1 ms      |
| Client patch apply  | 0.5 - 2 ms    |
| Total round-trip    | 2 - 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.

```html
<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.

```html
{# 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:

```html
<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:

```html
<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"`:

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

---

## Debugging

Enable VDOM tracing to see every diff decision:

```bash
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.
