Read OSS

DOM Rendering: Templates, Control Flow Blocks, and Hydration

Advanced

Prerequisites

  • Article 2: Compiler Pipeline (understanding compiled output)
  • Article 3: Reactivity Engine (signals, effects, batch scheduler)
  • DOM APIs (cloneNode, importNode, comment nodes, tree walking)
  • Understanding of server-side rendering and hydration concepts

DOM Rendering: Templates, Control Flow Blocks, and Hydration

With the compiler generating JavaScript and the reactivity engine tracking changes, the final piece is how Svelte actually touches the DOM. Svelte's approach is distinctive: it pre-creates HTML templates, clones them efficiently, and then wires up reactive effects to update specific nodes. This article traces that process from template instantiation through hydration.

Template Instantiation: from_html() and from_tree()

As we saw in Article 2, the compiler produces template strings from static HTML. At runtime, from_html() turns those strings into cloneable DOM nodes:

export function from_html(content, flags) {
    var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
    var use_import_node = (flags & TEMPLATE_USE_IMPORT_NODE) !== 0;
    var node;

    return () => {
        if (hydrating) {
            assign_nodes(hydrate_node, null);
            return hydrate_node;
        }

        if (node === undefined) {
            node = create_fragment_from_html(content);
            if (!is_fragment) node = get_first_child(node);
        }

        var clone = use_import_node || is_firefox
            ? document.importNode(node, true)
            : node.cloneNode(true);
        // ...
    };
}

The function returns a closure. On first invocation, it parses the HTML string into a DOM tree via innerHTML on a temporary element. On subsequent invocations, it clones that tree with cloneNode(true). This is a classic template-cloning optimization: parsing HTML once, cloning many times.

flowchart TD
    First["First call"] --> Parse["create_fragment_from_html(content)<br/>innerHTML parsing"]
    Parse --> Store["Store as 'node'"]
    Subsequent["Subsequent calls"] --> Clone["node.cloneNode(true)"]
    Clone --> DOM["Insert into DOM"]
    Hydrating["During hydration"] --> Reuse["Return existing hydrate_node<br/>Skip cloning entirely"]

Notice the Firefox special case: document.importNode is used instead of cloneNode due to a browser bug. The TEMPLATE_USE_IMPORT_NODE flag lets the compiler signal when this workaround is needed.

The assign_nodes() function at template.js#L40-L45 links the cloned DOM nodes to the current effect, creating the { start, end } anchors used for DOM manipulation when blocks update.

Tip: The <!> prefix in template strings creates a comment node that serves as a stable anchor point. When you see <!> in compiled output, it's a marker the runtime uses to track where dynamic content begins.

Mounting and Hydrating Components

The public API provides two ways to get a component into the DOM: mount() for fresh rendering and hydrate() for taking over server-rendered HTML. Both delegate to _mount().

The hydrate() function is particularly interesting. It searches the target element for the <!--[--> comment marker that indicates the start of server-rendered content. If found, it sets hydrating = true and begins walking the existing DOM. If anything goes wrong — a mismatch, a missing node — it catches the HYDRATION_ERROR, logs a warning, clears the target, and falls back to client-side mount():

export function hydrate(component, options) {
    try {
        var anchor = get_first_child(target);
        while (anchor && anchor.nodeType !== COMMENT_NODE || anchor.data !== HYDRATION_START) {
            anchor = get_next_sibling(anchor);
        }
        set_hydrating(true);
        set_hydrate_node(anchor);
        const instance = _mount(component, { ...options, anchor });
        set_hydrating(false);
        return instance;
    } catch (error) {
        // Fallback to client-side rendering
        clear_text_content(target);
        set_hydrating(false);
        return mount(component, options);
    }
}

The _mount() function at line 163 wraps the component in a component_root effect (via boundary()) and calls the compiled component function with the anchor node and props. The component function — the output of the compiler — sets up all the reactive state and DOM effects.

sequenceDiagram
    participant App as Application
    participant Mount as mount() / hydrate()
    participant Root as component_root
    participant Component as Compiled Component
    participant Runtime as $.state, $.if, etc.
    participant DOM as Browser DOM

    App->>Mount: mount(Component, { target })
    Mount->>Root: component_root(() => ...)
    Root->>Component: Component(anchor, props)
    Component->>Runtime: $.state(), $.from_html(), $.if()
    Runtime->>DOM: cloneNode, appendChild, effects

Control Flow Blocks: {#if}, {#each}, {#key}

Control flow blocks are where the reactivity engine meets the DOM. Each block type has a runtime implementation in dom/blocks/.

The if_block() function creates a BranchManager and a block effect. When the condition changes, update_branch() is called with a key (the branch index) and a render function. The BranchManager handles the transition: pausing the old branch (which triggers outro transitions), creating the new branch, and managing the DOM insertion point:

export function if_block(node, fn, elseif = false) {
    var branches = new BranchManager(node);
    var flags = elseif ? EFFECT_TRANSPARENT : 0;

    block(() => {
        fn((child_fn, key) => {
            update_branch(key, child_fn);
        });
    }, flags);
}

The each block is significantly more complex. It handles two reconciliation strategies:

  • Keyed — items have unique keys. When the list changes, the runtime matches old items to new items by key, reorders DOM nodes, and only creates/destroys what changed.
  • Unkeyed — items are matched by index. Simpler but can't preserve state across reorderings.

Each item in an {#each} block gets its own branch effect with reactive sources for the item value and index. The constants EACH_ITEM_REACTIVE, EACH_INDEX_REACTIVE, EACH_IS_ANIMATED, and EACH_IS_CONTROLLED are bitwise flags the compiler sets based on the template usage.

flowchart TD
    Each["$.each(node, flags, items_fn, render_fn)"]
    Each --> Block["block effect<br/>(re-runs when items change)"]
    Block --> Reconcile{"Keyed?"}
    Reconcile -->|yes| Keyed["Match by key,<br/>reorder DOM nodes,<br/>create/destroy as needed"]
    Reconcile -->|no| Unkeyed["Match by index,<br/>update in place,<br/>add/remove at end"]
    Keyed --> Items["Each item:<br/>branch effect + reactive sources"]
    Unkeyed --> Items

Boundaries: Error Handling and Async Suspense

The boundary block is one of Svelte 5's newer features. It wraps a section of the component tree and catches errors thrown during rendering, providing fallback UI:

// Props shape:
{
    onerror?: (error: unknown, reset: () => void) => void;
    failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void;
    pending?: (anchor: Node) => void;
}

A boundary has three possible states:

  1. Normal — children render successfully
  2. Pending — waiting for async work (suspense)
  3. Failed — an error was caught; render the failed snippet

The boundary effect uses BOUNDARY_EFFECT | EFFECT_TRANSPARENT | EFFECT_PRESERVED flags. The EFFECT_PRESERVED flag prevents the boundary itself from being pruned during the effect tree optimization pass. When an error occurs, the boundary destroys its children, renders the failed snippet with the error and a reset callback, and traps the error from propagating further.

stateDiagram-v2
    [*] --> Normal: Initial render
    Normal --> Failed: Error caught
    Normal --> Pending: Async work started
    Pending --> Normal: Async work completed
    Pending --> Failed: Async work failed
    Failed --> Normal: reset() called

The Hydration Protocol

Hydration is the process of attaching client-side reactivity to server-rendered HTML. Svelte's protocol uses comment markers injected during SSR.

The hydration state is managed in hydration.js. Two key variables track progress:

  • hydrating — boolean flag indicating we're in hydration mode
  • hydrate_node — cursor pointing to the current DOM node being hydrated

When hydrating is true, template functions like from_html() skip cloning entirely and return the existing hydrate_node. The hydrate_next() function advances the cursor to the next sibling. The reset() function validates that no unexpected siblings remain after processing a block.

For control flow blocks, the server injects instruction markers: [0 means "branch 0 was rendered", [-1 means "else branch". The client reads these to determine which branch the server took. If there's a mismatch (e.g., {#if browser} renders differently on server vs. client), the runtime strips out the server content and falls back to client rendering for that block.

sequenceDiagram
    participant SSR as Server HTML
    participant Hydrate as hydrate()
    participant Cursor as hydrate_node
    participant Block as if_block

    SSR->>Hydrate: <!--[--> content <!--]-->
    Hydrate->>Cursor: set to first child after <!--[-->
    Cursor->>Block: hydrate_node = <!--[0-->
    Block->>Block: read_hydration_instruction() → "[0"
    Block->>Block: Key matches? Create branch, walk children
    Block->>Cursor: advance past <!--]-->

Tip: If you see HYDRATION_ERROR in a stack trace, it means the client detected a structural mismatch with the server-rendered HTML. Check that your component doesn't use browser-only checks (typeof window !== 'undefined') in ways that produce different markup on server vs. client.

SSR: The Renderer Class

On the server side, the Renderer class accumulates HTML as a tree of strings. It's fundamentally different from the client runtime — no signals, no effects, no DOM. Just string concatenation:

export class Renderer {
    #out = [];       // string | Renderer items
    type;            // 'head' | 'body'
    promise;         // for async rendering

    push(content) { /* append string to #out */ }
    child(fn) { /* create nested Renderer, call fn(renderer) */ }
    head(fn) { /* create child renderer with type: 'head' */ }
    // ...
}

The Renderer is a tree because content can target different parts of the page. <svelte:head> content goes into a child renderer with type: 'head', while regular content stays in type: 'body'. When rendering is complete, the tree is "collected" — recursively concatenated into a final { head: string, body: string } result.

The server runtime's render() function creates a Renderer instance and calls the compiled server component, which pushes HTML strings into it:

export function render(component, options = {}) {
    return Renderer.render(component, options);
}

The server equivalent of element() at index.js#L40-L61 directly pushes opening/closing tags as strings, with hydration markers (<!---->) injected for dynamic elements.

Client vs Server: Two Paths, One Component

The same .svelte component produces different compiled output depending on the target. As we discussed in Article 1, the compiler forks at the transform phase. Here's a concrete comparison for a simple component:

Client output (simplified):

import * as $ from 'svelte/internal/client';
var root = $.from_html(`<h1><!></h1>`);
export default function Component($$anchor) {
    let count = $.state(0);
    var h1 = root();
    var text = $.child(h1);
    $.template_effect(() => $.set_text(text, $.get(count)));
    $.append($$anchor, h1);
}

Server output (simplified):

import * as $ from 'svelte/internal/server';
export default function Component($$renderer, $$props) {
    let count = 0;  // no reactivity needed
    $$renderer.push(`<h1>${$.escape(count)}</h1>`);
}

The server version is dramatically simpler: no signals, no effects, no DOM manipulation. Values are just plain JavaScript. The Renderer accumulates HTML strings. Lifecycle hooks like onMount are no-ops (as we saw in Article 1, they're stubbed out in index-server.js).

This duality is maintained through the conditional exports system in package.json. The svelte/internal/client and svelte/internal/server paths are completely separate module trees, sharing only the svelte/internal/shared utilities for things like attribute rendering and HTML escaping.

flowchart TD
    Component[".svelte Component"]
    Component -->|"generate: 'client'"| Client["Client JS<br/>$.state(), $.from_html(),<br/>$.template_effect()"]
    Component -->|"generate: 'server'"| Server["Server JS<br/>renderer.push(),<br/>$.escape()"]
    Client --> ClientRuntime["svelte/internal/client<br/>Signals, Effects, DOM"]
    Server --> ServerRuntime["svelte/internal/server<br/>Renderer, string concat"]

What's Next

We've now covered the full pipeline from .svelte source to running DOM. The final article explores the supporting systems that complete the developer experience: the public reactive utility classes, the Svelte 4 migration tool, dev-mode features like ownership tracking and HMR, the transition and animation runtime, and the testing infrastructure.