Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

Live rendering — the render walk & diff codec

How the render produces a frame stream alongside HTML, and how the diff codec turns two frame streams into a minimal edit-op list.

‹ Back to Live rendering

The render walk: HTML + a frame stream in parallel

HtmlSerializer.Serialize(Component, StringBuilder, FrameWriter?) does the work. It always produces the HTML string. When FrameSinkScope.Current is set to a FrameWriter, it also records a parallel RenderFrame stream — a compact tagged-union modelled on Blazor's RenderTreeFrame but trimmed to the variants Rask actually diffs (Live/RenderFrame.cs):

RenderFrameKind Role
Element (1) Opens an element; close is implicit at index + SubtreeLength
Attribute (2) One name/value attribute on the last-opened element
Text (3) HTML-encoded text content
Raw (4) Verbatim markup (Raw(...))
Doctype (5) The doctype declaration
Component (6) Marks the start of a user component's rendered subtree (carries the instance ref)

Key fields on RenderFrame:

  • SubtreeLength — total frames in the subtree (incl. self); 1 is a leaf. Patched in by FrameWriter.CloseElement/CloseComponent. Lets a consumer skip a subtree without recursion.
  • HtmlStart/HtmlEnd — UTF-16 offsets into the rendered HTML string marking where this frame's output lives. The codec slices [HtmlStart..HtmlEnd] to ship the HTML fragment for an InsertSubtree op.
  • Value on an Element frame holds the active scoped-CSS id, so an inserted element can be re-stamped data-{scopeId} client-side.

FrameWriter rents its backing RenderFrame[] from ArrayPool, so steady-state appends amortize to zero allocation. FrameSinkScope is a [ThreadStatic] ambient holder so the 48 WriteAttributes(StringBuilder) overrides can emit Attribute frames without a signature change (Live/RenderFrame.cs).

The Component frame's instance ref lets the diff short-circuit: an unchanged component instance that still yields a cached subtree can be skipped.

The diff codec: ship a minimal edit-op list, not full HTML

FrameDiffer.Diff(oldFrames, newFrames, output, ...) walks the previous render's frame stream against the current one and emits a minimal List<EditOp> that transforms the old DOM into the new (Live/FrameDiffer.cs). Each EditOp names its target by Path — a child-index sequence from the document root counting only DOM-relevant nodes (Element / Text / Raw / Doctype). Attribute frames are not counted; Component frames are transparent (their body contributes siblings at the surrounding level). The op kinds (EditOpKind):

Kind # Meaning
SetAttribute 1 set/replace an attribute
RemoveAttribute 2 remove an attribute by name
UpdateText 3 replace text/raw node content
InsertSubtree 4 insert a new subtree (carries pre-serialized HTML fragment)
RemoveSubtree 5 remove a contiguous run of Length sibling subtrees
MoveSubtree 6 move an existing sibling node (detach at source, insert at dest)
PermutationBatch 7 a batch of sibling moves under one keyed parent (see below)
MorphSubtree 8 reconcile the children of the element at Path against the inner HTML the op carries (see the fallback ladder)

One frame per DOM node: text & Raw

Path indexing only works if every DOM-relevant frame maps to exactly one browser DOM node. Two cases would otherwise break that 1:1 mapping, so the runtime normalizes them (Live/RenderFrame.cs, FrameWriter.Text):

  • Adjacent text coalesces. Div["a", value] is two Text frames, but the browser merges adjacent text into a single DOM node. The frame writer concatenates contiguous text frames into one so the model matches — including text on either side of a transparent component (a [...] collection or Context), which emits no markup of its own. (Contiguity is detected by HtmlEnd == htmlStart: any tag or node between two texts advances the HTML, so element-separated text stays distinct.)
  • Empty text emits nothing. An empty/null string child produces no HTML and no DOM node, so no frame is emitted for it — otherwise every following sibling's path would drift by one.

Raw is the exception that can't be normalized: its verbatim markup parses into an unknown number of top-level nodes (zero, one, or many). A solitary Raw is safe (its nodes span the whole parent — nothing is indexed after it), but a Raw that shares a sibling level with other nodes makes every following position unreliable. When a changed sibling level mixes a Raw with other nodes, that level's ops are rolled back and its parent's children travel as one MorphSubtree, which reparses the markup correctly rather than shipping a mis-targeted positional op. Only at the document's own level, where there is no parent to name, does the diff set a force-full-HTML flag (DiffScratch.ForceFullHtml → SessionRenderCache.LastDiffForcedFullHtml) and the reply fall back to the whole page.

When a diff ships vs full HTML

LiveDiffMode (Live/LiveOptions.cs) is configured via AddRask(o => o.DiffMode = ...) / WasmHostBuilder.CreateDefault(o => ...):

  • Auto (default) — choose-smaller. Ship the diff whenever it isn't larger than re-sending the body. Fall back to full HTML on the first render (no baseline), on untrusted structural ops (a change of shape directly under <body>), and on out-of-band side effects.
  • DisabledFull — always full HTML; bit-for-bit pre-codec behaviour.
  • Forced — always diff when one is computable, even when larger than the body; for tests/benchmarks.

The chosen mode is snapshotted onto each LiveSession at construction (from the host's RaskLiveOptions — the Server carries it on the LiveSessionStore, WASM on the host builder), and read from that instance field on the render hot path. It is not a process-global static, so two hosts in one process — and parallel tests — each render in their own mode instead of racing a shared field. (PathBase / MinifyScopedAssets remain on the static LiveOptions because they back the process-wide content-addressed asset registries, which build one shared bundle per process.)

The actual decision lives in LiveSession.RenderAndSendAsync (server) and the equivalent WASM path. Sketch (Rask.Server/LiveSession.cs):

if (_renderCache.TryComputeDiff(_diffOps, html)
    && (_diffOps.Count > 0 || historyUrl is not null || headChanged)
    && LiveDiffGate.DiffOpsAreClientSupported(_diffOps))
{
    LivePayload.BuildPayloadUtf8Diff(_writeBuffer, _diffOps, historyUrl, replace, jsInvokes, headHtml);
    var diffBytes = _writeBuffer.WrittenCount;
    if (diffMode == LiveDiffMode.Forced || diffBytes < html.Length)
        usedDiff = true;   // ship the diff
    else
        _writeBuffer.ResetWrittenCount();   // diff lost on bytes → full HTML
}

The byte fallback only kicks in for the pathological case where nearly every node changed on a tiny page, so the op framing exceeds the body. Any genuine in-place state change (counter tick, text edit, attribute, keyed-list edit) is far smaller than the body and takes the diff path regardless of page size.

The diff path is skipped entirely when there's an out-of-band side effect — auth or download instructions (both transports gate on auth is null && download is null). Fire-and-forget IJSRuntime invokes (e.g. a scoped-JS OnRendered hook) and navigation do ride the diff — they don't force full HTML.

Wire format

LivePayload.BuildPayloadUtf8Diff writes UTF-8 JSON directly (no UTF-16 string materialisation), using UnsafeRelaxedJsonEscaping to avoid escaping </>/& — pure wire-size win, the client JSON.parse is identical. Envelope shape:

{
  "kind": "diff",
  "names": ["class", "style"],
  "ops": [ ... ],
  "head": "<head>...</head>",
  "history": { "action": "push", "url": "/path" }
}

Each op is a positional array, not an object — [kind, [path...], extra...], where extra depends on the kind (LivePayload.cs):

[1, [0, 2], "class", "active"]      // SetAttribute: name, value
[2, [0, 2], "class"]                // RemoveAttribute: name
[3, [1, 0], "new text"]             // UpdateText: value
[4, [1], "<li>…</li>", 1]           // InsertSubtree: html fragment, length
[5, [1], 2]                         // RemoveSubtree: length
[6, [1], 3]                         // MoveSubtree: source slot in Length
[7, [1], [dst0, src0, dst1, src1]]  // PermutationBatch: flat moves array

The conceptual shape is {k, p, n, v, l} (kind, path, name, value, length); on the wire it's serialized positionally to save bytes.

Name interning (names): a pass-1 symbol table interns any attribute name that appears 3+ times across the ops (break-even with table overhead), so an attribute burst sharing one name (e.g. 100 ops on class) drops the duplicate name to a single integer index per op. Small diffs pay no envelope.

The client (rask.js / rask.wasm.js) applies these via applyDiff(ops). Its resolvePath walks parent.childNodes filtered to Element/Text/Doctype only, so the path coordinates line up with the server's DOM-relevant frame counting.

applyDiff and its helpers live in one shared module, src/Rask.Core/Resources/rask-dom.ts, which both client entry points import — the same arrangement the full-HTML morph (rask-morph.ts) uses. Keeping the codec in a single shared module means both runtimes decode the C# FrameDiffer opcodes identically; they cannot drift. esbuild bundles each entry point into the runtime its host serves (_RaskBundleClientJs in Rask.Server.csproj and Rask.Wasm.csproj).

This replaced an MSBuild String.Replace that pasted the shared files into each host at // @@RASK_*@@ markers, in an order the build had to get right, with cross-module calls going through implicit window.__rask* globals. Nothing checked the order, the markers, or that a caller and its callee agreed — and the two constraints it imposed (no export/import, and top-level helpers kept as hoisted function declarations so splice order could not matter) are gone with it.

Keyed reconciliation: trusted structural ops

Give list items a stable .Key(…) (Blazor @key parity — an ordinary chain step) and the diff reconciles them by identity instead of position. This unlocks trusted Insert/Remove/Move ops that preserve focus, selection, scroll, IDL property state, and event listeners on surviving nodes. The client relocates a moved node with the Atomic Move API (Node.moveBefore, Chromium 133+), which repositions it without disconnecting it from the document — the distinction matters: removeChild

  • insertBefore, or even a bare insertBefore of a connected node, briefly detaches it and so blurs a focused descendant and drops its selection/caret (the element survives, but its interaction state does not). Where moveBefore is unavailable the runtime falls back to insertBefore, preserving the node and its value but not its focus.

EditOp.Trusted marks an op produced by FrameDiffer's keyed-matching branch. The gate LiveDiffGate.DiffOpsAreClientSupported (Live/LiveDiffGate.cs) routes any untrusted InsertSubtree/RemoveSubtree/MoveSubtree/PermutationBatch op to the full-HTML morph path:

if ((op.Kind == InsertSubtree || RemoveSubtree || MoveSubtree || PermutationBatch)
    && !op.Trusted)
    return false;   // → full HTML

The reasoning (verbatim from the source): positional structural ops can replace mid-list elements the morph would have preserved — ungating them unconditionally broke 83/430 E2E tests in an earlier iteration. So a positional (keyless) replace never ships as ops: below the document's own level the differ rewrites it as a morph of its parent before the gate sees it (see the fallback ladder), and what still reaches the gate untrusted falls back to full HTML. The SessionRenderCache.TryComputeDiff overload surfaces usedKeyedPath so the session knows whether the diff touched the keyed branch.

Without keys: a tail, or one run among matching siblings

Two positional changes are trusted too, both only below the document's own level (there the client's raw childNodes[slot] and the server's frame count agree):

  • A tail — children appended to, or cut from, the end of a level nothing was replaced in.
  • One run — a child, or several side by side, that comes or goes among siblings that still pair up: saved ? Ui.Callout[…] : null above a form, an error text under a field, an icon in front of a badge's words. After the positional walk met a tag mismatch or was left with a tail, FrameDiffer (Live/FrameDiffer.OneRun.cs) counts the children on both sides and takes the longest front and back they share — a pair needs the same kind and tag, the same data-rask-key (or none on both), and equal raw markup. When front + back cover the shorter side, the longer side's extra children are one contiguous run: the level's ops are rolled back and written again as the pairs before the run, trusted inserts or removes for the run, then the pairs after it at the slots they have once the run is in or out — the order the client applies them in. A node that stayed is never touched, so it keeps its focus, its caret and what was typed into it.

Among same-tag siblings the run fits at several positions. It stays at the last of them — where the positional walk would put it — unless another position scores strictly higher, the score being how many siblings stay paired with what they already were (the same subtree, handler ids aside, counts 2; the same element with the same attributes of its own counts 1). Handler ids are left out because a handler that comes or goes above renumbers the ones below it; those new ids reach the page as ordinary SetAttribute ops on the shifted siblings.

Not a run: an element in place of another (cond ? A : B, text in place of an element), two children arriving apart from each other, and a run inside <svg>/<math> (the client parses an insert in a <template>, where an SVG child read on its own is an unknown HTML element). Those levels are the next rung down.

The fallback ladder: fine diff → one run → subtree morph → whole page

Each sibling level is answered with the first of these that can carry it, and a level settles before the one above it does:

  1. A fine diff. Attributes, text, keyed inserts/removes/moves, a tail: ops that name exactly the nodes that changed.
  2. One run (above): trusted inserts or removes among siblings that still pair up.
  3. A subtree morph. One child took the PLACE of another — cond ? A : B, words that become an element, a loading line that becomes a list, one page in the Outlet giving way to the next — and the positional walk can only answer with a remove and an insert the gate refuses, because it cannot tell which node stayed. DiffPositional says so of its own level (a level-local flag: a replace happened and the level is not one run — never a scan of the ops, which would see deeper levels' too), and DiffSiblings rolls that level's ops back to where they began and writes ONE trusted MorphSubtree in their place (MorphRefusedLevel, Live/FrameDiffer.RefusedLevel.cs): the path of the level's parent, in new coordinates like every other op, and the char range of its inner HTML in the render. Because deeper levels settle first, the outermost refused level speaks for the ones below it; a level above that is still patched node by node keeps its own ops around the morph, in order. A level beside Raw markup takes the same rung (MorphRawTaintedLevel).
  4. The whole page. A refused level directly under <body> or <html> (the runtime's script and whatever the browser or a library put beside the page live there, and there is no element of the page's own to name), anything at the document's own level, a first render, a sign-in handoff or a download — and any diff that is no smaller than the page (the size check below is in front of every rung).

What the client does with op 8 (rask-dom.ts): it resolves the path, shallow-clones that element, sets the clone's innerHTML to the fragment, and runs the same morph() a whole page goes through — no further than this one element. Cloning the element itself is what makes the fragment parse in context: table rows inside a <tbody>, options inside a <select>, and shapes inside an <svg> in the SVG namespace. So everything the whole-page morph guarantees holds inside the morphed parent, exactly as before — a focused field that streams its typing is left alone, a field holding what the server has not heard yet keeps it, data-rask-managed nodes are skipped, a data-rask-opaque island keeps its children, attributes a behaviour hook owns stay the hook's, an open <dialog> stays open, a keyed child keeps its node — and everything outside it is not touched at all: the focus, the caret, the scroll position and the expanded groups of a menu the navigation never named. An inline <script> inside the morphed parent runs when it is new or lands where a different node was, as in a whole-page morph, and the runtime's own tag never runs twice; a script elsewhere on the page is no longer re-run by a navigation, since nothing pairs it against a neighbour any more.

A navigation changes <title> too, and <head> is not in the frame stream. That was already solved for diffs: when the rendered <head> differs from the last one sent, the diff carries it whole ("head"), and the client morphs document.head before the body ops. A page swap rides that — the outlet as a MorphSubtree, the title in the head fragment, the address in history.

Measured on a replica of a reported app (a menu of 84 links, a header, the page in the outlet): a navigation between two pages of the layout went from 349,698 B to 5,859 B, a list arriving in place of its loading line from 417,357 B to 69,341 B (the table itself), a form in place of a "no such record" callout from 348,394 B to 4,398 B (NavigationIsADiffTests).

A morph is still by position. Inside the morphed parent, children are paired by slot unless they carry keys, so a sibling after the child that changed shape is rebuilt when its tag no longer lines up. That is what a .Key(…) is for now: not bytes, but identity — the node that must stay the node it was (its focus, what was typed into it, its scroll) across a change of shape beside it. In Development each morphed level is logged on Rask.Live at Information — the parent's path, the size of its markup, and the child that changed shape — so a missing key can still be found.

Minimal moves via LIS

When a keyed list reorders, the keyed branch (DiffKeyedSiblings in Live/FrameDiffer.cs) computes the permutation mapping each surviving item to its new position and finds the longest-increasing-subsequence (LIS) of that permutation. Items on the LIS are already in the right relative order and never move; everything else is repositioned. This is the same minimal-moves strategy React/Vue/Inferno use:

// targets[i] = new position of surviving[i]; lis = its longest increasing subsequence
ComputeLisIndexSet(targets, n, lis);
// walk new indices RIGHT-TO-LEFT, anchoring each off-LIS node before the
// already-final node at the next index — the standard correct minimal-move reconcile

Complexity. The LIS itself is O(n log n). The move loop then repositions each off-LIS node with a live.IndexOf + live.Insert on a List<int>, each O(n), so the loop is O((n − |LIS|) · n). The number of off-LIS nodes — not n — is what matters: a full reverse has |LIS| = 1, so all n − 1 rows move and the loop degrades to O(n²) (this is the genuine worst case, and it's deliberately pinned by FrameDifferBenchmarks.ReverseReorder_ReusedScratch). Realistic edits keep a near-full LIS — a table sort that reranks only the head, a feed that drops a few rows and appends new ones — so only a handful of rows are off-LIS and the loop stays effectively linear (TopNRerank_ReusedScratch / AppendWithDeletes_ReusedScratch pin those shapes). The quadratic case is bounded (one List<int> per parent, n capped by the rendered list size) and hasn't warranted replacing the list with an O(log n) order-statistic structure; the benchmarks guard against a regression that would make it matter. At 1000 rows the full reverse runs ~1.9× a two-element swap, while the top-N rerank and append-with-deletes shapes run at ~1.0× — confirming the cost is the off-LIS move count, not the list size.

PermutationBatch (op kind 7)

Rather than emitting one MoveSubtree op per moved row — each re-emitting the full parent path, which dominates the wire bytes of a large reorder — the run's moves are accumulated as a flat [dst0, src0, dst1, src1, …] array and shipped as a single PermutationBatch op carrying the shared parent path once. The array order is load-bearing: each dst/src pair is computed against the live DOM as mutated by all preceding pairs, so the client replays it strictly left-to-right. This is what collapsed a 200-row reverse-sort from ~3.9 KB to ~1.1 KB (see the Performance section of the README). Like MoveSubtree, it only ever comes from the keyed path and is always Trusted.

Confirm