MUI Docs Infra

Warning

This is an internal project, and is not intended for public use. No support or stability guarantees are provided.

Prop Compression

Purpose: Reduce page weight and improve hydration performance when sending complex data structures from the server to the client.

Core Strategy: Send plain text in the initial HTML for fast rendering and SEO, then deliver enhanced data (like syntax-highlighted code) as compressed, deferred props that are parsed only when needed. Keep crawler-relevant links in server-rendered HTML.


The Problem

When crossing server to client boundaries, sometimes we need large and complex data structures to be sent from the server to the client. When a page's props become too large, it can compound to create a page that exceeds a practical crawl budget (commonly treated as around 2MB of HTML by many teams).

To optimize for crawlers, the initial HTML should contain semantically relevant information. In this project, code snippets are a useful example. At a basic level, you only need plain text to understand code. For human readers, syntax coloring can improve readability.

Large code snippets can balloon the overall size of the page, simply based on the number of <span> elements used to add color to the code. When the client needs access to that same semantic information, it has to be repeated at the end of the HTML, serialized.

This complex structure also slows hydration, because the data must be parsed and processed before the page can become interactive.

When streaming back multiple large code blocks on a single page, uncompressed props also introduce head-of-line blocking. Every code block above the viewport must be fully parsed and hydrated before the last one can render. This is especially noticeable when a user navigates directly to a #slug targeting the last code block on the page - they have to wait for all preceding blocks to process first.


How It Works

1. Send Plain Text in Initial HTML

We send the plain text variant of the code snippet in the initial HTML, safely escaped by React:

<pre><code class="language-javascript">
  import { AlertDialog } from &#x27;@base-ui/react/alert-dialog&#x27;;
  import styles from &#x27;./index.module.css&#x27;;

  export default function ExampleAlertDialog() {
    return (
      &lt;AlertDialog.Root&gt;
        &lt;AlertDialog.Trigger data-color=&quot;red&quot; className={styles.Button}&gt;
          Discard draft
        &lt;/AlertDialog.Trigger&gt;
        &lt;AlertDialog.Portal&gt;
          &lt;AlertDialog.Backdrop className={styles.Backdrop} /&gt;
          &lt;AlertDialog.Popup className={styles.Popup}&gt;
            &lt;AlertDialog.Title className={styles.Title}&gt;Discard draft?&lt;/AlertDialog.Title&gt;
            &lt;AlertDialog.Description className={styles.Description}&gt;
              You can&#x27;t undo this action.
            &lt;/AlertDialog.Description&gt;
            &lt;div className={styles.Actions}&gt;
              &lt;AlertDialog.Close className={styles.Button}&gt;Cancel&lt;/AlertDialog.Close&gt;
              &lt;AlertDialog.Close data-color=&quot;red&quot; className={styles.Button}&gt;
                Discard
              &lt;/AlertDialog.Close&gt;
            &lt;/div&gt;
          &lt;/AlertDialog.Popup&gt;
        &lt;/AlertDialog.Portal&gt;
      &lt;/AlertDialog.Root&gt;
    );
  }
</code></pre>

This HTML string is 1.33 KB.

2. Serialize Minimal Props for Hydration

Because this code lives inside a client component, we send the props in a serialized format, such as a stringified JSON object:

{
  "language": "javascript",
  "code": "import { AlertDialog } from '@base-ui/react/alert-dialog';\nimport styles from './index.module.css';\n\nexport default function ExampleAlertDialog() {\n  return (\n    <AlertDialog.Root>\n      <AlertDialog.Trigger data-color=\"red\" className={styles.Button}>\n        Discard draft\n      </AlertDialog.Trigger>\n      <AlertDialog.Portal>\n        <AlertDialog.Backdrop className={styles.Backdrop} />\n        <AlertDialog.Popup className={styles.Popup}>\n          <AlertDialog.Title className={styles.Title}>Discard draft?</AlertDialog.Title>\n          <AlertDialog.Description className={styles.Description}>\n            You can't undo this action.\n          </AlertDialog.Description>\n          <div className={styles.Actions}>\n            <AlertDialog.Close className={styles.Button}>Cancel</AlertDialog.Close>\n            <AlertDialog.Close data-color=\"red\" className={styles.Button}>\n              Discard\n            </AlertDialog.Close>\n          </div>\n        </AlertDialog.Popup>\n      </AlertDialog.Portal>\n    </AlertDialog.Root>\n  );\n}\n"
}

This JSON string is 1.08 KB.

The browser parses and paints this HTML very quickly. When JS downloads, the page hydrates immediately. This requires two copies of the plain text code in the HTML, doubling the cost for uncompressed HTML, but transfer-level compression substantially reduces the duplicate cost in practice.

3. Defer Enhanced Data as Compressed Props

After streaming plain text, we can still deliver full syntax highlighting. The highlighted code is represented as a HAST tree, a JSON-based AST where each span of color becomes a nested object:

{
  "highlightedCode": {
    "type": "root",
    "data": { "totalLines": 27 },
    "children": [{
      "type": "element", "tagName": "span",
      "properties": { "className": "frame", "dataLined": "" },
      "children": [{
        "type": "element", "tagName": "span",
        "properties": { "className": "line", "dataLn": 1 },
        "children": [{
          "type": "element", "tagName": "span",
          "properties": { "className": ["pl-k"] },
          "children": [{
            "type": "text",
            "value": "import"
          }]
        },
        {
          "type": "text",
          "value": " { "
        },
        {
          "type": "element", "tagName": "span",
          "properties": { "className": ["pl-smi"] },
          "children": [{
            "type": "text",
            "value": "AlertDialog"
          }]
        },
          [...]
        ]
      }]
    }]
  }
}

This HAST serializes to 16.76 KB of JSON.

The HAST can then be rendered after hydration to enhance the plain text with syntax coloring:

<pre><code class="language-tsx" data-total-lines="27"><span class="frame" data-lined=""><span class="line" data-ln="1"><span class="pl-k">import</span> { <span class="pl-smi">AlertDialog</span> } <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">'</span>@base-ui/react/alert-dialog<span class="pl-pds">'</span></span>;</span>
<span class="line" data-ln="2"><span class="pl-k">import</span> <span class="pl-smi">styles</span> <span class="pl-k">from</span> <span class="pl-s"><span class="pl-pds">'</span>./index.module.css<span class="pl-pds">'</span></span>;</span>
<span class="line" data-ln="3">
</span><span class="line" data-ln="4"><span class="pl-k">export</span> <span class="pl-k">default</span> <span class="pl-k">function</span> <span class="pl-en">ExampleAlertDialog</span>() {</span>
<span class="line" data-ln="5">  <span class="pl-k">return</span> (</span>
<span class="line" data-ln="6">    &lt;<span class="pl-c1">AlertDialog.Root</span>&gt;</span>
<span class="line" data-ln="7">      &lt;<span class="pl-c1">AlertDialog.Trigger</span> <span class="pl-e">data-color</span><span class="pl-k">=</span><span class="pl-s"><span class="pl-pds">"</span>red<span class="pl-pds">"</span></span> <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">styles</span>.<span class="pl-smi">Button</span><span class="pl-pse">}</span>&gt;</span>
<span class="line" data-ln="8">        Discard draft</span>
<span class="line" data-ln="9">      &lt;/<span class="pl-c1">AlertDialog.Trigger</span>&gt;</span>
<span class="line" data-ln="10">      &lt;<span class="pl-c1">AlertDialog.Portal</span>&gt;</span>
<span class="line" data-ln="11">        &lt;<span class="pl-c1">AlertDialog.Backdrop</span> <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">styles</span>.<span class="pl-smi">Backdrop</span><span class="pl-pse">}</span> /&gt;</span>
<span class="line" data-ln="12">        &lt;<span class="pl-c1">AlertDialog.Popup</span> <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">styles</span>.<span class="pl-smi">Popup</span><span class="pl-pse">}</span>&gt;</span>
<span class="line" data-ln="13">          &lt;<span class="pl-c1">AlertDialog.Title</span> <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">styles</span>.<span class="pl-smi">Title</span><span class="pl-pse">}</span>&gt;Discard draft?&lt;/<span class="pl-c1">AlertDialog.Title</span>&gt;</span>
<span class="line" data-ln="14">          &lt;<span class="pl-c1">AlertDialog.Description</span> <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">styles</span>.<span class="pl-smi">Description</span><span class="pl-pse">}</span>&gt;</span>
<span class="line" data-ln="15">            You can't undo this action.</span>
<span class="line" data-ln="16">          &lt;/<span class="pl-c1">AlertDialog.Description</span>&gt;</span>
<span class="line" data-ln="17">          &lt;<span class="pl-ent">div</span> <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">styles</span>.<span class="pl-smi">Actions</span><span class="pl-pse">}</span>&gt;</span>
<span class="line" data-ln="18">            &lt;<span class="pl-c1">AlertDialog.Close</span> <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">styles</span>.<span class="pl-smi">Button</span><span class="pl-pse">}</span>&gt;Cancel&lt;/<span class="pl-c1">AlertDialog.Close</span>&gt;</span>
<span class="line" data-ln="19">            &lt;<span class="pl-c1">AlertDialog.Close</span> <span class="pl-e">data-color</span><span class="pl-k">=</span><span class="pl-s"><span class="pl-pds">"</span>red<span class="pl-pds">"</span></span> <span class="pl-e">className</span><span class="pl-k">=</span><span class="pl-pse">{</span><span class="pl-smi">styles</span>.<span class="pl-smi">Button</span><span class="pl-pse">}</span>&gt;</span>
<span class="line" data-ln="20">              Discard</span>
<span class="line" data-ln="21">            &lt;/<span class="pl-c1">AlertDialog.Close</span>&gt;</span>
<span class="line" data-ln="22">          &lt;/<span class="pl-ent">div</span>&gt;</span>
<span class="line" data-ln="23">        &lt;/<span class="pl-c1">AlertDialog.Popup</span>&gt;</span>
<span class="line" data-ln="24">      &lt;/<span class="pl-c1">AlertDialog.Portal</span>&gt;</span>
<span class="line" data-ln="25">    &lt;/<span class="pl-c1">AlertDialog.Root</span>&gt;</span>
<span class="line" data-ln="26">  );</span>
<span class="line" data-ln="27">}</span>
</span></code></pre>

This HTML string is 4.79 KB.

For the AlertDialog hero demo, this HAST tree is 16.76 KB: 15.5× larger than the 1.08 KB plain text. A highlighted HTML string (the <span>-wrapped markup shown above) would be 4.79 KB.

We compress the HAST with DEFLATE and defer parsing until the snippet enters the viewport via an IntersectionObserver. The compressed payload is 0.97 KB (27% smaller than the escaped plain text HTML from Step 1, 80% smaller than the highlighted <span> markup). The compression library we use only adds 4 KB to the bundle for async decompression.

For better compression, we can optionally use a shared (preset) dictionary on both server and client. This primes DEFLATE with repeated HAST patterns and common text so the deferred payload is smaller. In this example, the deferred payload drops from 0.97 KB to 0.82 KB (38% smaller than the escaped plain text HTML and 83% smaller than the highlighted <span> markup), as shown by the "Compressed with Dict" results below. See Content-Aware Dictionary for details.

This approach relies on the browser's built-in JSON.parse instead of shipping a separate HTML parser. This is simpler to reason about, has no parser bundle cost, and offers predictable behavior across clients.

4. Highlight on Init (Optional)

For above-the-fold code where deferred highlighting would cause a visible flash, we can skip deferral and decompress at hydration time instead. This front-loads highlighting work into hydration but still benefits from the compressed payload. The hydration cost is still smaller than passing uncompressed HAST or HTML strings in props. This is how highlightAt: 'init' works for the CodeHighlighter component.


Size Comparison

Small Snippet

When testing the code for console.log('Hello, world!'), we see the following sizes for different approaches:

ScenarioInitial HTMLInitial PropsSuspended PropsTotal Weight%
Plain text97 bytes0 bytes0 bytes= 97 bytes100%
Plain Text Hydrated97 bytes64 bytes0 bytes= 161 bytes166%
HTML String in Props193 bytes193 bytes0 bytes= 386 bytes398%
Compressed Props97 bytes64 bytes320 bytes= 481 bytes496%
Compressed with Dict97 bytes64 bytes168 bytes= 329 bytes339%
Highlight on Init193 bytes168 bytes0 bytes= 361 bytes372%

Using HTML String in Props requires dangerouslySetInnerHTML to render, which can have security implications when content is untrusted. To add interactivity, we would either need to parse the HTML string or directly use browser APIs outside of React.

Medium Snippet

For a more realistic example, consider the AlertDialog hero demo (27 lines of JSX). In the initial HTML, React escapes the plain text to 1.33 KB. The serialized props for hydration are 1.08 KB.

The highlighted HAST for this snippet serializes to 16.76 KB of JSON, 15.5× larger than the plain text. An equivalent highlighted HTML string (the <span>-wrapped markup) would be 4.79 KB. By compressing the HAST with DEFLATE, the deferred payload drops to 0.97 KB (27% smaller than the escaped plain text HTML from Step 1). With a content-aware dictionary, it drops further to 0.82 KB.

ScenarioInitial HTMLInitial PropsSuspended PropsTotal Weight%
Plain text1.33 KB0 bytes0 bytes= 1.33 KB100%
Plain Text Hydrated1.33 KB1.08 KB0 bytes= 2.41 KB181%
HTML String in Props4.79 KB4.79 KB0 bytes= 9.58 KB720%
Compressed Props1.33 KB1.08 KB0.97 KB= 3.38 KB254%
Compressed with Dict1.33 KB1.08 KB0.82 KB= 3.23 KB243%
Highlight on Init4.79 KB0.82 KB0 bytes= 5.61 KB422%

For the medium snippet, decompression takes ~0.5 ms and HAST-to-JSX rendering takes ~0.84 ms, well under a single frame budget.

Very Large Snippet

With a very large code snippet, the savings become significant:

ScenarioInitial HTMLInitial PropsSuspended PropsTotal Weight%
Plain Text49 KB0 bytes0 bytes= 49 KB100%
Plain Text Hydrated49 KB47 KB0 bytes= 96 KB196%
HTML String in Props203 KB203 KB0 bytes= 406 KB829%
Compressed Props49 KB47 KB54 KB= 151 KB308%
Compressed with Dict49 KB47 KB35 KB= 131 KB267%
Highlight on Init203 KB35 KB0 bytes= 238 KB486%

Both "HTML String in Props" and "Compressed Props" deliver syntax-highlighted code on the client, but with different timing. The HTML string highlights immediately on hydration, while compressed props defer highlighting until the snippet enters the viewport. In exchange, compressed props achieve a total weight of 151 KB versus 406 KB, a 63% reduction. The highlighted payload itself is 54 KB instead of 203 KB because DEFLATE exploits the repetitive structure of HAST JSON. Meanwhile, compressed props keep the initial HTML at 49 KB of plain text, so first paint is fast. HTML String in Props sends 203 KB of highlighted markup upfront, which increases HTML parse time and style calculation.

Decompression for this snippet takes around 10 ms (compared to ~0.5 ms for the medium snippet). With highlightAt: 'idle', each snippet decompresses and renders as one task when it enters the viewport. Many small snippets on a page are no problem, but a single giant snippet could blow past the 50 ms long-task budget.

If we want to highlight on init instead of deferring, we can still use compressed props to reduce the hydration payload. This delivers a 41% reduction in total weight (238 KB vs 406 KB) and a smaller initial hydration cost (35 KB vs 203 KB), at the expense of front-loading all highlighting work into hydration instead of spreading it out during idle time. Ideally, this is only done for code that you know will be above the fold on first render. This is how highlightAt: 'init' works for the CodeHighlighter component.

Streaming Comparison

The size tables above show total weight, but the order in which bytes arrive matters just as much. Using the very large snippet as an example, here is how each strategy streams data to the browser:

Compressed with Dict (lazy highlighting)

StepBytes SentWhat Happens in the Browser
1. Initial HTML49 KBPlain text renders, fast HTML parse
2. RSC payload47 KBHydration (minimal props, highlighting deferred)
3. Deferred payload35 KBDecompress + enhance on idle/intersection

Total: 131 KB. The server can stream the plain text HTML before highlighting even finishes. Syntax highlighting is CPU-bound work that takes time, especially for large snippets. By not blocking the response on highlighting, the browser receives and paints the plain text sooner, improving FCP. Hydration is fast because the props contain only plain text + a compressed blob. Highlighting arrives later and is applied incrementally during idle time without blocking the main thread.

Highlight on Init

StepBytes SentWhat Happens in the Browser
1. Initial HTML203 KBHighlighted HTML renders, slower HTML parse + style calc
2. RSC payload35 KBHydration (decompress + render highlighted HAST)

Total: 238 KB. The user sees highlighted code on first paint, but the initial HTML is over 4× larger, which increases HTML parse time and style calculation. The hydration step must decompress and render the HAST synchronously before the component becomes interactive.

When to use each

Lazy highlighting is the better default. It keeps initial HTML small, hydration fast, and total weight low. Highlight on Init is useful for code that is guaranteed to be above the fold on first render, so the user sees color immediately without a plain-text-to-highlighted flash.

A hybrid approach combines both: use highlightAt: 'init' only for the visible lines of a snippet (the first frame), and plain text for the rest. This way, only a small portion of the code carries the verbose highlighted spans in the initial HTML, while lines that are scrolled out of view remain plain text until the deferred payload enhances them. This limits the cost of Highlight on Init to the visible surface area rather than the entire snippet.


Measured Impact

Core Web Vitals (Base UI Demos)

When this pattern was applied to the Base UI docs, profiling showed significant improvements:

MetricBeforeAfterChange
FCP (Menubar page)0.27s0.21s-22%
HTML parse time18.4ms7.8ms-58%
HTML + CSS render time43ms27ms-37%
LCP (Menu page, 6 demos)0.43s0.33s-23%
Total Blocking TimeN/A0sNo long tasks

By sending only plain text in the initial HTML, the browser parses fewer DOM nodes and skips complex <span> styling entirely. Total Blocking Time remains at zero because deferred highlighted payloads let pages enhance code blocks incrementally during idle time instead of front-loading all highlighting work into initial hydration.

Page Weight & Performance (Type Documentation)

Before this pattern, type pages passed fully highlighted React nodes across the server-client boundary for every prop's type signature. Each highlighted fragment was serialized as a nested JSX tree in the page payload, duplicating work the browser had already rendered in the initial HTML.

Applying prop compression to type documentation shows a different profile than demos. Type pages contain many smaller highlighted fragments spread across dozens of props, so even modest per-prop savings compound quickly.

Base UI Combobox

Decreases the page's uncompressed HTML size by 30%, bringing it below the practical 2 MB crawl-budget limit for search engine crawlers. The tradeoff is a 5% increase in compressed (transfer-encoded) HTML because the pre-compressed props compress poorly under a second pass of DEFLATE.

Uncompressed HTMLCompressed HTML
Before2675 KB276 KB
After1886 KB290 KB
Change-789 KB (30%)+14 KB (5%)

Beyond page weight, the reduction in serialized props also improves first contentful paint and hydration time by 42 ms. Fewer DOM nodes means faster HTML parsing, less scripting work to deserialize the RSC payload, and quicker style calculation.

Parse TimeScripting TimeCalculating StylesTotal
Before56ms49ms27ms= 132ms
After37ms34ms19ms= 90ms
Change-19ms (34%)-15ms (31%)-8ms (30%)= -42ms (29%)

docs-infra CodeHighlighter

For pages already well within the crawl budget, the pattern still provides a meaningful reduction in uncompressed HTML.

Uncompressed HTMLCompressed HTML
Change-283 KB (23%)+6 KB (5%)

When Not to Use This Pattern

For very small snippets (for example, single-line inline code), the complexity of compression and decompression often outweighs the benefits. Reserve this pattern for larger blocks where deferred parsing and reduced prop payloads materially improve page weight and hydration behavior.

Also avoid deferring data that contains links that are semantically important for crawlers. For example, if TypeDoc output includes links to external type definitions that should be discoverable in the initial HTML, keep those links in the server-rendered markup.

The same caution governs splitting the fallback by visibility: stripping a collapsed block's hidden lines out of the initial HTML removes them from what a crawler can read. Use it only for content that will not be crawled (authenticated or internal pages). The by-relevance residual split has no such restriction — it never removes anything the loading UI was going to paint.


Implementation Details

Content-Aware Dictionary

DEFLATE supports a preset dictionary: a buffer of bytes the compressor assumes was already sent to the decompressor. Backreferences into the dictionary compress repetitive content more efficiently than starting cold.

The static HAST_DICTIONARY (~3 KB) contains JSON structural patterns and class names that appear in every highlighted HAST tree. It is always included at the end of the 32 KiB dictionary window so it stays in scope regardless of how much text content precedes it.

When textContent is provided, the actual text of the HAST (extracted via toText(hast, { whitespace: 'pre' })) is prepended to the dictionary. This works because HAST JSON literally contains its text content as "value" fields inside text nodes, so the dictionary seeds backreferences for those repetitions.

Dictionary layout (≤ 32 KiB total):

[truncated_text_content][HAST_DICTIONARY]
  • Text content fills the remaining ~29 KB budget.
  • Text is truncated from the end (the start is kept because first-rendered content produces the most valuable backreferences).
  • When textContent is omitted, only the static dictionary is used (opt-out).

Checksum Verification

When textContent is supplied at compression time, a 4-byte FNV-1a checksum of the final dictionary is embedded at the start of the compressed payload:

base64([4-byte checksum][deflate bytes])

On decompression, the checksum is recomputed from buildDictionary(textContent) and compared. If they don't match, a HastDictionaryMismatchError is thrown, preventing silently rendered corrupted markup when the wrong text is passed.

When textContent is omitted, no checksum is embedded and none is verified.

Server → Client Data Flow

The text dictionary is derived from the links-only fallback rather than passed as a separate prop. On the server, hastToJsxDeferred builds a stripped fallback HAST (highlighting spans removed) and converts it to a compact FallbackNode[] tuple format before passing it to the client component as the fallback prop. The text content is extracted from this compact format and used as the DEFLATE dictionary for compression.

On the client, the same text extraction reconstructs the identical dictionary for decompression, so no duplicate text string crosses the boundary.

Compact Fallback Format

The fallback HAST is a simple tree (only links and text after stripping highlighting spans), but raw HAST JSON is verbose. Every node carries repeated type, tagName, properties, and children keys. The compact FallbackNode[] format replaces this with variable-length tuples:

// Raw HAST element:
{ type: 'element', tagName: 'a', properties: { className: 'ref', href: '/api' },
  children: [{ type: 'text', value: 'Ref' }] }

// Compact FallbackNode:
['a', 'ref', { href: '/api' }, 'Ref']

This is a good fit for simple trees where the structure is shallow and predictable. For complex HAST (deep nesting, many element types, syntax highlighting spans), DEFLATE compression produces better results because it can exploit repetitive JSON patterns across the entire tree. HAST also deserializes directly into a usable format via JSON.parse. A custom intermediate format would need to be converted into HAST anyway (for the enhancers API), creating extra memory pressure from throwaway objects that must be garbage collected. That tradeoff is acceptable for the compact fallback, which is small and shallow, but not for the full highlighted tree.

Double Compression: Compressing the Fallback Itself

Prop compression already compresses the highlighted HAST. A second layer can compress the fallback itself — the parts of it not needed for the initial paint. This double compression comes in two forms:

  • By relevance — the files and variants the loading UI never renders (the residual fallback, below).
  • By visibility — the lines hidden inside a collapsed block (further below).

Both share the property that makes them safe to lean on: decompressing a fallback never blocks the first paint. The visible content is already plain text in the initial HTML, so the deferred fallback can be decompressed lazily — on idle, when a tab is selected, or in chunks as the reader scrolls or expands — without ever holding up what's on screen.

Compressing the residual (by relevance)

The fallback text plays two roles: it is the plain markup a ContentLoading component paints before highlighting loads, and its text doubles as the DEFLATE dictionary that decompresses hastCompressed. But a loading UI rarely paints everything. It usually shows a single file (the selected tab) or a single variant, even when the block holds many files or variants.

The files and variants the loading UI never paints are the residual fallback. They still have to cross the boundary — they are the decompression dictionary for their own hastCompressed source — but they are never rendered as text, so shipping them as plain FallbackNode[] is dead weight in the initial payload.

So we split the fallback by what the loading UI actually renders:

  • Rendered subset (the selected file, or the selected variant's files) → stays plain FallbackNode[] and is handed to ContentLoading as source / extraSource props, so first paint is immediate.
  • Residual (every other file and variant) → bundled together and DEFLATE-compressed as a single blob, then decompressed once when the full content swaps in.

The scope of the rendered subset is already known from the fallbackUsesExtraFiles and fallbackUsesAllVariants flags, the same signals that decide what gets hoisted to ContentLoading props. So "one file shown, the rest compressed" and "one variant shown, the rest compressed" fall out of the existing split.

One blob, not one per file

The residual is compressed as a single object rather than per file. The swap to full content already blocks on decompression, so one decompress of one blob beats N independent decompresses — and, more importantly, a single DEFLATE stream backreferences across files and variants. Sibling files share imports and boilerplate; a TypeScript variant and its JavaScript sibling are nearly identical line-for-line. Compressing them together deduplicates all of that shared text, which a per-file blob cannot. On arrival the blob is decompressed once and its plain FallbackNode[] entries are scattered back onto the in-memory variants, so every consumer still reads a plain, co-located fallback — the consolidation is a wire-format detail, invisible downstream.

A small byte threshold skips compression entirely when the whole residual is tiny, where DEFLATE + base64 framing would only grow it.

Why this matters for page weight

The residual fallback never appears in the rendered HTML — the loading UI doesn't paint it — but it is still serialized into the payload as the decode dictionary. For a page of multi-file or multi-variant demos that is a large amount of plain text that the browser must parse before the page settles. Consolidating and compressing it can drastically cut the uncompressed payload size (the figure that counts against a crawl budget), and shrinks parse time on whichever path renders the block:

  • HTML parse, when the block is server-rendered — fewer bytes of serialized props in the streamed HTML.
  • JS parse, when the block is rendered on the client from build-time precomputed data — the residual ships as a compact blob instead of inline source in the module.
  • JSON parse, when the block is rendered on the client at runtime — only the rendered subset is parsed up front; the residual stays a single opaque string until the swap decompresses it.

This keeps the single-crossing invariant intact — the fallback still crosses once — it just crosses partially compressed: plain for what paints now, one consolidated blob for what paints later.

Splitting the Fallback by Visibility

A collapsible code block initially shows only its focused window — the highlighted lines plus a little padding — and hides the rest behind a toggle. Yet the fallback shipped to ContentLoading still contains every line, so the initial HTML carries source the reader can't see until they expand.

The fallbackCollapsed option trades that away. When set, the fallback painted by ContentLoading is stripped to just the visible frames, so a collapsed block's initial HTML contains only what's on screen. The file's full fallback — every frame — moves into the compressed payload, where it serves the same two purposes the residual fallback does: the DEFLATE dictionary that decodes the file's hastCompressed source, and the fallback for the expanded view once the reader toggles it open. Because the visible frames already painted from plain text, reconstructing the full fallback never blocks anything — it decompresses on idle, or when the reader expands.

This is the by-visibility half of double compression, and it stacks on top of the by-relevance residual: a tabbed, collapsed block ships only the focused window of the selected tab as plain text, and defers everything else — the rest of that tab and every other tab — into the compressed payload.

Because the ContentLoading fallback only holds the visible window, its expand control should be disabled while it is showing — there is nothing to expand to until the full content arrives. useCodeFallback returns collapsed: true for exactly this case, so the fallback can disable its toggle. The full content swap is gated on a successful decode (which uses the full fallback, decompressed from the blob), so the moment the interactive content takes over, expanding works immediately — no second wait.

Only when the block will not be crawled

Unlike the residual split — which only compresses what ContentLoading already declined to paint, and so never removes anything from the server-rendered DOM — splitting by visibility removes the hidden lines of the shown file from the crawlable HTML. A search engine reading the server-rendered markup will see only the focused window, not the full source.

So fallbackCollapsed is appropriate only when the block will not be crawled: authenticated dashboards, internal tools, app UIs behind a login. For public documentation, keep the full fallback in the initial HTML (the default) so the complete source stays indexable — see When Not to Use This Pattern. The payoff where it does apply is a smaller uncompressed initial HTML and lower parse cost (HTML when server-rendered, JS or JSON when client-rendered), since a deeply collapsed block can paint a handful of visible lines instead of the entire file up front.

Decompression & Rendering

After the component enters the viewport, we decompress and parse the JSON object to load a HAST object into memory, then render it as JSX. Derived render output can be cached with a WeakMap keyed by HAST child arrays, so the lightweight compressed payload stays small until rendering is needed, while expanded render data can still be released when those objects are no longer referenced. By contrast, storing the expanded HAST or rendered output directly in module scope increases baseline memory pressure because that larger structure stays resident for the lifetime of the module.

For compression and decompression, this project uses fflate.

We use the library hast-util-to-jsx-runtime to turn this HAST object into regular React components.

The HAST object is also very easy to manipulate using the rehype ecosystem, allowing customization based on user preferences or dynamic data. If HAST originates from an untrusted source, sanitize it before rendering.

How Compressed Data Arrives

Compressed props need to be produced somewhere. The Built Factories pattern solves this: a build-time loader processes each factory call (createDemo(import.meta.url, ...)) and injects a precompute object containing the DEFLATE-compressed HAST. The loadPrecomputedCodeHighlighter loader's hastCompressed output mode is a concrete implementation of this.

At runtime, the factory receives precomputed data through its options, so no heavy highlighting or parsing libraries are shipped to the client. When precomputation isn't available (e.g. dynamic routes), the same component falls back to server- or client-time processing.

Abstracting Complexity from Users

The Props Context Layering pattern keeps this compression logic out of consumer code. Props carry the precomputed compressed data from the server, and context provides client-side functions (like decompression) when the component hydrates. The loading structure looks like this:

<Code>
  <Suspense fallback={<PlainCode />}>
    <HighlightedCode />
  </Suspense>
</Code>

A content handler only needs to consume the result:

'use client';

function ContentHandler(props) {
  const { CodeBlock } = useCode(props);

  return (
    <div>
      <CodeBlock />
    </div>
  );
}

This is roughly how the CodeHighlighter works.


Real-World Usage

This pattern is used throughout the docs-infra system:

  • CodeHighlighter: Renders plain text as the Suspense fallback, then progressively enhances with decompressed highlighted code
  • loadPrecomputedCodeHighlighter: Build-time loader that produces compressed HAST via the hastCompressed output format
  • useCode: Hook that manages decompression and caching of compressed props on the client