shikidown
Guides

Incremental rendering

Re-parse only the blocks whose source changed, and keep the DOM — and the state of embedded components — for everything else.

By default, every change to [content] re-parses the whole document and replaces the whole DOM subtree. That is fine for a document you display once. It is not fine for a live editor: at every keystroke Shiki re-highlights every fence, the browser rebuilds every node, and every embedded component is destroyed and recreated — counters reset, scroll positions jump, focus is lost.

Turn it on in the provider:

app.config.ts
provideMarkdown({ incrementalRendering: true });

This affects MarkdownComponent only. MarkdownPipe always renders whole documents.

What changes

WithoutWith
Shiki re-highlightingEvery block, every timeOnly changed blocks
DOM mutationFull replacementOnly changed block nodes
Embedded component stateLostPreserved in unchanged blocks

How it works

Four mechanisms compose here, and each one matters:

Splitting into root blocks

groupTokensToBlocks() walks the flat token array markdown-it produces and counts nesting depth: an opening token collects tokens until its matching close, a self-closing token becomes a block on its own. It is O(n) and looks only at token.nesting, never at the token type — which is what makes it agnostic to whatever plugins you added.

Each block records the source lines it came from, so its identity can be computed from text alone.

Hashing the source

Each block's raw source text is hashed with FNV-1a (32-bit, hex). The hash is the block's identity: same text, same hash, regardless of where it moved in the document.

The LRU cache

Rendered HTML is stored per hash in an LRU cache — a hit moves the entry to the front, the least recently used one is evicted when full. The source is stored alongside, so a hash collision is a miss, never another block's HTML. Capacity is blockCacheSize, 256 by default, and the cache is shared by every MarkdownComponent instance because it lives on the singleton service. Highlighted code has an LRU cache of its own, of the same capacity.

Stable block ids and in-place patching

This is the step that actually saves the DOM. Each block receives an id: an unchanged block takes the id of a block with the same hash (so a moved paragraph keeps its element), a changed block takes the id of the block that held its position (so the paragraph being typed keeps its element). The template tracks by that id, and two identical blocks no longer collide.

A block whose HTML is unchanged is not written at all. A changed one is patched, not rewritten: text nodes are updated in place, and Custom Elements keep their instance and state — including in the block being edited.

No flicker while re-parsing

Parsing is asynchronous, but the component does not blank out while it runs: the last successfully rendered blocks stay on screen, and are replaced only when the new render resolves. The loading skeleton appears on the very first render only.

Clearing the cache

The cache is keyed on source text alone — it knows nothing about the theme. Changing the Shiki theme at runtime therefore leaves stale colours in every cached block. Flush it:

import { MarkdownService } from 'shikidown';

@Component({ /* … */ })
export class ThemeSwitcher {
  private readonly md = inject(MarkdownService);

  switchTheme(): void {
    // update your theme configuration…
    this.md.clearCache();
  }
}

This is only needed for a runtime theme change, not for a dark/light toggle. With a { dark, light } theme pair, both colour sets are already in the rendered HTML and switching is pure CSS — see Styling and dark mode.

Tuning the cache

blockCacheSize bounds memory, not correctness: a document larger than the cache still renders, it just stops getting cache hits on its oldest blocks. Raise it if you render long documents.

provideMarkdown({ incrementalRendering: true, blockCacheSize: 512 });

See also

On this page