From 53d912cba807580d0536b9f2af59239845589490 Mon Sep 17 00:00:00 2001 From: dav-is Date: Fri, 27 Mar 2026 21:57:52 -0400 Subject: [PATCH 01/61] Add prop compression pattern --- docs/app/docs-infra/patterns/page.mdx | 9 + .../patterns/prop-compression/page.mdx | 179 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 docs/app/docs-infra/patterns/prop-compression/page.mdx diff --git a/docs/app/docs-infra/patterns/page.mdx b/docs/app/docs-infra/patterns/page.mdx index 041a0d839..77930b2cc 100644 --- a/docs/app/docs-infra/patterns/page.mdx +++ b/docs/app/docs-infra/patterns/page.mdx @@ -7,6 +7,7 @@ - Built Factories Pattern - ([Outline](#built-factories-pattern), [Contents](./built-factories/page.mdx)) - Props Context Layering - ([Outline](#props-context-layering), [Contents](./props-context-layering/page.mdx)) - HAST (Hypertext Abstract Syntax Tree) - ([Outline](#hast-hypertext-abstract-syntax-tree), [Contents](./hast/page.mdx)) +- Prop Compression [New] - ([Outline](#prop-compression), [Contents](./prop-compression/page.mdx)) [//]: # 'This section is autogenerated, DO NOT EDIT AFTER THIS LINE, run: pnpm docs:validate docs-infra/patterns' @@ -96,6 +97,14 @@ HAST is the core data structure used throughout this package for syntax highligh [Read more](./hast/page.mdx) +## Prop Compression + +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 exceeding the 2MB restriction search engines have for crawling. + +[Read more](./prop-compression/page.mdx) + [//]: # 'The above section is autogenerated, but the remainder of the file can be modified.' diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx new file mode 100644 index 000000000..36c6cf613 --- /dev/null +++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx @@ -0,0 +1,179 @@ +# Prop Compression + +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 exceeding the 2MB restriction search engines have for crawling. + +To optimize for crawlers, the initial HTML should contain only semantically relevant information. +An example relevant to this project is code snippets. At a basic level, to understand the code, you only need the plain text. +However, for a human reader, adding different colors to the code can make it easier to read and understand. + +Large code snippets can balloon the overall size of the page, simply based on the number of `` 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 down hydration, as this data needs to be parsed and processed before the page can be interactive. + +To solve this problem, we can use a technique called "prop compression". + +We send the plain text variant of the code snippet in the initial HTML: + +```html +

+  console.log('Hello, world!');
+
+``` + +Then, because this code is a client component, we send the props to the client in a compressed format, such as a stringified JSON object: + +```json +{ + "code": "console.log('Hello, world!');", + "language": "javascript" +} +``` + +This HTML is parsed, then painted by the browser very quickly, and when the JS downloads, hydrates the entire page very quickly. +This requires us to have two copies of the plain text code in the HTML. This doubles the cost of code for uncompressed HTML, +but has no effect when compressed by HTTP servers. + +But then how do we enhance this code snippet? + +We can highlight the code, even after already streaming the plain text code back to the client: + +```json +{ + "highlightedCode": { + "type": "root", + "children": [ + { + "type": "element", + "tagName": "span", + "properties": { + "className": [ + "pl-en" + ] + }, + "children": [ + { + "type": "text", + "value": "console" + } + ] + }, + { + "type": "text", + "value": "." + }, + { + "type": "element", + "tagName": "span", + "properties": { + "className": [ + "pl-c1" + ] + }, + "children": [ + { + "type": "text", + "value": "log" + } + ] + }, + { + "type": "text", + "value": "(" + }, + { + "type": "element", + "tagName": "span", + "properties": { + "className": [ + "pl-s" + ] + }, + "children": [ + { + "type": "element", + "tagName": "span", + "properties": { + "className": [ + "pl-pds" + ] + }, + "children": [ + { + "type": "text", + "value": "'" + } + ] + }, + { + "type": "text", + "value": "Hello, world!" + }, + { + "type": "element", + "tagName": "span", + "properties": { + "className": [ + "pl-pds" + ] + }, + "children": [ + { + "type": "text", + "value": "'" + } + ] + } + ] + }, + { + "type": "text", + "value": ");" + } + ] + } +} +``` + +This added structured data is 704 bytes, compared to the plain text version of the code, which is 87 bytes (7.1x larger). +An HTML string of this highlighted code would be smaller at 193 bytes, but would require parsing and processing the HTML string, which is more expensive than parsing the JSON. + +To improve hydration time, we defer parsing by compressing the highlighted code as a JSON object, and only parsing it when we need to render the highlighted code. +We can do this with an intersection observer, which only parses the highlighted code when the code snippet is in the viewport. + +320 bytes gzipped, only (2.7x larger than the plain text, 66% larger than the html as a string). + +``` +H4sIAKYqx2kCA62TT2vDMAzFv8qmyzbIBrlm540dyi47dj2IWG0DqmVs9R8l371WW0opJLQlJ9vvyU8/gb0D3QaCCqKIQgH1vGEXyUM1PlvEtCBvruLsFxempYA+CyFKoKgNJah2UDOmdCqYRlsLcKj4bfs/xaijxmezvJC/vDuJ7YDd2SKPXUY5bcjsMQR+zzGTjkyljQWukJd2rMUnYYI213fVfMCF9zBTXd7KxDLr5XkdhCd14jwcGVx36NUML70T/hCzFE9rieyeB5n2XrQeuLfPXvZ/f7h/fN2GoqLI9okyWNm2e7/QiqvTAwAA +``` + +| Scenario | Initial HTML | Initial Props | Suspended Props | Total Weight | % | +|----------------------|--------------|---------------|-----------------|--------------|------| +| Plain text | 87 bytes | 0 bytes | 0 bytes | = 87 bytes | 100% | +| Plain Text Hydrated | 87 bytes | 87 bytes | 0 bytes | = 174 bytes | 200% | +| HTML String in Props | 193 bytes | 193 bytes | 0 bytes | = 386 bytes | 444% | +| Compressed Props | 87 bytes | 87 bytes | 320 bytes | = 494 bytes | 568% | + +Using HTML String in Props requires `dangerouslySetInnerHTML` to render, which can have security implications. +In order to enhance this code with interactivity, we would either need to parse the HTML string, or directly use browser APIs outside of React. + +To give another example where we have [a large code snippet](https://github.com/mui/mui-public/blob/530ec94f97bfe64ae018a9fd8ff0c326cbd61298/docs/app/bench/docs-infra/components/code-highlighter/snippets/large/snippet.ts): + +| Scenario | Initial HTML | Initial Props | Suspended Props | Total Weight | % | +|----------------------|--------------|---------------|-----------------|--------------|------| +| Plain Text | 47 KB | 0 bytes | 0 bytes | = 47 KB | 100% | +| Plain Text Hydrated | 47 KB | 47 KB | 0 bytes | = 94 KB | 200% | +| HTML String in Props | 203 KB | 203 KB | 0 bytes | = 406 KB | 864% | +| Compressed Props | 47 KB | 47 KB | 54 KB | = 149 KB | 317% | + +With compressed props, we get a significant reduction in the total weight to to send highlighted code to client components. + +After the given component comes into the viewport, we can decompress and parse the JSON object to get a HAST object into memory. +It can be stored in a `WeakSet` to avoid parsing and decompressing the JSON object multiple times, and then rendered as JSX. + +We use the library [`hast-util-to-jsx-runtime`](https://github.com/syntax-tree/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. From 272898f7849358c0ecb63534c85c73ee175e1c65 Mon Sep 17 00:00:00 2001 From: dav-is Date: Fri, 27 Mar 2026 22:26:07 -0400 Subject: [PATCH 02/61] Restructure doc --- .../patterns/prop-compression/page.mdx | 80 ++++++++++++------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx index 36c6cf613..ab72a94fc 100644 --- a/docs/app/docs-infra/patterns/prop-compression/page.mdx +++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx @@ -1,19 +1,28 @@ # Prop Compression -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 exceeding the 2MB restriction search engines have for crawling. +**Purpose**: Reduce page weight and improve hydration performance when sending complex data structures from the server to the client. -To optimize for crawlers, the initial HTML should contain only semantically relevant information. -An example relevant to this project is code snippets. At a basic level, to understand the code, you only need the plain text. -However, for a human reader, adding different colors to the code can make it easier to read and understand. +**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. -Large code snippets can balloon the overall size of the page, simply based on the number of `` 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 down hydration, as this data needs to be parsed and processed before the page can be interactive. +## The Problem -To solve this problem, we can use a technique called "prop compression". +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 `` 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: @@ -23,6 +32,8 @@ We send the plain text variant of the code snippet in the initial HTML: ``` +### 2. Serialize Minimal Props for Hydration + Then, because this code is a client component, we send the props to the client in a compressed format, such as a stringified JSON object: ```json @@ -32,11 +43,9 @@ Then, because this code is a client component, we send the props to the client i } ``` -This HTML is parsed, then painted by the browser very quickly, and when the JS downloads, hydrates the entire page very quickly. -This requires us to have two copies of the plain text code in the HTML. This doubles the cost of code for uncompressed HTML, -but has no effect when compressed by HTTP servers. +This HTML is parsed, then painted by the browser very quickly, and when the JS downloads, hydrates the entire page very quickly. This requires us to have two copies of the plain text code in the HTML. This doubles the cost of code for uncompressed HTML, but has no effect when compressed by HTTP servers. -But then how do we enhance this code snippet? +### 3. Defer Enhanced Data as Compressed Props We can highlight the code, even after already streaming the plain text code back to the client: @@ -137,18 +146,24 @@ We can highlight the code, even after already streaming the plain text code back } ``` -This added structured data is 704 bytes, compared to the plain text version of the code, which is 87 bytes (7.1x larger). -An HTML string of this highlighted code would be smaller at 193 bytes, but would require parsing and processing the HTML string, which is more expensive than parsing the JSON. +This added structured data is 704 bytes, compared to the plain text version of the code, which is 87 bytes (7.1x larger). An HTML string of this highlighted code would be smaller at 193 bytes, but rendering it safely often requires shipping extra parsing logic. -To improve hydration time, we defer parsing by compressing the highlighted code as a JSON object, and only parsing it when we need to render the highlighted code. -We can do this with an intersection observer, which only parses the highlighted code when the code snippet is in the viewport. +To improve hydration time, we defer parsing by compressing the highlighted code as a JSON object, and only parsing it when we need to render the highlighted code. We can do this with an `IntersectionObserver`, which parses highlighted data only when the snippet enters the viewport. -320 bytes gzipped, only (2.7x larger than the plain text, 66% larger than the html as a string). +This approach intentionally relies on the browser's built-in JSON parser (`JSON.parse`) instead of shipping a separate HTML parser. In practice, this is simpler to reason about, avoids parser bundle cost, and keeps behavior predictable across clients because JSON parsing is part of the JavaScript runtime. + +320 bytes gzipped (2.7x larger than plain text, 66% larger than the HTML string). ``` H4sIAKYqx2kCA62TT2vDMAzFv8qmyzbIBrlm540dyi47dj2IWG0DqmVs9R8l371WW0opJLQlJ9vvyU8/gb0D3QaCCqKIQgH1vGEXyUM1PlvEtCBvruLsFxempYA+CyFKoKgNJah2UDOmdCqYRlsLcKj4bfs/xaijxmezvJC/vDuJ7YDd2SKPXUY5bcjsMQR+zzGTjkyljQWukJd2rMUnYYI213fVfMCF9zBTXd7KxDLr5XkdhCd14jwcGVx36NUML70T/hCzFE9rieyeB5n2XrQeuLfPXvZ/f7h/fN2GoqLI9okyWNm2e7/QiqvTAwAA ``` +--- + +## Size Comparison + +### Small Snippet + | Scenario | Initial HTML | Initial Props | Suspended Props | Total Weight | % | |----------------------|--------------|---------------|-----------------|--------------|------| | Plain text | 87 bytes | 0 bytes | 0 bytes | = 87 bytes | 100% | @@ -156,8 +171,9 @@ H4sIAKYqx2kCA62TT2vDMAzFv8qmyzbIBrlm540dyi47dj2IWG0DqmVs9R8l371WW0opJLQlJ9vvyU8/ | HTML String in Props | 193 bytes | 193 bytes | 0 bytes | = 386 bytes | 444% | | Compressed Props | 87 bytes | 87 bytes | 320 bytes | = 494 bytes | 568% | -Using HTML String in Props requires `dangerouslySetInnerHTML` to render, which can have security implications. -In order to enhance this code with interactivity, we would either need to parse the HTML string, or directly use browser APIs outside of React. +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. + +### Large Snippet To give another example where we have [a large code snippet](https://github.com/mui/mui-public/blob/530ec94f97bfe64ae018a9fd8ff0c326cbd61298/docs/app/bench/docs-infra/components/code-highlighter/snippets/large/snippet.ts): @@ -168,12 +184,22 @@ To give another example where we have [a large code snippet](https://github.com/ | HTML String in Props | 203 KB | 203 KB | 0 bytes | = 406 KB | 864% | | Compressed Props | 47 KB | 47 KB | 54 KB | = 149 KB | 317% | -With compressed props, we get a significant reduction in the total weight to to send highlighted code to client components. +With compressed props, we get a significant reduction in total weight when sending highlighted code to client components. + +### 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. + +--- + +## Decompression & Rendering + +After the component enters the viewport, we can decompress and parse the JSON object to load a [HAST](../hast/) object into memory. It can be stored in a `WeakSet` to avoid repeated parsing and decompression, and then rendered as JSX. -After the given component comes into the viewport, we can decompress and parse the JSON object to get a HAST object into memory. -It can be stored in a `WeakSet` to avoid parsing and decompressing the JSON object multiple times, and then rendered as JSX. +For compression and decompression, this project uses [`fflate`](https://www.npmjs.com/package/fflate). -We use the library [`hast-util-to-jsx-runtime`](https://github.com/syntax-tree/hast-util-to-jsx-runtime) -to turn this HAST object into regular React components. +We use the library [`hast-util-to-jsx-runtime`](https://github.com/syntax-tree/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. +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. From 33f5557e93fbe2863d6c2e65ef922057453a4a37 Mon Sep 17 00:00:00 2001 From: dav-is Date: Fri, 27 Mar 2026 22:26:20 -0400 Subject: [PATCH 03/61] Add abstracting note to docs --- .../patterns/prop-compression/page.mdx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx index ab72a94fc..7c8bba5ca 100644 --- a/docs/app/docs-infra/patterns/prop-compression/page.mdx +++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx @@ -203,3 +203,33 @@ For compression and decompression, this project uses [`fflate`](https://www.npmj We use the library [`hast-util-to-jsx-runtime`](https://github.com/syntax-tree/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. + +## Abstracting Complexity from Users + +We can use the [Props Context Layering](../props-context-layering/page.mdx) pattern to abstract this complexity. + +If you imagine the loading components following a pattern similar to this: + +```jsx + + }> + + + +``` + +The user can write a content handler abstracted like this: + +```jsx +'use client'; + +function ContentHandler(props) { + const { CodeBlock } = useCode(props); + + return
+ +
; +} +``` + +This is roughly how the [`CodeHighlighter`](../../components/code-highlighter/page.mdx) works. \ No newline at end of file From a95a0c96f830e578aee9ee4bb0289d8186eefe7d Mon Sep 17 00:00:00 2001 From: dav-is Date: Fri, 27 Mar 2026 22:39:11 -0400 Subject: [PATCH 04/61] More docs improvements --- .../patterns/prop-compression/page.mdx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx index 7c8bba5ca..306d7cf79 100644 --- a/docs/app/docs-infra/patterns/prop-compression/page.mdx +++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx @@ -204,11 +204,19 @@ We use the library [`hast-util-to-jsx-runtime`](https://github.com/syntax-tree/h 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. -## Abstracting Complexity from Users +--- + +## How Compressed Data Arrives + +Compressed props need to be produced somewhere. The [Built Factories](../built-factories/page.mdx) pattern solves this: a build-time loader processes each factory call (`createDemo(import.meta.url, …)`) and injects a `precompute` object containing the gzip-compressed HAST. The [`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx) loader's `hastGzip` output mode is a concrete implementation of this. -We can use the [Props Context Layering](../props-context-layering/page.mdx) pattern to abstract this complexity. +At runtime, the factory receives precomputed data through its options — 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. -If you imagine the loading components following a pattern similar to this: +--- + +## Abstracting Complexity from Users + +The [Props Context Layering](../props-context-layering/page.mdx) 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: ```jsx @@ -218,7 +226,7 @@ If you imagine the loading components following a pattern similar to this: ``` -The user can write a content handler abstracted like this: +A content handler only needs to consume the result: ```jsx 'use client'; @@ -232,4 +240,14 @@ function ContentHandler(props) { } ``` -This is roughly how the [`CodeHighlighter`](../../components/code-highlighter/page.mdx) works. \ No newline at end of file +This is roughly how the [`CodeHighlighter`](../../components/code-highlighter/page.mdx) works. + +--- + +## Real-World Usage + +This pattern is used throughout the docs-infra system: + +- **[`CodeHighlighter`](../../components/code-highlighter/page.mdx)**: Renders plain text as the `Suspense` fallback, then progressively enhances with decompressed highlighted code +- **[`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx)**: Build-time loader that produces compressed HAST via the `hastGzip` output format +- **[`useCode`](../../hooks/use-code/page.mdx)**: Hook that manages decompression and caching of compressed props on the client \ No newline at end of file From 6420b9a90e744d6d8ef4d1eaca3b164e7d0a7ab0 Mon Sep 17 00:00:00 2001 From: dav-is Date: Fri, 27 Mar 2026 22:43:44 -0400 Subject: [PATCH 05/61] Add link to base ui case --- .../patterns/prop-compression/page.mdx | 52 +++++++++++-------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx index 306d7cf79..2ece2a9b8 100644 --- a/docs/app/docs-infra/patterns/prop-compression/page.mdx +++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx @@ -58,9 +58,7 @@ We can highlight the code, even after already streaming the plain text code back "type": "element", "tagName": "span", "properties": { - "className": [ - "pl-en" - ] + "className": ["pl-en"] }, "children": [ { @@ -77,9 +75,7 @@ We can highlight the code, even after already streaming the plain text code back "type": "element", "tagName": "span", "properties": { - "className": [ - "pl-c1" - ] + "className": ["pl-c1"] }, "children": [ { @@ -96,18 +92,14 @@ We can highlight the code, even after already streaming the plain text code back "type": "element", "tagName": "span", "properties": { - "className": [ - "pl-s" - ] + "className": ["pl-s"] }, "children": [ { "type": "element", "tagName": "span", "properties": { - "className": [ - "pl-pds" - ] + "className": ["pl-pds"] }, "children": [ { @@ -124,9 +116,7 @@ We can highlight the code, even after already streaming the plain text code back "type": "element", "tagName": "span", "properties": { - "className": [ - "pl-pds" - ] + "className": ["pl-pds"] }, "children": [ { @@ -165,7 +155,7 @@ H4sIAKYqx2kCA62TT2vDMAzFv8qmyzbIBrlm540dyi47dj2IWG0DqmVs9R8l371WW0opJLQlJ9vvyU8/ ### Small Snippet | Scenario | Initial HTML | Initial Props | Suspended Props | Total Weight | % | -|----------------------|--------------|---------------|-----------------|--------------|------| +| -------------------- | ------------ | ------------- | --------------- | ------------ | ---- | | Plain text | 87 bytes | 0 bytes | 0 bytes | = 87 bytes | 100% | | Plain Text Hydrated | 87 bytes | 87 bytes | 0 bytes | = 174 bytes | 200% | | HTML String in Props | 193 bytes | 193 bytes | 0 bytes | = 386 bytes | 444% | @@ -178,7 +168,7 @@ Using HTML String in Props requires `dangerouslySetInnerHTML` to render, which c To give another example where we have [a large code snippet](https://github.com/mui/mui-public/blob/530ec94f97bfe64ae018a9fd8ff0c326cbd61298/docs/app/bench/docs-infra/components/code-highlighter/snippets/large/snippet.ts): | Scenario | Initial HTML | Initial Props | Suspended Props | Total Weight | % | -|----------------------|--------------|---------------|-----------------|--------------|------| +| -------------------- | ------------ | ------------- | --------------- | ------------ | ---- | | Plain Text | 47 KB | 0 bytes | 0 bytes | = 47 KB | 100% | | Plain Text Hydrated | 47 KB | 47 KB | 0 bytes | = 94 KB | 200% | | HTML String in Props | 203 KB | 203 KB | 0 bytes | = 406 KB | 864% | @@ -234,9 +224,11 @@ A content handler only needs to consume the result: function ContentHandler(props) { const { CodeBlock } = useCode(props); - return
- -
; + return ( +
+ +
+ ); } ``` @@ -244,10 +236,28 @@ This is roughly how the [`CodeHighlighter`](../../components/code-highlighter/pa --- +## Measured Impact + +When this pattern was applied to the [Base UI docs](https://github.com/mui/base-ui/pull/2443), profiling showed significant improvements to core web vitals: + +| Metric | Before | After | Change | +| ------------------------ | ------ | ----- | ------------- | +| FCP (Menubar page) | 0.27s | 0.21s | -22% | +| HTML parse time | 18.4ms | 7.8ms | -58% | +| HTML + CSS render time | 43ms | 27ms | -37% | +| LCP (Menu page, 6 demos) | 0.43s | 0.33s | -23% | +| Total Blocking Time | — | 0s | No long tasks | + +By sending only plain text in the initial HTML, the browser parses fewer DOM nodes and skips complex `` styling entirely. Rendering plain text without syntax-highlighted class names is inherently cheaper for both the HTML parser and the CSS engine. + +Total Blocking Time remains at zero because decompression and highlighting are split into small chunks using `scheduler.yield()` and `requestIdleCallback()`, ensuring no single task exceeds 50ms. This allows demo components to hydrate and become interactive before any code is highlighted — users expect demos to work instantly, while highlighting is not essential to reading or interacting with the page. + +--- + ## Real-World Usage This pattern is used throughout the docs-infra system: - **[`CodeHighlighter`](../../components/code-highlighter/page.mdx)**: Renders plain text as the `Suspense` fallback, then progressively enhances with decompressed highlighted code - **[`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx)**: Build-time loader that produces compressed HAST via the `hastGzip` output format -- **[`useCode`](../../hooks/use-code/page.mdx)**: Hook that manages decompression and caching of compressed props on the client \ No newline at end of file +- **[`useCode`](../../hooks/use-code/page.mdx)**: Hook that manages decompression and caching of compressed props on the client From 1462865dd65d93f178764082e347fa6da8915dde Mon Sep 17 00:00:00 2001 From: dav-is Date: Fri, 27 Mar 2026 22:49:39 -0400 Subject: [PATCH 06/61] Update index --- docs/app/docs-infra/patterns/page.mdx | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/docs/app/docs-infra/patterns/page.mdx b/docs/app/docs-infra/patterns/page.mdx index 77930b2cc..63c161da2 100644 --- a/docs/app/docs-infra/patterns/page.mdx +++ b/docs/app/docs-infra/patterns/page.mdx @@ -99,9 +99,29 @@ HAST is the core data structure used throughout this package for syntax highligh ## Prop Compression -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 exceeding the 2MB restriction search engines have for crawling. +**Purpose**: Reduce page weight and improve hydration performance when sending complex data structures from the server to the client. + +
+ +Outline + +- Sections: + - The Problem + - How It Works + - 1\. Send Plain Text in Initial HTML + - 2\. Serialize Minimal Props for Hydration + - 3\. Defer Enhanced Data as Compressed Props + - Size Comparison + - Small Snippet + - Large Snippet + - When Not to Use This Pattern + - Decompression & Rendering + - How Compressed Data Arrives + - Abstracting Complexity from Users + - Measured Impact + - Real-World Usage + +
[Read more](./prop-compression/page.mdx) From e7eaffec7ef6d9a25e97838a08de9fb94f9ff9c5 Mon Sep 17 00:00:00 2001 From: dav-is Date: Fri, 27 Mar 2026 23:14:03 -0400 Subject: [PATCH 07/61] Another pass at the doc --- .../patterns/prop-compression/page.mdx | 131 ++++++++++++++---- 1 file changed, 102 insertions(+), 29 deletions(-) diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx index 2ece2a9b8..85409cd1e 100644 --- a/docs/app/docs-infra/patterns/prop-compression/page.mdx +++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx @@ -8,15 +8,30 @@ ## 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 `` 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. +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 `` 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. --- @@ -43,7 +58,11 @@ Then, because this code is a client component, we send the props to the client i } ``` -This HTML is parsed, then painted by the browser very quickly, and when the JS downloads, hydrates the entire page very quickly. This requires us to have two copies of the plain text code in the HTML. This doubles the cost of code for uncompressed HTML, but has no effect when compressed by HTTP servers. +This HTML is parsed, then painted by the browser very quickly, and when the JS +downloads, hydrates the entire page very quickly. This requires us to have two +copies of the plain text code in the HTML. This doubles the cost of code for +uncompressed HTML, but compression substantially reduces the duplicate transfer +cost in practice. ### 3. Defer Enhanced Data as Compressed Props @@ -136,11 +155,21 @@ We can highlight the code, even after already streaming the plain text code back } ``` -This added structured data is 704 bytes, compared to the plain text version of the code, which is 87 bytes (7.1x larger). An HTML string of this highlighted code would be smaller at 193 bytes, but rendering it safely often requires shipping extra parsing logic. +This added structured data is 704 bytes, compared to the plain text version of +the code, which is 87 bytes (7.1x larger). An HTML string of this highlighted +code would be smaller at 193 bytes, but rendering it safely often requires +shipping extra parsing logic. -To improve hydration time, we defer parsing by compressing the highlighted code as a JSON object, and only parsing it when we need to render the highlighted code. We can do this with an `IntersectionObserver`, which parses highlighted data only when the snippet enters the viewport. +To improve hydration time, we defer parsing by compressing the highlighted code +as a JSON object, and only parsing it when we need to render the highlighted +code. We can do this with an `IntersectionObserver`, which parses highlighted +data only when the snippet enters the viewport. -This approach intentionally relies on the browser's built-in JSON parser (`JSON.parse`) instead of shipping a separate HTML parser. In practice, this is simpler to reason about, avoids parser bundle cost, and keeps behavior predictable across clients because JSON parsing is part of the JavaScript runtime. +This approach intentionally relies on the browser's built-in JSON parser +(`JSON.parse`) instead of shipping a separate HTML parser. In practice, this is +simpler to reason about, avoids parser bundle cost, and keeps behavior +predictable across clients because JSON parsing is part of the JavaScript +runtime. 320 bytes gzipped (2.7x larger than plain text, 66% larger than the HTML string). @@ -161,7 +190,10 @@ H4sIAKYqx2kCA62TT2vDMAzFv8qmyzbIBrlm540dyi47dj2IWG0DqmVs9R8l371WW0opJLQlJ9vvyU8/ | HTML String in Props | 193 bytes | 193 bytes | 0 bytes | = 386 bytes | 444% | | Compressed Props | 87 bytes | 87 bytes | 320 bytes | = 494 bytes | 568% | -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. +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. ### Large Snippet @@ -174,39 +206,70 @@ To give another example where we have [a large code snippet](https://github.com/ | HTML String in Props | 203 KB | 203 KB | 0 bytes | = 406 KB | 864% | | Compressed Props | 47 KB | 47 KB | 54 KB | = 149 KB | 317% | -With compressed props, we get a significant reduction in total weight when sending highlighted code to client components. +With compressed props, we get a significant reduction in total weight when +sending highlighted code to client components. ### 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. +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. +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. --- ## Decompression & Rendering -After the component enters the viewport, we can decompress and parse the JSON object to load a [HAST](../hast/) object into memory. It can be stored in a `WeakSet` to avoid repeated parsing and decompression, and then rendered as JSX. +After the component enters the viewport, we can decompress and parse the JSON +object to load a [HAST](../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`](https://www.npmjs.com/package/fflate). -We use the library [`hast-util-to-jsx-runtime`](https://github.com/syntax-tree/hast-util-to-jsx-runtime) to turn this HAST object into regular React components. +We use the library +[`hast-util-to-jsx-runtime`](https://github.com/syntax-tree/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. +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](../built-factories/page.mdx) pattern solves this: a build-time loader processes each factory call (`createDemo(import.meta.url, …)`) and injects a `precompute` object containing the gzip-compressed HAST. The [`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx) loader's `hastGzip` output mode is a concrete implementation of this. +Compressed props need to be produced somewhere. The +[Built Factories](../built-factories/page.mdx) pattern solves this: a +build-time loader processes each factory call (`createDemo(import.meta.url, +...)`) and injects a `precompute` object containing the gzip-compressed HAST. +The +[`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx) +loader's `hastGzip` output mode is a concrete implementation of this. -At runtime, the factory receives precomputed data through its options — 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. +At runtime, the factory receives precomputed data through its options - 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](../props-context-layering/page.mdx) 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: +The [Props Context Layering](../props-context-layering/page.mdx) 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: ```jsx @@ -238,7 +301,9 @@ This is roughly how the [`CodeHighlighter`](../../components/code-highlighter/pa ## Measured Impact -When this pattern was applied to the [Base UI docs](https://github.com/mui/base-ui/pull/2443), profiling showed significant improvements to core web vitals: +When this pattern was applied to the +[Base UI docs](https://github.com/mui/base-ui/pull/2443), profiling showed +significant improvements to core web vitals: | Metric | Before | After | Change | | ------------------------ | ------ | ----- | ------------- | @@ -246,11 +311,19 @@ When this pattern was applied to the [Base UI docs](https://github.com/mui/base- | HTML parse time | 18.4ms | 7.8ms | -58% | | HTML + CSS render time | 43ms | 27ms | -37% | | LCP (Menu page, 6 demos) | 0.43s | 0.33s | -23% | -| Total Blocking Time | — | 0s | No long tasks | - -By sending only plain text in the initial HTML, the browser parses fewer DOM nodes and skips complex `` styling entirely. Rendering plain text without syntax-highlighted class names is inherently cheaper for both the HTML parser and the CSS engine. - -Total Blocking Time remains at zero because decompression and highlighting are split into small chunks using `scheduler.yield()` and `requestIdleCallback()`, ensuring no single task exceeds 50ms. This allows demo components to hydrate and become interactive before any code is highlighted — users expect demos to work instantly, while highlighting is not essential to reading or interacting with the page. +| Total Blocking Time | N/A | 0s | No long tasks | + +By sending only plain text in the initial HTML, the browser parses fewer DOM +nodes and skips complex `` styling entirely. Rendering plain text without +syntax-highlighted class names is inherently cheaper for both the HTML parser +and the CSS engine. + +Total Blocking Time remains at zero because plain text keeps the initial render +cheap, and deferred highlighted payloads let pages with many code blocks +enhance them incrementally during idle time instead of front-loading all +highlighting work into initial hydration. This allows demo components to +hydrate and become interactive before any code is highlighted, while still +progressively improving readability as highlighted blocks become eligible. --- From 4b3f598b1594073839ef728d6b281757f3134667 Mon Sep 17 00:00:00 2001 From: dav-is Date: Mon, 30 Mar 2026 13:40:04 -0400 Subject: [PATCH 08/61] Add initial implementation --- .../factories/abstract-create-types/page.mdx | 26 ++ .../factories/abstract-create-types/types.md | 12 + docs/app/docs-infra/factories/page.mdx | 1 + .../pipeline/load-server-types/types.md | 33 +- docs/app/docs-infra/pipeline/page.mdx | 2 +- .../DeferredHighlightClient.tsx | 67 ++++ .../abstractCreateTypes.tsx | 16 + .../stripHighlightingSpans.test.ts | 307 +++++++++++++++++ .../stripHighlightingSpans.ts | 55 +++ .../abstractCreateTypes/typesToJsx.test.ts | 318 ++++++++++++++++++ .../src/abstractCreateTypes/typesToJsx.ts | 223 ++++++++++-- .../loadPrecomputedTypes.ts | 2 +- .../pipeline/loadServerTypes/hastTypeUtils.ts | 31 ++ .../loadServerTypes/highlightTypes.ts | 18 +- .../highlightTypesMeta.test.ts | 4 +- .../loadServerTypes/highlightTypesMeta.ts | 97 +++--- .../loadServerTypes/loadServerTypes.ts | 41 +-- 17 files changed, 1126 insertions(+), 127 deletions(-) create mode 100644 packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx create mode 100644 packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts create mode 100644 packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts diff --git a/docs/app/docs-infra/factories/abstract-create-types/page.mdx b/docs/app/docs-infra/factories/abstract-create-types/page.mdx index f893692fe..0bb356156 100644 --- a/docs/app/docs-infra/factories/abstract-create-types/page.mdx +++ b/docs/app/docs-infra/factories/abstract-create-types/page.mdx @@ -221,6 +221,32 @@ The `enhancersInline` option is the same concept but scoped to inline fields (`s Both options can be set at the factory level or overridden per type via `TypesTableMeta.enhancers` / `TypesTableMeta.enhancersInline`. Pass an empty array to disable all enhancers for that scope. +## highlightAt + +The `highlightAt` option controls when expensive `detailedType` and `formattedCode` HAST fields are converted to fully-highlighted JSX. These fields contain large syntax-highlighted trees that can be costly to render during SSG — deferring them to the client reduces server rendering time and initial page weight. + +- **`'init'`** — convert immediately during SSG. All highlighting is included in the server-rendered HTML. +- **`'hydration'`** — server-render a links-only fallback (syntax highlighting spans stripped, cross-reference links preserved), then replace with fully-highlighted content on client mount. +- **`'idle'`** (default) — same fallback, but defer full highlighting until the browser is idle (`requestIdleCallback`). + +To opt out of deferred rendering: + +```ts +const options = { + TypesTable, + components: mdxComponents, + TypePre: PreInline, + highlightAt: 'init', +} satisfies AbstractCreateTypesOptions; +``` + +Only `detailedType` and `formattedCode` fields are affected — compact fields like `type`, `shortType`, and `default` are always rendered at init since they're small. The fallback preserves all `` links so type cross-references remain interactive before highlighting completes. + +> [!TIP] +> The default `'idle'` works well for most documentation sites. The visual difference is minimal — most users don't expand detailed types immediately — and the server rendering savings can be substantial. Use `'init'` only if you need fully-highlighted type blocks in the initial server-rendered HTML. + +This option can be set at the factory level or overridden per type via `TypesTableMeta.highlightAt`. + ## Build Integration To make type extraction work at build time, configure the [`loadPrecomputedTypes`](../../pipeline/load-precomputed-types/page.mdx) webpack/Turbopack loader. The [`withDocsInfra`](../../pipeline/with-docs-infra/page.mdx) Next.js plugin includes this automatically. diff --git a/docs/app/docs-infra/factories/abstract-create-types/types.md b/docs/app/docs-infra/factories/abstract-create-types/types.md index befb61162..442d4d027 100644 --- a/docs/app/docs-infra/factories/abstract-create-types/types.md +++ b/docs/app/docs-infra/factories/abstract-create-types/types.md @@ -154,6 +154,12 @@ type AbstractCreateTypesOptions = { * Can be overridden by TypesTableMeta.defaultImportSlug. */ defaultImportSlug?: string; + /** + * Controls when expensive detailedType and formattedCode HAST fields are + * converted to fully-highlighted JSX. + * Can be overridden by TypesTableMeta.highlightAt. + */ + highlightAt?: 'init' | 'hydration' | 'idle'; }; ``` @@ -274,6 +280,12 @@ type TypesTableMeta = { * Pass an empty array to disable all inline enhancers. */ enhancersInline?: Pluggable[]; + /** + * Controls when expensive detailedType and formattedCode HAST fields are + * converted to fully-highlighted JSX. + * When set, overrides the factory-level highlightAt. + */ + highlightAt?: 'init' | 'hydration' | 'idle'; /** * Custom component tag name to use instead of `` for type reference links. * When set, enhanceCodeTypes emits elements with this tag name, diff --git a/docs/app/docs-infra/factories/page.mdx b/docs/app/docs-infra/factories/page.mdx index 84e28d4ff..d593a319d 100644 --- a/docs/app/docs-infra/factories/page.mdx +++ b/docs/app/docs-infra/factories/page.mdx @@ -86,6 +86,7 @@ The `abstractCreateTypes` function helps you create structured type documentatio - Variant-Only Groups (Types-Only Modules) - Customizing Type Rendering - Enhancers + - highlightAt - Build Integration - typeRefComponent - typePropRefComponent diff --git a/docs/app/docs-infra/pipeline/load-server-types/types.md b/docs/app/docs-infra/pipeline/load-server-types/types.md index df2d283dc..cf64742c4 100644 --- a/docs/app/docs-infra/pipeline/load-server-types/types.md +++ b/docs/app/docs-infra/pipeline/load-server-types/types.md @@ -333,14 +333,16 @@ type LoadServerTypesOptions = { */ sync?: boolean; /** - * When true, replaces HAST Root nodes in the result with `{ hastJson: string }` - * wrappers. This defers tree allocation from module-evaluation time to render - * time: V8 only creates a string instead of the full object graph, and - * `JSON.parse` at render time provides both deserialization and a free deep - * clone (eliminating the need for `structuredClone`). - * @default false + * Controls the output format for HAST nodes in the result. + * + * - `'hast'`: Live HAST Root objects (default) + * - `'hastJson'`: JSON-serialized `{ hastJson: string }` wrappers — defers + * tree allocation from module-evaluation time to render time + * - `'hastGzip'`: Gzip-compressed + base64-encoded `{ hastGzip: string }` + * wrappers — smallest payload, decompressed at render time + * @default 'hast' */ - serializeHast?: boolean; + output?: TypesOutputFormat; /** Absolute path to the types.md file to generate */ typesMarkdownPath: string; /** Root context directory (workspace root) */ @@ -433,6 +435,15 @@ type LoadServerTypesResult = { }; ``` +### SerializedHastGzip + +A gzip-compressed, base64-encoded wrapper around a HastRoot. +Smaller than JSON for transport; decoded and decompressed at render time. + +```typescript +type SerializedHastGzip = { hastGzip: string }; +``` + ### SerializedHastRoot A JSON-serialized wrapper around a HastRoot. Defers tree allocation to @@ -442,3 +453,11 @@ provides both deserialization and a free deep clone. ```typescript type SerializedHastRoot = { hastJson: string }; ``` + +### TypesOutputFormat + +Controls the output format of HAST fields in type metadata. + +```typescript +type TypesOutputFormat = 'hast' | 'hastJson' | 'hastGzip'; +``` diff --git a/docs/app/docs-infra/pipeline/page.mdx b/docs/app/docs-infra/pipeline/page.mdx index 5cbfc386a..e50647d1a 100644 --- a/docs/app/docs-infra/pipeline/page.mdx +++ b/docs/app/docs-infra/pipeline/page.mdx @@ -771,7 +771,7 @@ A server-side function for loading and processing TypeScript types with syntax h - Exports: - loadServerTypes - Parameters: options -- Types: HighlightedClassProperty, HighlightedClassTypeMeta, HighlightedComponentTypeMeta, HighlightedEnumMemberMeta, HighlightedFunctionTypeMeta, HighlightedHookTypeMeta, HighlightedMethod, HighlightedParameter, HighlightedProperty, HighlightedRawTypeMeta, HighlightedTypesMeta, LoadServerTypesOptions, LoadServerTypesResult, SerializedHastRoot +- Types: HighlightedClassProperty, HighlightedClassTypeMeta, HighlightedComponentTypeMeta, HighlightedEnumMemberMeta, HighlightedFunctionTypeMeta, HighlightedHookTypeMeta, HighlightedMethod, HighlightedParameter, HighlightedProperty, HighlightedRawTypeMeta, HighlightedTypesMeta, LoadServerTypesOptions, LoadServerTypesResult, SerializedHastGzip, SerializedHastRoot, TypesOutputFormat diff --git a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx new file mode 100644 index 000000000..f37d7b206 --- /dev/null +++ b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx @@ -0,0 +1,67 @@ +'use client'; +import * as React from 'react'; +import type { Root as HastRoot } from 'hast'; +import { decompressSync, strFromU8 } from 'fflate'; +import { decode } from 'uint8-to-base64'; +import { hastToJsx } from '../pipeline/hastUtils'; + +type HighlightAt = 'hydration' | 'idle'; + +interface DeferredHighlightClientProps { + /** JSON-serialized array of HAST children. Used when data is not compressed. */ + hastJson?: string; + /** Gzip-compressed, base64-encoded array of HAST children. */ + hastGzip?: string; + /** When to replace the fallback with the fully-highlighted version. */ + highlightAt: HighlightAt; + /** Server-rendered links-only fallback. */ + children?: React.ReactNode; +} + +/** + * Renders a links-only fallback on the server and replaces it with the + * fully syntax-highlighted version on the client at the configured time. + * + * The component only handles the inner content of a `` element — + * the outer `
` / `TypePre` wrapper stays server-rendered.
+ */
+export function DeferredHighlightClient({
+  hastJson,
+  hastGzip,
+  highlightAt,
+  children,
+}: DeferredHighlightClientProps) {
+  const [highlighted, setHighlighted] = React.useState(null);
+
+  React.useEffect(() => {
+    const render = () => {
+      let nodes;
+      if (hastGzip) {
+        nodes = JSON.parse(strFromU8(decompressSync(decode(hastGzip))));
+      } else {
+        nodes = JSON.parse(hastJson!);
+      }
+      const hast: HastRoot = { type: 'root', children: nodes };
+      setHighlighted(hastToJsx(hast));
+    };
+
+    if (highlightAt === 'hydration') {
+      render();
+      return undefined;
+    }
+
+    // 'idle' — defer until the browser is idle
+    if (typeof requestIdleCallback !== 'undefined') {
+      const id = requestIdleCallback(render);
+      return () => cancelIdleCallback(id);
+    }
+    const id = setTimeout(render, 0);
+    return () => clearTimeout(id);
+  }, [hastJson, hastGzip, highlightAt]);
+
+  if (highlighted !== null) {
+    return highlighted;
+  }
+
+  return children;
+}
diff --git a/packages/docs-infra/src/abstractCreateTypes/abstractCreateTypes.tsx b/packages/docs-infra/src/abstractCreateTypes/abstractCreateTypes.tsx
index aab451777..9c5eb8e6a 100644
--- a/packages/docs-infra/src/abstractCreateTypes/abstractCreateTypes.tsx
+++ b/packages/docs-infra/src/abstractCreateTypes/abstractCreateTypes.tsx
@@ -114,6 +114,12 @@ export type TypesTableMeta = {
    * Pass an empty array to disable all inline enhancers.
    */
   enhancersInline?: PluggableList;
+  /**
+   * Controls when expensive detailedType and formattedCode HAST fields are
+   * converted to fully-highlighted JSX.
+   * When set, overrides the factory-level highlightAt.
+   */
+  highlightAt?: TypesJsxOptions['highlightAt'];
   /**
    * Custom component tag name to use instead of `` for type reference links.
    * When set, enhanceCodeTypes emits elements with this tag name,
@@ -261,6 +267,12 @@ export type AbstractCreateTypesOptions = {
    * Can be overridden by TypesTableMeta.defaultImportSlug.
    */
   defaultImportSlug?: string;
+  /**
+   * Controls when expensive detailedType and formattedCode HAST fields are
+   * converted to fully-highlighted JSX.
+   * Can be overridden by TypesTableMeta.highlightAt.
+   */
+  highlightAt?: TypesJsxOptions['highlightAt'];
 };
 
 export function abstractCreateTypes(
@@ -293,6 +305,7 @@ export function abstractCreateTypes(
   const ShortTypeCode = meta.ShortTypeCode ?? options.ShortTypeCode;
   const DefaultCode = meta.DefaultCode ?? options.DefaultCode;
   const RawTypePre = meta.RawTypePre ?? options.RawTypePre;
+  const highlightAt = meta.highlightAt ?? options.highlightAt;
 
   // Enhancers from meta completely override options.enhancers if set
   // Use DEFAULT_ENHANCERS if neither meta nor options specify enhancers
@@ -387,6 +400,7 @@ export function abstractCreateTypes(
             RawTypePre,
             enhancers,
             enhancersInline,
+            highlightAt,
           },
           // Include additionalTypes for:
           // 1. Single component mode (createTypes)
@@ -501,6 +515,7 @@ function createAdditionalTypesComponent(
   const ShortTypeCode = meta.ShortTypeCode ?? options.ShortTypeCode;
   const DefaultCode = meta.DefaultCode ?? options.DefaultCode;
   const RawTypePre = meta.RawTypePre ?? options.RawTypePre;
+  const highlightAt = meta.highlightAt ?? options.highlightAt;
 
   // Enhancers from meta completely override options.enhancers if set
   // Use DEFAULT_ENHANCERS if neither meta nor options specify enhancers
@@ -571,6 +586,7 @@ function createAdditionalTypesComponent(
           RawTypePre,
           enhancers,
           enhancersInline,
+          highlightAt,
         }),
       [],
     );
diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
new file mode 100644
index 000000000..868e25373
--- /dev/null
+++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
@@ -0,0 +1,307 @@
+import { describe, it, expect } from 'vitest';
+import type { Root as HastRoot, Element as HastElement } from 'hast';
+import { stripHighlightingSpans } from './stripHighlightingSpans';
+
+describe('stripHighlightingSpans', () => {
+  it('should return text-only trees unchanged', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [{ type: 'text', value: 'hello world' }],
+    };
+    expect(stripHighlightingSpans(root)).toEqual(root);
+  });
+
+  it('should unwrap span elements and keep their text content', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: { className: ['pl-k'] },
+          children: [{ type: 'text', value: 'type' }],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    expect(result.children).toEqual([{ type: 'text', value: 'type' }]);
+  });
+
+  it('should unwrap nested spans', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: {},
+          children: [
+            {
+              type: 'element',
+              tagName: 'span',
+              properties: { className: ['pl-k'] },
+              children: [{ type: 'text', value: 'type' }],
+            },
+          ],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    expect(result.children).toEqual([{ type: 'text', value: 'type' }]);
+  });
+
+  it('should preserve link (a) elements', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'a',
+          properties: { href: '#button-props' },
+          children: [{ type: 'text', value: 'ButtonProps' }],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    expect(result.children).toEqual([
+      {
+        type: 'element',
+        tagName: 'a',
+        properties: { href: '#button-props' },
+        children: [{ type: 'text', value: 'ButtonProps' }],
+      },
+    ]);
+  });
+
+  it('should unwrap spans inside links', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'a',
+          properties: { href: '#props' },
+          children: [
+            {
+              type: 'element',
+              tagName: 'span',
+              properties: { className: ['pl-smi'] },
+              children: [{ type: 'text', value: 'Props' }],
+            },
+          ],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    expect(result.children).toEqual([
+      {
+        type: 'element',
+        tagName: 'a',
+        properties: { href: '#props' },
+        children: [{ type: 'text', value: 'Props' }],
+      },
+    ]);
+  });
+
+  it('should preserve links that are wrapped in spans', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: {},
+          children: [
+            {
+              type: 'element',
+              tagName: 'a',
+              properties: { href: '#ref' },
+              children: [{ type: 'text', value: 'Ref' }],
+            },
+          ],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    expect(result.children).toEqual([
+      {
+        type: 'element',
+        tagName: 'a',
+        properties: { href: '#ref' },
+        children: [{ type: 'text', value: 'Ref' }],
+      },
+    ]);
+  });
+
+  it('should preserve container elements like pre and code', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'pre',
+          properties: {},
+          children: [
+            {
+              type: 'element',
+              tagName: 'code',
+              properties: {},
+              children: [
+                {
+                  type: 'element',
+                  tagName: 'span',
+                  properties: { className: ['pl-k'] },
+                  children: [{ type: 'text', value: 'type' }],
+                },
+                { type: 'text', value: ' ' },
+                {
+                  type: 'element',
+                  tagName: 'a',
+                  properties: { href: '#my-type' },
+                  children: [
+                    {
+                      type: 'element',
+                      tagName: 'span',
+                      properties: { className: ['pl-smi'] },
+                      children: [{ type: 'text', value: 'MyType' }],
+                    },
+                  ],
+                },
+                { type: 'text', value: ' = {}' },
+              ],
+            },
+          ],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    const pre = result.children[0] as HastElement;
+    expect(pre.tagName).toBe('pre');
+    const code = pre.children[0] as HastElement;
+    expect(code.tagName).toBe('code');
+    expect(code.children).toEqual([
+      { type: 'text', value: 'type ' },
+      {
+        type: 'element',
+        tagName: 'a',
+        properties: { href: '#my-type' },
+        children: [{ type: 'text', value: 'MyType' }],
+      },
+      { type: 'text', value: ' = {}' },
+    ]);
+  });
+
+  it('should handle spans with multiple children', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: {},
+          children: [
+            { type: 'text', value: 'hello' },
+            { type: 'text', value: ' world' },
+          ],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    expect(result.children).toEqual([{ type: 'text', value: 'hello world' }]);
+  });
+
+  it('should handle empty root', () => {
+    const root: HastRoot = { type: 'root', children: [] };
+    expect(stripHighlightingSpans(root)).toEqual({ type: 'root', children: [] });
+  });
+
+  it('should handle a realistic highlighted type signature with mixed content', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'pre',
+          properties: {},
+          children: [
+            {
+              type: 'element',
+              tagName: 'code',
+              properties: {},
+              children: [
+                {
+                  type: 'element',
+                  tagName: 'span',
+                  properties: { className: ['pl-k'] },
+                  children: [{ type: 'text', value: 'type' }],
+                },
+                { type: 'text', value: ' ' },
+                {
+                  type: 'element',
+                  tagName: 'span',
+                  properties: { className: ['pl-smi'] },
+                  children: [{ type: 'text', value: 'Props' }],
+                },
+                { type: 'text', value: ' = {\n  ' },
+                {
+                  type: 'element',
+                  tagName: 'span',
+                  properties: { className: ['pl-smi'] },
+                  children: [{ type: 'text', value: 'disabled' }],
+                },
+                { type: 'text', value: ': ' },
+                {
+                  type: 'element',
+                  tagName: 'a',
+                  properties: { href: '#boolean' },
+                  children: [
+                    {
+                      type: 'element',
+                      tagName: 'span',
+                      properties: { className: ['pl-c1'] },
+                      children: [{ type: 'text', value: 'boolean' }],
+                    },
+                  ],
+                },
+                { type: 'text', value: '\n}' },
+              ],
+            },
+          ],
+        },
+      ],
+    };
+
+    const result = stripHighlightingSpans(root);
+    const pre = result.children[0] as HastElement;
+    const code = pre.children[0] as HastElement;
+
+    // All spans removed, adjacent text merged, links preserved
+    expect(code.children).toEqual([
+      { type: 'text', value: 'type Props = {\n  disabled: ' },
+      {
+        type: 'element',
+        tagName: 'a',
+        properties: { href: '#boolean' },
+        children: [{ type: 'text', value: 'boolean' }],
+      },
+      { type: 'text', value: '\n}' },
+    ]);
+  });
+
+  it('should not mutate the input tree', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: { className: ['pl-k'] },
+          children: [{ type: 'text', value: 'type' }],
+        },
+      ],
+    };
+    const originalJson = JSON.stringify(root);
+    stripHighlightingSpans(root);
+    expect(JSON.stringify(root)).toBe(originalJson);
+  });
+});
diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
new file mode 100644
index 000000000..ee89a56e6
--- /dev/null
+++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
@@ -0,0 +1,55 @@
+import type { Root as HastRoot, RootContent, Element as HastElement } from 'hast';
+
+/**
+ * Strip syntax-highlighting `` elements from a HAST tree while preserving
+ * `` links and text content. Produces a "links-only" version of the tree
+ * suitable as a lightweight server-rendered fallback for deferred highlighting.
+ *
+ * - `` elements: removed, children promoted to parent
+ * - `` elements: preserved, children recursively processed
+ * - text nodes: preserved, adjacent text nodes merged
+ * - other elements (pre, code, etc.): preserved, children recursively processed
+ *
+ * Does not mutate the input tree.
+ */
+export function stripHighlightingSpans(root: HastRoot): HastRoot {
+  return {
+    ...root,
+    children: processChildren(root.children),
+  };
+}
+
+function processChildren(children: RootContent[]): RootContent[] {
+  const flat = children.flatMap((node): RootContent[] => {
+    if (node.type !== 'element') {
+      return [node];
+    }
+    const element = node as HastElement;
+    if (element.tagName === 'span') {
+      // Unwrap: replace span with its recursively-processed children
+      return processChildren(element.children as RootContent[]);
+    }
+    // Keep other elements, process their children
+    return [
+      {
+        ...element,
+        children: processChildren(element.children as RootContent[]),
+      } as RootContent,
+    ];
+  });
+  return mergeAdjacentText(flat);
+}
+
+function mergeAdjacentText(nodes: RootContent[]): RootContent[] {
+  const result: RootContent[] = [];
+  for (const node of nodes) {
+    const prev = result[result.length - 1];
+    if (node.type === 'text' && prev?.type === 'text') {
+      // Replace the previous text node with a merged one (no mutation)
+      result[result.length - 1] = { type: 'text', value: prev.value + node.value };
+    } else {
+      result.push(node);
+    }
+  }
+  return result;
+}
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
index 6a56af636..145574f66 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
@@ -1,5 +1,7 @@
 import { describe, it, expect } from 'vitest';
 import type { Root as HastRoot } from 'hast';
+import { compressSync, strToU8 } from 'fflate';
+import { encode } from 'uint8-to-base64';
 import type { HighlightedTypesMeta } from '../pipeline/loadServerTypes';
 import { typeToJsx, additionalTypesToJsx, type TypesJsxOptions } from './typesToJsx';
 
@@ -22,6 +24,38 @@ function createHastRoot(text: string): HastRoot {
  * Helper to create an HighlightedComponentTypeMeta for testing.
  * Uses 'unknown' cast to satisfy TypeScript while providing minimal mock data.
  */
+/**
+ * Helper to create a HAST code block with highlighted spans, simulating
+ * syntax-highlighted output (e.g. `pre > code > [span.pl-k, text, ...]`).
+ */
+function createHighlightedCodeBlock(text: string): HastRoot {
+  return {
+    type: 'root',
+    children: [
+      {
+        type: 'element',
+        tagName: 'pre',
+        properties: {},
+        children: [
+          {
+            type: 'element',
+            tagName: 'code',
+            properties: {},
+            children: [
+              {
+                type: 'element',
+                tagName: 'span',
+                properties: { className: ['pl-k'] },
+                children: [{ type: 'text', value: text }],
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  };
+}
+
 function createHighlightedComponent(
   name: string,
   options: {
@@ -34,6 +68,7 @@ function createHighlightedComponent(
         required?: boolean;
         description?: HastRoot;
         default?: HastRoot;
+        detailedType?: HastRoot;
       }
     >;
     description?: HastRoot;
@@ -73,6 +108,7 @@ function createHighlightedHook(
       typeText: string;
       required?: boolean;
       description?: HastRoot;
+      detailedType?: HastRoot;
     }>;
     returnValue?:
       | HastRoot
@@ -83,6 +119,7 @@ function createHighlightedHook(
             typeText: string;
             required?: boolean;
             description?: HastRoot;
+            detailedType?: HastRoot;
           }
         >;
     description?: HastRoot;
@@ -127,6 +164,56 @@ function createHighlightedRaw(
   } as unknown as HighlightedTypesMeta;
 }
 
+/**
+ * Helper to create an HighlightedFunctionTypeMeta for testing.
+ */
+function createHighlightedFunction(
+  name: string,
+  options: {
+    parameters?: Array<{
+      name: string;
+      type: HastRoot;
+      typeText: string;
+      required?: boolean;
+      description?: HastRoot;
+      detailedType?: HastRoot;
+    }>;
+    returnValue?:
+      | HastRoot
+      | { hastJson: string }
+      | { hastGzip: string }
+      | Record<
+          string,
+          {
+            type: HastRoot;
+            typeText: string;
+            required?: boolean;
+            description?: HastRoot;
+            detailedType?: HastRoot;
+          }
+        >;
+    description?: HastRoot;
+  } = {},
+): HighlightedTypesMeta {
+  return {
+    type: 'function',
+    name,
+    data: {
+      name,
+      parameters: options.parameters ?? [],
+      returnValue: options.returnValue ?? createHastRoot('void'),
+      description: options.description,
+    },
+  } as unknown as HighlightedTypesMeta;
+}
+
+/**
+ * Compress a HAST root to a { hastGzip: string } wrapper for testing.
+ */
+function compressHast(hast: HastRoot): { hastGzip: string } {
+  return { hastGzip: encode(compressSync(strToU8(JSON.stringify(hast)), { level: 9 })) };
+}
+
 describe('typesToJsx', () => {
   describe('typeToJsx', () => {
     describe('component types', () => {
@@ -243,6 +330,64 @@ describe('typesToJsx', () => {
           expect(result.type.data.returnValue).toBeDefined();
         }
       });
+
+      it('should treat hastGzip returnValue as simple return type', () => {
+        const hook = createHighlightedHook('useCounter', {
+          returnValue: compressHast(createHastRoot('number')) as unknown as HastRoot,
+        });
+        const result = typeToJsx({ type: hook, additionalTypes: [] }, undefined, defaultOptions);
+
+        expect(result.type?.type).toBe('hook');
+        if (result.type?.type === 'hook') {
+          expect(result.type.data.returnValue).toBeDefined();
+          expect(result.type.data.returnValue!.kind).toBe('simple');
+        }
+      });
+
+      it('should treat hastJson returnValue as simple return type', () => {
+        const hook = createHighlightedHook('useCounter', {
+          returnValue: {
+            hastJson: JSON.stringify(createHastRoot('number')),
+          } as unknown as HastRoot,
+        });
+        const result = typeToJsx({ type: hook, additionalTypes: [] }, undefined, defaultOptions);
+
+        expect(result.type?.type).toBe('hook');
+        if (result.type?.type === 'hook') {
+          expect(result.type.data.returnValue).toBeDefined();
+          expect(result.type.data.returnValue!.kind).toBe('simple');
+        }
+      });
+    });
+
+    describe('function types', () => {
+      it('should treat hastGzip returnValue as simple return type', () => {
+        const func = createHighlightedFunction('getCount', {
+          returnValue: compressHast(createHastRoot('number')) as unknown as HastRoot,
+        });
+        const result = typeToJsx({ type: func, additionalTypes: [] }, undefined, defaultOptions);
+
+        expect(result.type?.type).toBe('function');
+        if (result.type?.type === 'function') {
+          expect(result.type.data.returnValue).toBeDefined();
+          expect(result.type.data.returnValue!.kind).toBe('simple');
+        }
+      });
+
+      it('should treat hastJson returnValue as simple return type', () => {
+        const func = createHighlightedFunction('getCount', {
+          returnValue: {
+            hastJson: JSON.stringify(createHastRoot('number')),
+          } as unknown as HastRoot,
+        });
+        const result = typeToJsx({ type: func, additionalTypes: [] }, undefined, defaultOptions);
+
+        expect(result.type?.type).toBe('function');
+        if (result.type?.type === 'function') {
+          expect(result.type.data.returnValue).toBeDefined();
+          expect(result.type.data.returnValue!.kind).toBe('simple');
+        }
+      });
     });
 
     describe('raw types', () => {
@@ -396,6 +541,179 @@ describe('typesToJsx', () => {
     });
   });
 
+  describe('highlightAt option', () => {
+    it('should produce identical output for highlightAt init vs explicit init', () => {
+      const component = createHighlightedComponent('Button', {
+        props: {
+          disabled: {
+            type: createHastRoot('boolean'),
+            typeText: 'boolean',
+            detailedType: createHighlightedCodeBlock('boolean'),
+          },
+        },
+      });
+      const resultInit = typeToJsx({ type: component, additionalTypes: [] }, undefined, {
+        ...defaultOptions,
+        highlightAt: 'init',
+      });
+      const resultExplicitInit = typeToJsx({ type: component, additionalTypes: [] }, undefined, {
+        ...defaultOptions,
+        highlightAt: 'init',
+      });
+
+      expect(resultInit.type?.type).toBe('component');
+      expect(resultExplicitInit.type?.type).toBe('component');
+      // Both should have detailedType defined
+      if (resultInit.type?.type === 'component' && resultExplicitInit.type?.type === 'component') {
+        expect(resultInit.type.data.props.disabled.detailedType).toBeDefined();
+        expect(resultExplicitInit.type.data.props.disabled.detailedType).toBeDefined();
+      }
+    });
+
+    it('should default to idle (deferred) when highlightAt is not set', () => {
+      const component = createHighlightedComponent('Button', {
+        props: {
+          disabled: {
+            type: createHastRoot('boolean'),
+            typeText: 'boolean',
+            detailedType: createHighlightedCodeBlock('boolean'),
+          },
+        },
+      });
+      const resultDefault = typeToJsx(
+        { type: component, additionalTypes: [] },
+        undefined,
+        defaultOptions,
+      );
+      const resultIdle = typeToJsx({ type: component, additionalTypes: [] }, undefined, {
+        ...defaultOptions,
+        highlightAt: 'idle',
+      });
+
+      expect(resultDefault.type?.type).toBe('component');
+      expect(resultIdle.type?.type).toBe('component');
+      // Both should produce deferred output for detailedType
+      if (resultDefault.type?.type === 'component' && resultIdle.type?.type === 'component') {
+        expect(resultDefault.type.data.props.disabled.detailedType).toBeDefined();
+        expect(resultIdle.type.data.props.disabled.detailedType).toBeDefined();
+      }
+    });
+
+    it('should produce deferred output for component detailedType with highlightAt idle', () => {
+      const component = createHighlightedComponent('Button', {
+        props: {
+          disabled: {
+            type: createHastRoot('boolean'),
+            typeText: 'boolean',
+            detailedType: createHighlightedCodeBlock('boolean'),
+          },
+        },
+      });
+      const result = typeToJsx({ type: component, additionalTypes: [] }, undefined, {
+        ...defaultOptions,
+        highlightAt: 'idle',
+      });
+
+      expect(result.type?.type).toBe('component');
+      if (result.type?.type === 'component') {
+        // detailedType should still be a React node (deferred wrapper)
+        expect(result.type.data.props.disabled.detailedType).toBeDefined();
+      }
+    });
+
+    it('should produce deferred output for raw formattedCode with highlightAt hydration', () => {
+      const raw = createHighlightedRaw('MyType', {
+        formattedCode: createHighlightedCodeBlock('type MyType = {}'),
+      });
+      const result = typeToJsx({ type: raw, additionalTypes: [] }, undefined, {
+        ...defaultOptions,
+        highlightAt: 'hydration',
+      });
+
+      expect(result.type?.type).toBe('raw');
+      if (result.type?.type === 'raw') {
+        expect(result.type.data.formattedCode).toBeDefined();
+      }
+    });
+
+    it('should produce deferred output for hook parameter detailedType', () => {
+      const hook = createHighlightedHook('useButton', {
+        parameters: [
+          {
+            name: 'options',
+            type: createHastRoot('ButtonOptions'),
+            typeText: 'ButtonOptions',
+            required: true,
+            detailedType: createHighlightedCodeBlock('ButtonOptions'),
+          },
+        ],
+      });
+      const result = typeToJsx({ type: hook, additionalTypes: [] }, undefined, {
+        ...defaultOptions,
+        highlightAt: 'idle',
+      });
+
+      expect(result.type?.type).toBe('hook');
+      if (result.type?.type === 'hook') {
+        expect(result.type.data.parameters).toBeDefined();
+        const param = result.type.data.parameters!.find((p) => p.name === 'options');
+        expect(param?.detailedType).toBeDefined();
+      }
+    });
+
+    it('should not affect non-deferred fields like type and description', () => {
+      const component = createHighlightedComponent('Button', {
+        description: createHastRoot('A button component'),
+        props: {
+          disabled: {
+            type: createHastRoot('boolean'),
+            typeText: 'boolean',
+            description: createHastRoot('Whether disabled'),
+            detailedType: createHighlightedCodeBlock('boolean'),
+          },
+        },
+      });
+      const resultDefault = typeToJsx(
+        { type: component, additionalTypes: [] },
+        undefined,
+        defaultOptions,
+      );
+      const resultIdle = typeToJsx({ type: component, additionalTypes: [] }, undefined, {
+        ...defaultOptions,
+        highlightAt: 'idle',
+      });
+
+      if (resultDefault.type?.type === 'component' && resultIdle.type?.type === 'component') {
+        // Non-deferred fields should be identical
+        expect(resultIdle.type.data.description).toBeDefined();
+        expect(resultIdle.type.data.props.disabled.type).toBeDefined();
+        expect(resultIdle.type.data.props.disabled.description).toBeDefined();
+      }
+    });
+
+    it('should handle undefined detailedType with highlightAt set', () => {
+      const component = createHighlightedComponent('Button', {
+        props: {
+          disabled: {
+            type: createHastRoot('boolean'),
+            typeText: 'boolean',
+            // No detailedType
+          },
+        },
+      });
+      const result = typeToJsx({ type: component, additionalTypes: [] }, undefined, {
+        ...defaultOptions,
+        highlightAt: 'idle',
+      });
+
+      expect(result.type?.type).toBe('component');
+      if (result.type?.type === 'component') {
+        // detailedType should be undefined since it wasn't provided
+        expect(result.type.data.props.disabled.detailedType).toBeUndefined();
+      }
+    });
+  });
+
   describe('additionalTypesToJsx', () => {
     it('should return empty array for undefined input', () => {
       const result = additionalTypesToJsx(undefined, defaultOptions);
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
index a8b48c176..554868e0c 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
@@ -1,6 +1,9 @@
-import type { Nodes as HastNodes } from 'hast';
+import * as React from 'react';
+import type { Nodes as HastNodes, Element as HastElement } from 'hast';
 import type { PluggableList } from 'unified';
 import { unified } from 'unified';
+import { decompressSync, strFromU8, compressSync, strToU8 } from 'fflate';
+import { decode, encode } from 'uint8-to-base64';
 import type {
   HighlightedComponentTypeMeta,
   HighlightedHookTypeMeta,
@@ -17,6 +20,8 @@ import type {
 import type { FormattedEnumMember } from '../pipeline/loadServerTypesMeta';
 import type { HastRoot } from '../CodeHighlighter/types';
 import { hastToJsx as hastToJsxBase } from '../pipeline/hastUtils';
+import { stripHighlightingSpans } from './stripHighlightingSpans';
+import { DeferredHighlightClient } from './DeferredHighlightClient';
 
 // Broad index signature to accept MDXComponents from `mdx/types`,
 // which uses `{ [key: string]: NestedMDXComponents | Component }`.
@@ -64,6 +69,14 @@ export type TypesJsxOptions = {
    * Applied instead of the full enhancers for these compact fields.
    */
   enhancersInline?: PluggableList;
+  /**
+   * Controls when expensive detailedType and formattedCode HAST fields are
+   * converted to fully-highlighted JSX.
+   * - `'init'`: convert immediately during SSG
+   * - `'hydration'`: server-render a links-only fallback, highlight on client mount
+   * - `'idle'`: server-render a links-only fallback, highlight when browser is idle (default)
+   */
+  highlightAt?: 'init' | 'hydration' | 'idle';
 };
 
 /**
@@ -325,14 +338,17 @@ export interface EnhancedExportData {
 
 /**
  * Type guard to check if a value is a HastRoot node or a serialized HAST wrapper.
- * Handles both live `{ type: 'root', children: [...] }` and serialized `{ hastJson: string }`.
+ * Handles live `{ type: 'root', children: [...] }`, serialized `{ hastJson: string }`,
+ * and compressed `{ hastGzip: string }`.
  */
-function isHastRoot(value: unknown): value is HastRoot | { hastJson: string } {
+function isHastRoot(
+  value: unknown,
+): value is HastRoot | { hastJson: string } | { hastGzip: string } {
   if (typeof value !== 'object' || value === null) {
     return false;
   }
   // Serialized HAST from loadPrecomputedTypes
-  if ('hastJson' in value) {
+  if ('hastJson' in value || 'hastGzip' in value) {
     return true;
   }
   // Live HAST Root
@@ -356,6 +372,8 @@ interface ResolvedFieldMaps {
   detailedType: ComponentMap;
   /** For raw type formattedCode (pre = RawTypePre or DetailedTypePre or TypePre) */
   rawType: ComponentMap;
+  /** Controls deferred rendering. Defaults to 'idle' when unset. */
+  highlightAt: 'init' | 'hydration' | 'idle';
 }
 
 function resolveFieldMaps(options: TypesJsxOptions): ResolvedFieldMaps {
@@ -370,6 +388,7 @@ function resolveFieldMaps(options: TypesJsxOptions): ResolvedFieldMaps {
     default: options.DefaultCode ? { ...typeMap, code: options.DefaultCode } : typeMap,
     detailedType: detailedTypeMap,
     rawType: options.RawTypePre ? { ...base, pre: options.RawTypePre } : detailedTypeMap,
+    highlightAt: options.highlightAt ?? 'idle',
   };
 }
 
@@ -399,26 +418,38 @@ function getOrCreateProcessor(enhancers: PluggableList): ReturnType` element in a HAST tree (typically root > pre > code).
+ */
+function findCodeElement(node: HastNodes): HastElement | null {
+  if (node.type === 'element') {
+    const el = node as HastElement;
+    if (el.tagName === 'code') {
+      return el;
+    }
+    for (const child of el.children) {
+      const found = findCodeElement(child as HastNodes);
+      if (found) {
+        return found;
+      }
+    }
+  }
+  if (node.type === 'root') {
+    for (const child of (node as HastRoot).children) {
+      const found = findCodeElement(child as HastNodes);
+      if (found) {
+        return found;
+      }
+    }
+  }
+  return null;
+}
+
+/**
+ * Deferred HAST-to-JSX conversion for expensive fields (detailedType, formattedCode).
+ * Server-renders a links-only fallback inside the normal component wrappers
+ * (TypePre, etc.) and injects a DeferredHighlightClient that replaces the inner
+ * code content with the fully-highlighted version on the client.
+ */
+function hastToJsxDeferred(
+  hastOrJson: SerializedHastInput,
+  components: ComponentMap | undefined,
+  enhancers: PluggableList | undefined,
+  highlightAt: 'hydration' | 'idle',
+): React.ReactNode {
+  const useGzip = typeof hastOrJson === 'object' && hastOrJson !== null && 'hastGzip' in hastOrJson;
+  const { hast: parsedHast, freshCopy } = deserializeHast(hastOrJson);
+  let hast = parsedHast;
+
+  // Run enhancers (adds links etc.)
+  if (enhancers && enhancers.length > 0) {
+    const input = freshCopy ? hast : structuredClone(hast);
+    const processor = getOrCreateProcessor(enhancers);
+    hast = processor.runSync(input as HastRoot) as HastNodes;
+  } else if (!freshCopy) {
+    hast = structuredClone(hast);
+  }
+
+  // Find the  element and extract its children
+  const codeElement = findCodeElement(hast);
+  if (!codeElement) {
+    // No code element — fall back to eager rendering
+    return hastToJsxBase(hast, components);
+  }
+
+  const innerChildren = [...codeElement.children];
+
+  // Serialize inner children — use gzip when the input was compressed
+  const clientProps: { hastJson?: string; hastGzip?: string; highlightAt: 'hydration' | 'idle' } = {
+    highlightAt,
+  };
+  if (useGzip) {
+    clientProps.hastGzip = encode(
+      compressSync(strToU8(JSON.stringify(innerChildren)), { level: 9 }),
+    );
+  } else {
+    clientProps.hastJson = JSON.stringify(innerChildren);
+  }
+
+  // Strip spans from inner children for links-only fallback
+  const linksOnlyRoot = stripHighlightingSpans({
+    type: 'root',
+    children: innerChildren as HastRoot['children'],
+  });
+  const linksOnlyJsx = hastToJsxBase(linksOnlyRoot, components);
+
+  // Empty the code element — the custom code component injects DeferredHighlightClient
+  codeElement.children = [];
+
+  const deferredComponents: ComponentMap = {
+    ...components,
+    code: (props: Record) => {
+      const { children: unusedChildren, ...codeProps } = props;
+      return React.createElement(
+        'code',
+        codeProps,
+        React.createElement(DeferredHighlightClient, clientProps, linksOnlyJsx),
+      );
+    },
+  };
+
+  return hastToJsxBase(hast, deferredComponents);
+}
+
+/**
+ * Convert a detailedType HAST field, using deferred rendering when highlightAt is set.
+ */
+function convertDetailedType(
+  hast: SerializedHastInput,
+  fieldMaps: ResolvedFieldMaps,
+  enhancers?: PluggableList,
+): React.ReactNode {
+  if (fieldMaps.highlightAt !== 'init') {
+    return hastToJsxDeferred(hast, fieldMaps.detailedType, enhancers, fieldMaps.highlightAt);
+  }
+  return hastToJsx(hast, fieldMaps.detailedType, enhancers);
+}
+
+/**
+ * Convert a raw type formattedCode HAST field, using deferred rendering when highlightAt is set.
+ */
+function convertRawType(
+  hast: SerializedHastInput,
+  fieldMaps: ResolvedFieldMaps,
+  enhancers?: PluggableList,
+): React.ReactNode {
+  if (fieldMaps.highlightAt !== 'init') {
+    return hastToJsxDeferred(hast, fieldMaps.rawType, enhancers, fieldMaps.highlightAt);
+  }
+  return hastToJsx(hast, fieldMaps.rawType, enhancers);
+}
+
 function enhanceComponentType(
   component: HighlightedComponentTypeMeta,
   components: TypesJsxOptions['components'],
@@ -485,7 +642,7 @@ function enhanceComponentType(
             enhanced.default = hastToJsx(prop.default, fieldMaps.default, enhancersInline);
           }
           if (prop.detailedType) {
-            enhanced.detailedType = hastToJsx(prop.detailedType, fieldMaps.detailedType, enhancers);
+            enhanced.detailedType = convertDetailedType(prop.detailedType, fieldMaps, enhancers);
           }
 
           return [key, enhanced];
@@ -554,7 +711,7 @@ function enhancePropertyRecord(
       prop.description && hastToJsx(prop.description, components, enhancers);
     const enhancedExample = prop.example && hastToJsx(prop.example, components, enhancers);
     const enhancedDetailedType =
-      prop.detailedType && hastToJsx(prop.detailedType, fieldMaps.detailedType, enhancers);
+      prop.detailedType && convertDetailedType(prop.detailedType, fieldMaps, enhancers);
     const enhancedSee = prop.see && hastToJsx(prop.see, components, enhancers);
 
     const {
@@ -641,7 +798,7 @@ function enhanceHookType(
         enhanced.default = hastToJsx(param.default, fieldMaps.default, enhancersInline);
       }
       if (detailedType) {
-        enhanced.detailedType = hastToJsx(detailedType, fieldMaps.detailedType, enhancers);
+        enhanced.detailedType = convertDetailedType(detailedType, fieldMaps, enhancers);
       }
       if (shortType) {
         enhanced.shortType = hastToJsx(shortType, fieldMaps.shortType, enhancersInline);
@@ -665,9 +822,9 @@ function enhanceHookType(
       type: hastToJsx(hook.returnValue, fieldMaps.type, enhancers),
     };
     if (hook.returnValueDetailedType) {
-      enhancedReturnValue.detailedType = hastToJsx(
+      enhancedReturnValue.detailedType = convertDetailedType(
         hook.returnValueDetailedType,
-        fieldMaps.detailedType,
+        fieldMaps,
         enhancers,
       );
     }
@@ -688,7 +845,7 @@ function enhanceHookType(
       const enhancedExample = prop.example && hastToJsx(prop.example, components, enhancers);
 
       const enhancedDetailedType =
-        prop.detailedType && hastToJsx(prop.detailedType, fieldMaps.detailedType, enhancers);
+        prop.detailedType && convertDetailedType(prop.detailedType, fieldMaps, enhancers);
       const enhancedSee = prop.see && hastToJsx(prop.see, components, enhancers);
       // Destructure to exclude HAST fields that need to be converted
       const {
@@ -819,7 +976,7 @@ function enhanceFunctionType(
         enhanced.default = hastToJsx(param.default, fieldMaps.default, enhancersInline);
       }
       if (param.detailedType) {
-        enhanced.detailedType = hastToJsx(param.detailedType, fieldMaps.detailedType, enhancers);
+        enhanced.detailedType = convertDetailedType(param.detailedType, fieldMaps, enhancers);
       }
       if (shortType) {
         enhanced.shortType = hastToJsx(shortType, fieldMaps.shortType, enhancersInline);
@@ -846,9 +1003,9 @@ function enhanceFunctionType(
         hastToJsx(func.returnValueDescription, components, enhancers),
     };
     if (func.returnValueDetailedType) {
-      enhancedReturnValue.detailedType = hastToJsx(
+      enhancedReturnValue.detailedType = convertDetailedType(
         func.returnValueDetailedType,
-        fieldMaps.detailedType,
+        fieldMaps,
         enhancers,
       );
     }
@@ -869,7 +1026,7 @@ function enhanceFunctionType(
       const enhancedExample = prop.example && hastToJsx(prop.example, components, enhancers);
 
       const enhancedDetailedType =
-        prop.detailedType && hastToJsx(prop.detailedType, fieldMaps.detailedType, enhancers);
+        prop.detailedType && convertDetailedType(prop.detailedType, fieldMaps, enhancers);
       const enhancedSee = prop.see && hastToJsx(prop.see, components, enhancers);
       // Destructure to exclude HAST fields that need to be converted
       const {
@@ -994,7 +1151,7 @@ function enhanceClassType(
         enhanced.default = hastToJsx(param.default, fieldMaps.default, enhancersInline);
       }
       if (param.detailedType) {
-        enhanced.detailedType = hastToJsx(param.detailedType, fieldMaps.detailedType, enhancers);
+        enhanced.detailedType = convertDetailedType(param.detailedType, fieldMaps, enhancers);
       }
       if (shortType) {
         enhanced.shortType = hastToJsx(shortType, fieldMaps.shortType, enhancersInline);
@@ -1041,7 +1198,7 @@ function enhanceClassType(
           enhanced.default = hastToJsx(param.default, fieldMaps.default, enhancersInline);
         }
         if (param.detailedType) {
-          enhanced.detailedType = hastToJsx(param.detailedType, fieldMaps.detailedType, enhancers);
+          enhanced.detailedType = convertDetailedType(param.detailedType, fieldMaps, enhancers);
         }
         if (shortType) {
           enhanced.shortType = hastToJsx(shortType, fieldMaps.shortType, enhancersInline);
@@ -1091,7 +1248,7 @@ function enhanceClassType(
         enhanced.shortType = hastToJsx(prop.type, fieldMaps.shortType, enhancersInline);
       }
       if (prop.detailedType) {
-        enhanced.detailedType = hastToJsx(prop.detailedType, fieldMaps.detailedType, enhancers);
+        enhanced.detailedType = convertDetailedType(prop.detailedType, fieldMaps, enhancers);
       }
       if (prop.description) {
         enhanced.description = hastToJsx(prop.description, components, enhancers);
@@ -1145,7 +1302,7 @@ function enhanceRawType(
     data: {
       ...raw,
       description: raw.description && hastToJsx(raw.description, components, enhancers),
-      formattedCode: hastToJsx(raw.formattedCode, fieldMaps.rawType, enhancers),
+      formattedCode: convertRawType(raw.formattedCode, fieldMaps, enhancers),
       enumMembers: enhancedEnumMembers,
       properties:
         raw.properties &&
diff --git a/packages/docs-infra/src/pipeline/loadPrecomputedTypes/loadPrecomputedTypes.ts b/packages/docs-infra/src/pipeline/loadPrecomputedTypes/loadPrecomputedTypes.ts
index 2d5dd46bb..02c65b948 100644
--- a/packages/docs-infra/src/pipeline/loadPrecomputedTypes/loadPrecomputedTypes.ts
+++ b/packages/docs-infra/src/pipeline/loadPrecomputedTypes/loadPrecomputedTypes.ts
@@ -173,7 +173,7 @@ export async function loadPrecomputedTypes(
       ordering: options.ordering,
       descriptionReplacements: options.descriptionReplacements,
       sync: true,
-      serializeHast: true,
+      output: 'hastGzip',
     });
 
     currentMark = performanceMeasure(
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts b/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
index 4c399fc45..8e5fc1abb 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
@@ -6,6 +6,8 @@
  */
 
 import type { Root as HastRoot, Element, Text, RootContent } from 'hast';
+import { compressSync, strToU8 } from 'fflate';
+import { encode } from 'uint8-to-base64';
 
 /**
  * Extracts all text content from a HAST node recursively.
@@ -515,11 +517,40 @@ export interface SerializedHastRoot {
   hastJson: string;
 }
 
+/**
+ * A gzip-compressed, base64-encoded wrapper around a HastRoot.
+ * Smaller than JSON for transport; decoded and decompressed at render time.
+ */
+export interface SerializedHastGzip {
+  hastGzip: string;
+}
+
+/** Controls the output format of HAST fields in type metadata. */
+export type TypesOutputFormat = 'hast' | 'hastJson' | 'hastGzip';
+
 /** Converts a HastRoot to a JSON-serialized wrapper. */
 export function serializeHastRoot(hast: HastRoot): SerializedHastRoot {
   return { hastJson: JSON.stringify(hast) };
 }
 
+/** Converts a HastRoot to a gzip-compressed, base64-encoded wrapper. */
+export function compressHastRoot(hast: HastRoot): SerializedHastGzip {
+  return { hastGzip: encode(compressSync(strToU8(JSON.stringify(hast)), { level: 9 })) };
+}
+
+/** Returns the appropriate serializer function for the given output format. */
+export function resolveSerializer(
+  output: TypesOutputFormat,
+): (hast: HastRoot) => HastRoot | SerializedHastRoot | SerializedHastGzip {
+  if (output === 'hastGzip') {
+    return compressHastRoot;
+  }
+  if (output === 'hastJson') {
+    return serializeHastRoot;
+  }
+  return hastIdentity;
+}
+
 /** No-op passthrough — avoids allocating a fresh closure on every call. */
 export function hastIdentity(hast: HastRoot): HastRoot {
   return hast;
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.ts
index af38c8f55..ca89ed135 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.ts
@@ -9,7 +9,7 @@ import {
   type TypesMeta,
 } from '../loadServerTypesMeta';
 import { formatInlineTypeAsHast } from './typeHighlighting';
-import { serializeHastRoot, hastIdentity } from './hastTypeUtils';
+import { resolveSerializer, type TypesOutputFormat } from './hastTypeUtils';
 
 /**
  * Result of the highlightTypes function.
@@ -49,7 +49,7 @@ export interface HighlightTypesResult {
 export async function highlightTypes(
   types: TypesMeta[],
   externalTypes: Record = {},
-  serializeHast = false,
+  output: TypesOutputFormat = 'hast',
 ): Promise {
   const processor = unified().use(transformHtmlCodeInline).use(transformHtmlCodeBlock);
 
@@ -58,19 +58,19 @@ export async function highlightTypes(
       if (typeMeta.type === 'component') {
         return {
           ...typeMeta,
-          data: await highlightComponentType(processor, typeMeta.data, serializeHast),
+          data: await highlightComponentType(processor, typeMeta.data, output),
         };
       }
       if (typeMeta.type === 'hook') {
         return {
           ...typeMeta,
-          data: await highlightCallableType(processor, typeMeta.data, serializeHast),
+          data: await highlightCallableType(processor, typeMeta.data, output),
         };
       }
       if (typeMeta.type === 'function') {
         return {
           ...typeMeta,
-          data: await highlightCallableType(processor, typeMeta.data, serializeHast),
+          data: await highlightCallableType(processor, typeMeta.data, output),
         };
       }
       return typeMeta;
@@ -216,9 +216,9 @@ function buildObjectTypeString(
 async function highlightComponentType(
   processor: any,
   data: ComponentTypeMeta,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
 
   // Transform markdown content (descriptions and examples) in parallel
   // Type fields remain as plain text - highlighting is done in highlightTypesMeta
@@ -295,9 +295,9 @@ async function highlightComponentType(
 async function highlightCallableType(
   processor: any,
   data: T,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
 
   // Transform markdown content (descriptions and examples) in parallel
   // Type fields remain as plain text - highlighting is done in highlightTypesMeta
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
index 63ebc9834..686081051 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
@@ -8,9 +8,9 @@ import type {
   FunctionTypeMeta,
 } from '../loadServerTypesMeta';
 import { getHastTextContent } from './hastTypeUtils';
-import type { SerializedHastRoot } from './hastTypeUtils';
+import type { SerializedHastRoot, SerializedHastGzip } from './hastTypeUtils';
 
-type HastField = HastRoot | SerializedHastRoot;
+type HastField = HastRoot | SerializedHastRoot | SerializedHastGzip;
 
 /**
  * Helper to check if a highlighted property has the expected fields
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
index a2e81e8ef..cd994e74b 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
@@ -42,14 +42,15 @@ import {
   replaceTypeReferences,
   collectTypeReferences,
   getHastTextContent,
-  serializeHastRoot,
-  hastIdentity,
+  resolveSerializer,
   type SerializedHastRoot,
+  type SerializedHastGzip,
+  type TypesOutputFormat,
 } from './hastTypeUtils';
 import { extractTypeProps as extractTypePropsFromCode } from './extractTypeProps';
 
-/** A HAST root or its JSON-serialized wrapper. */
-type HastField = HastRoot | SerializedHastRoot;
+/** A HAST root or its serialized/compressed wrapper. */
+type HastField = HastRoot | SerializedHastRoot | SerializedHastGzip;
 
 /**
  * Strips generic type arguments from a type string.
@@ -100,9 +101,9 @@ type PreProcessedProperty = Omit &
  */
 async function highlightRawProperties(
   properties: Record,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise> {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
   const processor = unified().use(transformHtmlCodeInline).use(transformHtmlCodeBlock);
 
   const entries = await Promise.all(
@@ -362,7 +363,7 @@ export interface HighlightTypesMetaOptions {
    * This defers tree allocation to render time and provides a free deep clone
    * via `JSON.parse`, eliminating the need for `structuredClone`.
    */
-  serializeHast?: boolean;
+  output?: TypesOutputFormat;
 }
 
 /**
@@ -390,7 +391,7 @@ export async function highlightTypesMeta(
     formatting?.defaultValueUnionPrintWidth ?? DEFAULT_UNION_PRINT_WIDTH;
   const typePrintWidth = formatting?.typePrintWidth ?? DEFAULT_TYPE_PRINT_WIDTH;
   const topLevelTypePrintWidth = formatting?.topLevelTypePrintWidth;
-  const serializeHast = options.serializeHast ?? false;
+  const output = options.output ?? 'hast';
 
   const highlightedTypes = await Promise.all(
     types.map(async (typeMeta): Promise => {
@@ -403,7 +404,7 @@ export async function highlightTypesMeta(
             shortTypeUnionPrintWidth,
             defaultValueUnionPrintWidth,
             typePrintWidth,
-            serializeHast,
+            output,
           ),
         };
       }
@@ -418,7 +419,7 @@ export async function highlightTypesMeta(
             defaultValueUnionPrintWidth,
             typePrintWidth,
             topLevelTypePrintWidth,
-            serializeHast,
+            output,
           ),
         };
       }
@@ -433,7 +434,7 @@ export async function highlightTypesMeta(
             defaultValueUnionPrintWidth,
             typePrintWidth,
             topLevelTypePrintWidth,
-            serializeHast,
+            output,
           ),
         };
       }
@@ -447,7 +448,7 @@ export async function highlightTypesMeta(
             defaultValueUnionPrintWidth,
             typePrintWidth,
             topLevelTypePrintWidth,
-            serializeHast,
+            output,
           ),
         };
       }
@@ -461,7 +462,7 @@ export async function highlightTypesMeta(
             topLevelTypePrintWidth,
             shortTypeUnionPrintWidth,
             defaultValueUnionPrintWidth,
-            serializeHast,
+            output,
           ),
         };
       }
@@ -482,7 +483,7 @@ async function highlightComponentTypeMeta(
   shortTypeUnionPrintWidth: number,
   defaultValueUnionPrintWidth: number,
   typePrintWidth: number,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
   const highlightedPropsEntries = await Promise.all(
     Object.entries(data.props).map(async ([propName, prop]) => {
@@ -493,7 +494,7 @@ async function highlightComponentTypeMeta(
         shortTypeUnionPrintWidth,
         defaultValueUnionPrintWidth,
         typePrintWidth,
-        serializeHast,
+        output,
       );
       return [propName, highlighted] as const;
     }),
@@ -516,9 +517,9 @@ async function highlightHookTypeMeta(
   defaultValueUnionPrintWidth: number,
   typePrintWidth: number,
   topLevelTypePrintWidth: number | undefined,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
 
   // Highlight parameters or expanded properties
   let highlightedParameters: HighlightedParameter[] | undefined;
@@ -536,7 +537,7 @@ async function highlightHookTypeMeta(
           shortTypeUnionPrintWidth,
           defaultValueUnionPrintWidth,
           typePrintWidth,
-          serializeHast,
+          output,
         );
         return [propName, highlighted] as const;
       }),
@@ -553,7 +554,7 @@ async function highlightHookTypeMeta(
           shortTypeUnionPrintWidth,
           defaultValueUnionPrintWidth,
           typePrintWidth,
-          serializeHast,
+          output,
         );
         return { name: param.name, ...highlighted };
       }),
@@ -568,7 +569,7 @@ async function highlightHookTypeMeta(
       const paramMatch = lookupRawTypeProperties(paramTypeText, rawTypeProperties);
       if (paramMatch) {
         expandedTypeName = paramMatch.name;
-        const highlightedProps = await highlightRawProperties(paramMatch.properties, serializeHast);
+        const highlightedProps = await highlightRawProperties(paramMatch.properties, output);
         const propEntries = await Promise.all(
           Object.entries(highlightedProps).map(async ([propName, prop]) => {
             const highlighted = await highlightPropertyMeta(
@@ -578,7 +579,7 @@ async function highlightHookTypeMeta(
               shortTypeUnionPrintWidth,
               defaultValueUnionPrintWidth,
               typePrintWidth,
-              serializeHast,
+              output,
             );
             return [propName, highlighted] as const;
           }),
@@ -598,7 +599,7 @@ async function highlightHookTypeMeta(
     const returnMatch = lookupRawTypeProperties(data.returnValue, rawTypeProperties);
     if (returnMatch) {
       returnValueTypeName = returnMatch.name;
-      const highlightedProps = await highlightRawProperties(returnMatch.properties, serializeHast);
+      const highlightedProps = await highlightRawProperties(returnMatch.properties, output);
       const returnValueEntries = await Promise.all(
         Object.entries(highlightedProps).map(async ([propName, prop]) => {
           const highlighted = await highlightPropertyMeta(
@@ -608,7 +609,7 @@ async function highlightHookTypeMeta(
             shortTypeUnionPrintWidth,
             defaultValueUnionPrintWidth,
             typePrintWidth,
-            serializeHast,
+            output,
           );
           return [propName, highlighted] as const;
         }),
@@ -652,7 +653,7 @@ async function highlightHookTypeMeta(
           shortTypeUnionPrintWidth,
           defaultValueUnionPrintWidth,
           typePrintWidth,
-          serializeHast,
+          output,
         );
         return [propName, highlighted] as const;
       }),
@@ -692,9 +693,9 @@ async function highlightFunctionTypeMeta(
   defaultValueUnionPrintWidth: number,
   typePrintWidth: number,
   topLevelTypePrintWidth: number | undefined,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
 
   // Highlight parameters or expanded properties
   let highlightedParameters: HighlightedParameter[] | undefined;
@@ -712,7 +713,7 @@ async function highlightFunctionTypeMeta(
           shortTypeUnionPrintWidth,
           defaultValueUnionPrintWidth,
           typePrintWidth,
-          serializeHast,
+          output,
         );
         return [propName, highlighted] as const;
       }),
@@ -729,7 +730,7 @@ async function highlightFunctionTypeMeta(
           shortTypeUnionPrintWidth,
           defaultValueUnionPrintWidth,
           typePrintWidth,
-          serializeHast,
+          output,
         );
         return { name: param.name, ...highlighted };
       }),
@@ -744,10 +745,7 @@ async function highlightFunctionTypeMeta(
       const funcParamMatch = lookupRawTypeProperties(paramTypeText, rawTypeProperties);
       if (funcParamMatch) {
         expandedTypeName = funcParamMatch.name;
-        const highlightedProps = await highlightRawProperties(
-          funcParamMatch.properties,
-          serializeHast,
-        );
+        const highlightedProps = await highlightRawProperties(funcParamMatch.properties, output);
         const propEntries = await Promise.all(
           Object.entries(highlightedProps).map(async ([propName, prop]) => {
             const highlighted = await highlightPropertyMeta(
@@ -757,7 +755,7 @@ async function highlightFunctionTypeMeta(
               shortTypeUnionPrintWidth,
               defaultValueUnionPrintWidth,
               typePrintWidth,
-              serializeHast,
+              output,
             );
             return [propName, highlighted] as const;
           }),
@@ -776,10 +774,7 @@ async function highlightFunctionTypeMeta(
     const funcReturnMatch = lookupRawTypeProperties(data.returnValue, rawTypeProperties);
     if (funcReturnMatch) {
       returnValueTypeName = funcReturnMatch.name;
-      const highlightedProps = await highlightRawProperties(
-        funcReturnMatch.properties,
-        serializeHast,
-      );
+      const highlightedProps = await highlightRawProperties(funcReturnMatch.properties, output);
       const returnValueEntries = await Promise.all(
         Object.entries(highlightedProps).map(async ([propName, prop]) => {
           const highlighted = await highlightPropertyMeta(
@@ -789,7 +784,7 @@ async function highlightFunctionTypeMeta(
             shortTypeUnionPrintWidth,
             defaultValueUnionPrintWidth,
             typePrintWidth,
-            serializeHast,
+            output,
           );
           return [propName, highlighted] as const;
         }),
@@ -833,7 +828,7 @@ async function highlightFunctionTypeMeta(
           shortTypeUnionPrintWidth,
           defaultValueUnionPrintWidth,
           typePrintWidth,
-          serializeHast,
+          output,
         );
         return [propName, highlighted] as const;
       }),
@@ -874,9 +869,9 @@ async function highlightClassTypeMeta(
   defaultValueUnionPrintWidth: number,
   typePrintWidth: number,
   topLevelTypePrintWidth: number | undefined,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
 
   // Enhance constructor parameters
   const highlightedConstructorParams = await Promise.all(
@@ -888,7 +883,7 @@ async function highlightClassTypeMeta(
         shortTypeUnionPrintWidth,
         defaultValueUnionPrintWidth,
         typePrintWidth,
-        serializeHast,
+        output,
       );
       return { ...highlighted, name: param.name };
     }),
@@ -903,7 +898,7 @@ async function highlightClassTypeMeta(
         highlightedExports,
         shortTypeUnionPrintWidth,
         typePrintWidth,
-        serializeHast,
+        output,
       );
       return [propName, highlighted] as const;
     }),
@@ -922,7 +917,7 @@ async function highlightClassTypeMeta(
             shortTypeUnionPrintWidth,
             defaultValueUnionPrintWidth,
             typePrintWidth,
-            serializeHast,
+            output,
           );
           return { ...highlighted, name: param.name };
         }),
@@ -1019,9 +1014,9 @@ async function highlightPropertyMeta(
   shortTypeUnionPrintWidth: number,
   defaultValueUnionPrintWidth: number,
   typePrintWidth: number,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
   // For shortType derivation, strip trailing `| undefined` from optional props
   // since required/optional status is shown separately (required props have *)
   const isOptional = !('required' in prop && prop.required);
@@ -1132,9 +1127,9 @@ async function highlightClassPropertyMeta(
   highlightedExports: Record,
   shortTypeUnionPrintWidth: number,
   typePrintWidth: number,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
   // For shortType derivation, strip trailing `| undefined` from optional props
   const strippedUndefined = prop.optional && prop.typeText.endsWith(' | undefined');
   const shortTypeInputText = strippedUndefined
@@ -1233,9 +1228,9 @@ async function highlightRawTypeMeta(
   topLevelTypePrintWidth: number | undefined,
   shortTypeUnionPrintWidth: number,
   defaultValueUnionPrintWidth: number,
-  serializeHast: boolean,
+  output: TypesOutputFormat,
 ): Promise {
-  const s = serializeHast ? serializeHastRoot : hastIdentity;
+  const s = resolveSerializer(output);
   // Re-format the raw code with prettier at the configured width
   let formattedCode = data.formattedCode;
   if (topLevelTypePrintWidth !== undefined) {
@@ -1322,7 +1317,7 @@ async function highlightRawTypeMeta(
             shortTypeUnionPrintWidth,
             defaultValueUnionPrintWidth,
             typePrintWidth,
-            serializeHast,
+            output,
           );
           return [path, highlighted] as const;
         }),
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/loadServerTypes.ts b/packages/docs-infra/src/pipeline/loadServerTypes/loadServerTypes.ts
index ac59099a6..fa60a953d 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/loadServerTypes.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/loadServerTypes.ts
@@ -21,6 +21,7 @@ import { syncTypes, type SyncTypesOptions } from '../syncTypes';
 import { loadServerTypesText, type TypesSourceData } from '../loadServerTypesText';
 import type { FormattedProperty, TypesMeta } from '../loadServerTypesMeta';
 import type { ExportData } from '../../abstractCreateTypes';
+import type { TypesOutputFormat } from './hastTypeUtils';
 
 export type {
   HighlightedTypesMeta,
@@ -35,7 +36,7 @@ export type {
   HighlightedParameter,
   HighlightedClassProperty,
 };
-export type { SerializedHastRoot } from './hastTypeUtils';
+export type { SerializedHastRoot, SerializedHastGzip, TypesOutputFormat } from './hastTypeUtils';
 
 const functionName = 'Load Server Types';
 
@@ -51,15 +52,17 @@ export interface LoadServerTypesOptions extends SyncTypesOptions {
    */
   sync?: boolean;
   /**
-   * When true, replaces HAST Root nodes in the result with `{ hastJson: string }`
-   * wrappers. This defers tree allocation from module-evaluation time to render
-   * time: V8 only creates a string instead of the full object graph, and
-   * `JSON.parse` at render time provides both deserialization and a free deep
-   * clone (eliminating the need for `structuredClone`).
+   * Controls the output format for HAST nodes in the result.
    *
-   * @default false
+   * - `'hast'`: Live HAST Root objects (default)
+   * - `'hastJson'`: JSON-serialized `{ hastJson: string }` wrappers — defers
+   *   tree allocation from module-evaluation time to render time
+   * - `'hastGzip'`: Gzip-compressed + base64-encoded `{ hastGzip: string }`
+   *   wrappers — smallest payload, decompressed at render time
+   *
+   * @default 'hast'
    */
-  serializeHast?: boolean;
+  output?: TypesOutputFormat;
 }
 
 export interface LoadServerTypesResult {
@@ -114,7 +117,7 @@ export async function loadServerTypes(
     rootContext,
     formattingOptions,
     sync = false,
-    serializeHast = false,
+    output = 'hast',
   } = options;
 
   // Derive relative path for logging
@@ -203,16 +206,12 @@ export async function loadServerTypes(
   const processedExports = await Promise.all(
     exportEntries.map(async ([exportName, exportData]) => {
       const exportTypes = [exportData.type, ...exportData.additionalTypes];
-      const highlightResult = await highlightTypes(
-        exportTypes,
-        syncResult.externalTypes,
-        serializeHast,
-      );
+      const highlightResult = await highlightTypes(exportTypes, syncResult.externalTypes, output);
       const highlightedTypes = await highlightTypesMeta(highlightResult.types, {
         highlightedExports: highlightResult.highlightedExports,
         rawTypeProperties: sharedRawTypeProperties,
         formatting: formattingOptions,
-        serializeHast,
+        output,
       });
 
       // First highlighted type is the main export type, rest are additional
@@ -238,13 +237,13 @@ export async function loadServerTypes(
     const highlightResult = await highlightTypes(
       syncResult.additionalTypes,
       syncResult.externalTypes,
-      serializeHast,
+      output,
     );
     additionalTypes = await highlightTypesMeta(highlightResult.types, {
       highlightedExports: highlightResult.highlightedExports,
       rawTypeProperties: sharedRawTypeProperties,
       formatting: formattingOptions,
-      serializeHast,
+      output,
     });
   }
 
@@ -257,16 +256,12 @@ export async function loadServerTypes(
         if (types.length === 0) {
           return { variantName, enhanced: [] as HighlightedTypesMeta[] };
         }
-        const highlightResult = await highlightTypes(
-          types,
-          syncResult.externalTypes,
-          serializeHast,
-        );
+        const highlightResult = await highlightTypes(types, syncResult.externalTypes, output);
         const enhanced = await highlightTypesMeta(highlightResult.types, {
           highlightedExports: highlightResult.highlightedExports,
           rawTypeProperties: sharedRawTypeProperties,
           formatting: formattingOptions,
-          serializeHast,
+          output,
         });
         return { variantName, enhanced };
       }),

From 33e3b4048178116f88b84ba8eeeff4650ff33526 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 09:57:22 -0400
Subject: [PATCH 09/61] Preserve frames in unhighlighted code

---
 .../stripHighlightingSpans.test.ts            | 158 ++++++++++++++----
 .../stripHighlightingSpans.ts                 |  18 +-
 2 files changed, 139 insertions(+), 37 deletions(-)

diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
index 868e25373..85f4286a4 100644
--- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
@@ -232,38 +232,74 @@ describe('stripHighlightingSpans', () => {
                 {
                   type: 'element',
                   tagName: 'span',
-                  properties: { className: ['pl-k'] },
-                  children: [{ type: 'text', value: 'type' }],
-                },
-                { type: 'text', value: ' ' },
-                {
-                  type: 'element',
-                  tagName: 'span',
-                  properties: { className: ['pl-smi'] },
-                  children: [{ type: 'text', value: 'Props' }],
-                },
-                { type: 'text', value: ' = {\n  ' },
-                {
-                  type: 'element',
-                  tagName: 'span',
-                  properties: { className: ['pl-smi'] },
-                  children: [{ type: 'text', value: 'disabled' }],
-                },
-                { type: 'text', value: ': ' },
-                {
-                  type: 'element',
-                  tagName: 'a',
-                  properties: { href: '#boolean' },
+                  properties: { className: ['frame'], dataFrameStartLine: 1, dataFrameEndLine: 3 },
                   children: [
                     {
                       type: 'element',
                       tagName: 'span',
-                      properties: { className: ['pl-c1'] },
-                      children: [{ type: 'text', value: 'boolean' }],
+                      properties: { className: ['line'], dataLn: 1 },
+                      children: [
+                        {
+                          type: 'element',
+                          tagName: 'span',
+                          properties: { className: ['pl-k'] },
+                          children: [{ type: 'text', value: 'type' }],
+                        },
+                        { type: 'text', value: ' ' },
+                        {
+                          type: 'element',
+                          tagName: 'span',
+                          properties: { className: ['pl-smi'] },
+                          children: [{ type: 'text', value: 'Props' }],
+                        },
+                        { type: 'text', value: ' = {' },
+                      ],
+                    },
+                    { type: 'text', value: '\n' },
+                    {
+                      type: 'element',
+                      tagName: 'span',
+                      properties: { className: ['line'], dataLn: 2 },
+                      children: [
+                        { type: 'text', value: '  ' },
+                        {
+                          type: 'element',
+                          tagName: 'span',
+                          properties: { className: ['pl-smi'] },
+                          children: [{ type: 'text', value: 'disabled' }],
+                        },
+                        { type: 'text', value: ': ' },
+                        {
+                          type: 'element',
+                          tagName: 'a',
+                          properties: { href: '#boolean' },
+                          children: [
+                            {
+                              type: 'element',
+                              tagName: 'span',
+                              properties: { className: ['pl-c1'] },
+                              children: [{ type: 'text', value: 'boolean' }],
+                            },
+                          ],
+                        },
+                      ],
+                    },
+                    { type: 'text', value: '\n' },
+                    {
+                      type: 'element',
+                      tagName: 'span',
+                      properties: { className: ['line'], dataLn: 3 },
+                      children: [
+                        {
+                          type: 'element',
+                          tagName: 'span',
+                          properties: { className: ['pl-k'] },
+                          children: [{ type: 'text', value: '}' }],
+                        },
+                      ],
                     },
                   ],
                 },
-                { type: 'text', value: '\n}' },
               ],
             },
           ],
@@ -275,19 +311,79 @@ describe('stripHighlightingSpans', () => {
     const pre = result.children[0] as HastElement;
     const code = pre.children[0] as HastElement;
 
-    // All spans removed, adjacent text merged, links preserved
+    // Frame preserved, line spans and highlighting spans stripped, text merged
     expect(code.children).toEqual([
-      { type: 'text', value: 'type Props = {\n  disabled: ' },
       {
         type: 'element',
-        tagName: 'a',
-        properties: { href: '#boolean' },
-        children: [{ type: 'text', value: 'boolean' }],
+        tagName: 'span',
+        properties: { className: ['frame'], dataFrameStartLine: 1, dataFrameEndLine: 3 },
+        children: [
+          { type: 'text', value: 'type Props = {\n  disabled: ' },
+          {
+            type: 'element',
+            tagName: 'a',
+            properties: { href: '#boolean' },
+            children: [{ type: 'text', value: 'boolean' }],
+          },
+          { type: 'text', value: '\n}' },
+        ],
       },
-      { type: 'text', value: '\n}' },
     ]);
   });
 
+  it('should preserve frame spans with their data attributes', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: {
+            className: ['frame'],
+            dataFrameStartLine: 1,
+            dataFrameEndLine: 5,
+            dataFrameType: 'highlighted',
+          },
+          children: [
+            {
+              type: 'element',
+              tagName: 'span',
+              properties: { className: ['pl-k'] },
+              children: [{ type: 'text', value: 'const' }],
+            },
+            { type: 'text', value: ' x = 1' },
+          ],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    const frame = result.children[0] as HastElement;
+    expect(frame.tagName).toBe('span');
+    expect(frame.properties).toEqual({
+      className: ['frame'],
+      dataFrameStartLine: 1,
+      dataFrameEndLine: 5,
+      dataFrameType: 'highlighted',
+    });
+    expect(frame.children).toEqual([{ type: 'text', value: 'const x = 1' }]);
+  });
+
+  it('should strip line spans but preserve their content', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: { className: ['line'], dataLn: 1 },
+          children: [{ type: 'text', value: 'hello' }],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    expect(result.children).toEqual([{ type: 'text', value: 'hello' }]);
+  });
+
   it('should not mutate the input tree', () => {
     const root: HastRoot = {
       type: 'root',
diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
index ee89a56e6..87c615849 100644
--- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
@@ -2,10 +2,11 @@ import type { Root as HastRoot, RootContent, Element as HastElement } from 'hast
 
 /**
  * Strip syntax-highlighting `` elements from a HAST tree while preserving
- * `` links and text content. Produces a "links-only" version of the tree
- * suitable as a lightweight server-rendered fallback for deferred highlighting.
+ * semantic structure and text content. Produces a "links-only" version of the
+ * tree suitable as a lightweight server-rendered fallback for deferred highlighting.
  *
- * - `` elements: removed, children promoted to parent
+ * - Highlighting `` elements (e.g. `pl-k`, `pl-smi`, `line`): removed, children promoted
+ * - Frame `` elements (`frame`): preserved with their data attributes
  * - `` elements: preserved, children recursively processed
  * - text nodes: preserved, adjacent text nodes merged
  * - other elements (pre, code, etc.): preserved, children recursively processed
@@ -19,17 +20,22 @@ export function stripHighlightingSpans(root: HastRoot): HastRoot {
   };
 }
 
+function isFrameSpan(element: HastElement): boolean {
+  const className = element.properties?.className;
+  return Array.isArray(className) && className.includes('frame');
+}
+
 function processChildren(children: RootContent[]): RootContent[] {
   const flat = children.flatMap((node): RootContent[] => {
     if (node.type !== 'element') {
       return [node];
     }
     const element = node as HastElement;
-    if (element.tagName === 'span') {
-      // Unwrap: replace span with its recursively-processed children
+    if (element.tagName === 'span' && !isFrameSpan(element)) {
+      // Unwrap highlighting spans: replace with recursively-processed children
       return processChildren(element.children as RootContent[]);
     }
-    // Keep other elements, process their children
+    // Keep semantic spans, links, and other elements — process their children
     return [
       {
         ...element,

From d921704cf236f179d4257f99d35163ccb8ab8691 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 10:21:05 -0400
Subject: [PATCH 10/61] Also convert types

---
 .../abstractCreateTypes/typesToJsx.test.ts    |  2 +-
 .../src/abstractCreateTypes/typesToJsx.ts     | 28 ++++++++++++++-----
 2 files changed, 22 insertions(+), 8 deletions(-)

diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
index 145574f66..d8ff77d88 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
@@ -661,7 +661,7 @@ describe('typesToJsx', () => {
       }
     });
 
-    it('should not affect non-deferred fields like type and description', () => {
+    it('should not affect non-deferred fields like description', () => {
       const component = createHighlightedComponent('Button', {
         description: createHastRoot('A button component'),
         props: {
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
index 554868e0c..91bf363cd 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
@@ -562,6 +562,20 @@ function hastToJsxDeferred(
   return hastToJsxBase(hast, deferredComponents);
 }
 
+/**
+ * Convert a type HAST field, using deferred rendering when highlightAt is set.
+ */
+function convertType(
+  hast: SerializedHastInput,
+  fieldMaps: ResolvedFieldMaps,
+  enhancers?: PluggableList,
+): React.ReactNode {
+  if (fieldMaps.highlightAt !== 'init') {
+    return hastToJsxDeferred(hast, fieldMaps.type, enhancers, fieldMaps.highlightAt);
+  }
+  return hastToJsx(hast, fieldMaps.type, enhancers);
+}
+
 /**
  * Convert a detailedType HAST field, using deferred rendering when highlightAt is set.
  */
@@ -619,7 +633,7 @@ function enhanceComponentType(
 
           const enhanced: EnhancedProperty = {
             ...rest,
-            type: hastToJsx(prop.type, fieldMaps.type, enhancers),
+            type: convertType(prop.type, fieldMaps, enhancers),
           };
 
           if (prop.description) {
@@ -702,7 +716,7 @@ function enhancePropertyRecord(
   enhancersInline?: PluggableList,
 ): Record {
   const entries = Object.entries(properties).map(([key, prop]) => {
-    const enhancedType = prop.type && hastToJsx(prop.type, fieldMaps.type, enhancers);
+    const enhancedType = prop.type && convertType(prop.type, fieldMaps, enhancers);
     const enhancedShortType =
       prop.shortType && hastToJsx(prop.shortType, fieldMaps.shortType, enhancersInline);
     const enhancedDefault =
@@ -819,7 +833,7 @@ function enhanceHookType(
     // It's a HastRoot - convert to simple discriminated union
     enhancedReturnValue = {
       kind: 'simple',
-      type: hastToJsx(hook.returnValue, fieldMaps.type, enhancers),
+      type: convertType(hook.returnValue, fieldMaps, enhancers),
     };
     if (hook.returnValueDetailedType) {
       enhancedReturnValue.detailedType = convertDetailedType(
@@ -831,7 +845,7 @@ function enhanceHookType(
   } else {
     const entries = Object.entries(hook.returnValue).map(([key, prop]) => {
       // Type is always HastRoot for return value properties
-      const enhancedType = prop.type && hastToJsx(prop.type, fieldMaps.type, enhancers);
+      const enhancedType = prop.type && convertType(prop.type, fieldMaps, enhancers);
 
       // ShortType, default, description, example, and detailedType can be HastRoot or undefined
       const enhancedShortType =
@@ -997,7 +1011,7 @@ function enhanceFunctionType(
     // It's a HastRoot - convert to simple discriminated union
     enhancedReturnValue = {
       kind: 'simple',
-      type: hastToJsx(func.returnValue, fieldMaps.type, enhancers),
+      type: convertType(func.returnValue, fieldMaps, enhancers),
       description:
         func.returnValueDescription &&
         hastToJsx(func.returnValueDescription, components, enhancers),
@@ -1012,7 +1026,7 @@ function enhanceFunctionType(
   } else {
     const entries = Object.entries(func.returnValue).map(([key, prop]) => {
       // Type is always HastRoot for return value properties
-      const enhancedType = prop.type && hastToJsx(prop.type, fieldMaps.type, enhancers);
+      const enhancedType = prop.type && convertType(prop.type, fieldMaps, enhancers);
 
       // ShortType, default, description, example, and detailedType can be HastRoot or undefined
       const enhancedShortType =
@@ -1238,7 +1252,7 @@ function enhanceClassType(
 
       const enhanced: EnhancedProperty = {
         ...rest,
-        type: hastToJsx(prop.type, fieldMaps.type, enhancers),
+        type: convertType(prop.type, fieldMaps, enhancers),
       };
 
       if (prop.shortType) {

From 74795d2417001b6ee57d3736c62ab896a924d5c8 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 12:41:47 -0400
Subject: [PATCH 11/61] Add a compression dictionary

---
 .../DeferredHighlightClient.tsx               |   6 +-
 .../abstractCreateTypes/typesToJsx.test.ts    |  11 +-
 .../src/abstractCreateTypes/typesToJsx.ts     |  10 +-
 .../src/pipeline/hastUtils/hastCompression.ts | 113 ++++++++++++++++++
 .../src/pipeline/hastUtils/hastUtils.tsx      |   9 +-
 .../src/pipeline/hastUtils/index.ts           |   1 +
 .../loadCodeVariant/loadCodeVariant.ts        |  19 +--
 .../loadCodeVariant/transformSource.ts        |  23 +---
 .../pipeline/loadServerTypes/hastTypeUtils.ts |   5 +-
 .../loadServerTypes/highlightTypes.test.ts    |  66 +++++-----
 packages/docs-infra/src/useCode/Pre.tsx       |   6 +-
 .../src/useCode/useFileNavigation.tsx         |   5 +-
 .../src/useCode/useSourceEnhancing.ts         |   5 +-
 13 files changed, 168 insertions(+), 111 deletions(-)
 create mode 100644 packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts

diff --git a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
index f37d7b206..949310dba 100644
--- a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
+++ b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
@@ -1,9 +1,7 @@
 'use client';
 import * as React from 'react';
 import type { Root as HastRoot } from 'hast';
-import { decompressSync, strFromU8 } from 'fflate';
-import { decode } from 'uint8-to-base64';
-import { hastToJsx } from '../pipeline/hastUtils';
+import { decompressHast, hastToJsx } from '../pipeline/hastUtils';
 
 type HighlightAt = 'hydration' | 'idle';
 
@@ -37,7 +35,7 @@ export function DeferredHighlightClient({
     const render = () => {
       let nodes;
       if (hastGzip) {
-        nodes = JSON.parse(strFromU8(decompressSync(decode(hastGzip))));
+        nodes = JSON.parse(decompressHast(hastGzip));
       } else {
         nodes = JSON.parse(hastJson!);
       }
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
index d8ff77d88..c5a12912f 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
@@ -1,7 +1,6 @@
 import { describe, it, expect } from 'vitest';
 import type { Root as HastRoot } from 'hast';
-import { compressSync, strToU8 } from 'fflate';
-import { encode } from 'uint8-to-base64';
+import { compressHast } from '../pipeline/hastUtils';
 import type { HighlightedTypesMeta } from '../pipeline/loadServerTypes';
 import { typeToJsx, additionalTypesToJsx, type TypesJsxOptions } from './typesToJsx';
 
@@ -210,8 +209,8 @@ function createHighlightedFunction(
 /**
  * Compress a HAST root to a { hastGzip: string } wrapper for testing.
  */
-function compressHast(hast: HastRoot): { hastGzip: string } {
-  return { hastGzip: encode(compressSync(strToU8(JSON.stringify(hast)), { level: 9 })) };
+function compressHastRoot(hast: HastRoot): { hastGzip: string } {
+  return { hastGzip: compressHast(JSON.stringify(hast)) };
 }
 
 describe('typesToJsx', () => {
@@ -333,7 +332,7 @@ describe('typesToJsx', () => {
 
       it('should treat hastGzip returnValue as simple return type', () => {
         const hook = createHighlightedHook('useCounter', {
-          returnValue: compressHast(createHastRoot('number')) as unknown as HastRoot,
+          returnValue: compressHastRoot(createHastRoot('number')) as unknown as HastRoot,
         });
         const result = typeToJsx({ type: hook, additionalTypes: [] }, undefined, defaultOptions);
 
@@ -363,7 +362,7 @@ describe('typesToJsx', () => {
     describe('function types', () => {
       it('should treat hastGzip returnValue as simple return type', () => {
         const func = createHighlightedFunction('getCount', {
-          returnValue: compressHast(createHastRoot('number')) as unknown as HastRoot,
+          returnValue: compressHastRoot(createHastRoot('number')) as unknown as HastRoot,
         });
         const result = typeToJsx({ type: func, additionalTypes: [] }, undefined, defaultOptions);
 
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
index 91bf363cd..b213ab034 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
@@ -2,8 +2,7 @@ import * as React from 'react';
 import type { Nodes as HastNodes, Element as HastElement } from 'hast';
 import type { PluggableList } from 'unified';
 import { unified } from 'unified';
-import { decompressSync, strFromU8, compressSync, strToU8 } from 'fflate';
-import { decode, encode } from 'uint8-to-base64';
+import { compressHast, decompressHast, hastToJsx as hastToJsxBase } from '../pipeline/hastUtils';
 import type {
   HighlightedComponentTypeMeta,
   HighlightedHookTypeMeta,
@@ -19,7 +18,6 @@ import type {
 } from '../pipeline/loadServerTypes';
 import type { FormattedEnumMember } from '../pipeline/loadServerTypesMeta';
 import type { HastRoot } from '../CodeHighlighter/types';
-import { hastToJsx as hastToJsxBase } from '../pipeline/hastUtils';
 import { stripHighlightingSpans } from './stripHighlightingSpans';
 import { DeferredHighlightClient } from './DeferredHighlightClient';
 
@@ -432,7 +430,7 @@ function deserializeHast(input: SerializedHastInput): { hast: HastNodes; freshCo
   if (typeof input === 'object' && input !== null) {
     if ('hastGzip' in input) {
       return {
-        hast: JSON.parse(strFromU8(decompressSync(decode(input.hastGzip)))),
+        hast: JSON.parse(decompressHast(input.hastGzip)),
         freshCopy: true,
       };
     }
@@ -530,9 +528,7 @@ function hastToJsxDeferred(
     highlightAt,
   };
   if (useGzip) {
-    clientProps.hastGzip = encode(
-      compressSync(strToU8(JSON.stringify(innerChildren)), { level: 9 }),
-    );
+    clientProps.hastGzip = compressHast(JSON.stringify(innerChildren));
   } else {
     clientProps.hastJson = JSON.stringify(innerChildren);
   }
diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
new file mode 100644
index 000000000..9a5c72f09
--- /dev/null
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
@@ -0,0 +1,113 @@
+import { deflateSync, deflate, inflateSync, inflate, strToU8, strFromU8 } from 'fflate';
+import { encode, decode } from 'uint8-to-base64';
+
+/**
+ * Shared dictionary for DEFLATE compression of HAST JSON.
+ *
+ * Contains byte sequences that frequently appear in JSON-serialized HAST trees
+ * (syntax-highlighted TypeScript type documentation). The dictionary is
+ * embedded in both the server build and the client bundle, so it must stay
+ * small — currently ~600 bytes uncompressed.
+ *
+ * IMPORTANT: Changing this dictionary is a **breaking change** for any
+ * previously-compressed payloads. When the dictionary is updated, all cached
+ * or persisted `hastGzip` strings become undecodable. Bump the dictionary only
+ * between major precomputed data regeneration cycles.
+ */
+const HAST_DICTIONARY = strToU8(
+  [
+    // JSON structural patterns (most frequent first)
+    '{"type":"element","tagName":"span","properties":{"className":["',
+    '{"type":"element","tagName":"a","properties":{"href":"',
+    '{"type":"text","value":"',
+    '"children":[',
+    '"properties":{}',
+    '"tagName":"code"',
+    '"tagName":"pre"',
+    '"type":"root"',
+    '"type":"element"',
+    '"type":"text"',
+    // Starry Night highlighting class names
+    'pl-k","pl-',
+    'pl-smi',
+    'pl-c1',
+    'pl-en',
+    'pl-s',
+    'pl-v',
+    'pl-pds',
+    // Frame & line structure
+    '"className":["frame"]',
+    '"className":["line"]',
+    '"dataFrameStartLine":',
+    '"dataFrameEndLine":',
+    '"dataLn":',
+    // Common TypeScript tokens in type documentation
+    'string',
+    'number',
+    'boolean',
+    'undefined',
+    'null',
+    'object',
+    'void',
+    'Array',
+    'Record',
+    'Partial',
+    'React.ReactNode',
+    'React.HTMLAttributes',
+    'HTMLElement',
+  ].join(''),
+);
+
+/**
+ * Compress a JSON string using DEFLATE with the shared HAST dictionary.
+ * Returns a base64-encoded string suitable for embedding in serialized props.
+ */
+export function compressHast(json: string): string {
+  return encode(deflateSync(strToU8(json), { level: 9, dictionary: HAST_DICTIONARY }));
+}
+
+/**
+ * Decompress a base64-encoded DEFLATE payload that was compressed with
+ * `compressHast`. Returns the original JSON string.
+ *
+ * Throws if the payload was not compressed with the matching dictionary.
+ */
+export function decompressHast(base64: string): string {
+  return strFromU8(inflateSync(decode(base64), { dictionary: HAST_DICTIONARY }));
+}
+
+/**
+ * Compress a string asynchronously using DEFLATE with the shared HAST dictionary.
+ * Returns a base64-encoded string.
+ */
+export function compressHastAsync(input: string): Promise {
+  return new Promise((resolve, reject) => {
+    deflate(
+      strToU8(input),
+      { consume: true, level: 9, dictionary: HAST_DICTIONARY },
+      (err, output) => {
+        if (err) {
+          reject(err);
+        } else {
+          resolve(encode(output));
+        }
+      },
+    );
+  });
+}
+
+/**
+ * Decompress a base64-encoded DEFLATE payload asynchronously.
+ * Returns the original JSON string.
+ */
+export function decompressHastAsync(base64: string): Promise {
+  return new Promise((resolve, reject) => {
+    inflate(decode(base64), { consume: true, dictionary: HAST_DICTIONARY }, (err, output) => {
+      if (err) {
+        reject(err);
+      } else {
+        resolve(strFromU8(output));
+      }
+    });
+  });
+}
diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastUtils.tsx b/packages/docs-infra/src/pipeline/hastUtils/hastUtils.tsx
index ecfb9cda0..58bcf84e0 100644
--- a/packages/docs-infra/src/pipeline/hastUtils/hastUtils.tsx
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastUtils.tsx
@@ -5,8 +5,7 @@ import type { Components } from 'hast-util-to-jsx-runtime';
 import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
 import { toText } from 'hast-util-to-text';
 import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
-import { decompressSync, strFromU8 } from 'fflate';
-import { decode } from 'uint8-to-base64';
+import { decompressHast } from './hastCompression';
 
 export function hastToJsx(hast: HastNodes, components?: Partial): React.ReactNode {
   return toJsxRuntime(hast, { Fragment, jsx, jsxs, components });
@@ -25,7 +24,7 @@ export function hastOrJsonToJsx(
     }
   } else if ('hastGzip' in hastOrJson) {
     try {
-      hast = JSON.parse(strFromU8(decompressSync(decode(hastOrJson.hastGzip))));
+      hast = JSON.parse(decompressHast(hastOrJson.hastGzip));
     } catch (error) {
       throw new Error(`Failed to parse hastGzip: ${JSON.stringify(error)}`);
     }
@@ -52,7 +51,7 @@ export function stringOrHastToString(
     }
   } else if ('hastGzip' in source) {
     try {
-      hast = JSON.parse(strFromU8(decompressSync(decode(source.hastGzip))));
+      hast = JSON.parse(decompressHast(source.hastGzip));
     } catch (error) {
       throw new Error(`Failed to parse hastGzip: ${JSON.stringify(error)}`);
     }
@@ -81,7 +80,7 @@ export function stringOrHastToJsx(
     }
   } else if ('hastGzip' in source) {
     try {
-      hast = JSON.parse(strFromU8(decompressSync(decode(source.hastGzip))));
+      hast = JSON.parse(decompressHast(source.hastGzip));
     } catch (error) {
       throw new Error(`Failed to parse hastGzip: ${JSON.stringify(error)}`);
     }
diff --git a/packages/docs-infra/src/pipeline/hastUtils/index.ts b/packages/docs-infra/src/pipeline/hastUtils/index.ts
index da4a6780a..030efe52a 100644
--- a/packages/docs-infra/src/pipeline/hastUtils/index.ts
+++ b/packages/docs-infra/src/pipeline/hastUtils/index.ts
@@ -1 +1,2 @@
+export * from './hastCompression';
 export * from './hastUtils';
diff --git a/packages/docs-infra/src/pipeline/loadCodeVariant/loadCodeVariant.ts b/packages/docs-infra/src/pipeline/loadCodeVariant/loadCodeVariant.ts
index 89e0d412d..143e7762c 100644
--- a/packages/docs-infra/src/pipeline/loadCodeVariant/loadCodeVariant.ts
+++ b/packages/docs-infra/src/pipeline/loadCodeVariant/loadCodeVariant.ts
@@ -1,6 +1,5 @@
 import * as path from 'path-module';
-import { compress, AsyncGzipOptions, strToU8 } from 'fflate';
-import { encode } from 'uint8-to-base64';
+import { compressHastAsync } from '../hastUtils';
 import { transformSource } from './transformSource';
 import { diffHast } from './diffHast';
 import { getFileNameFromUrl, getLanguageFromExtension, normalizeLanguage } from '../loaderUtils';
@@ -41,18 +40,6 @@ function convertCommentsToOneIndexed(
   return converted;
 }
 
-function compressAsync(input: Uint8Array, options: AsyncGzipOptions = {}): Promise {
-  return new Promise((resolve, reject) => {
-    compress(input, options, (err, output) => {
-      if (err) {
-        reject(err);
-      } else {
-        resolve(output);
-      }
-    });
-  });
-}
-
 /**
  * Check if a path is absolute (either filesystem absolute or URL)
  */
@@ -401,9 +388,7 @@ async function loadSingleFile(
       }
 
       if (options.output === 'hastGzip' && process.env.NODE_ENV === 'production') {
-        const hastGzip = encode(
-          await compressAsync(strToU8(JSON.stringify(finalSource)), { consume: true, level: 9 }),
-        );
+        const hastGzip = await compressHastAsync(JSON.stringify(finalSource));
         finalSource = { hastGzip };
 
         currentMark = performanceMeasure(
diff --git a/packages/docs-infra/src/pipeline/loadCodeVariant/transformSource.ts b/packages/docs-infra/src/pipeline/loadCodeVariant/transformSource.ts
index 1ef0c2e93..c275a8c2c 100644
--- a/packages/docs-infra/src/pipeline/loadCodeVariant/transformSource.ts
+++ b/packages/docs-infra/src/pipeline/loadCodeVariant/transformSource.ts
@@ -1,27 +1,11 @@
 import { create, Delta } from 'jsondiffpatch';
 import { toText } from 'hast-util-to-text';
-import { AsyncInflateOptions, decompress, strFromU8 } from 'fflate';
-import { decode } from 'uint8-to-base64';
 import type { Nodes as HastNodes } from 'hast';
 import type { VariantSource, SourceTransformers, Transforms } from '../../CodeHighlighter/types';
+import { decompressHastAsync } from '../hastUtils';
 
 const differ = create({ omitRemovedValues: true, cloneDiffValues: true });
 
-function decompressAsync(
-  input: Uint8Array,
-  options: AsyncInflateOptions = {},
-): Promise {
-  return new Promise((resolve, reject) => {
-    decompress(input, options, (err, output) => {
-      if (err) {
-        reject(err);
-      } else {
-        resolve(output);
-      }
-    });
-  });
-}
-
 export async function transformSource(
   source: VariantSource,
   fileName: string,
@@ -40,10 +24,9 @@ export async function transformSource(
         } else if ('hastJson' in source) {
           sourceString = toText(JSON.parse(source.hastJson) as HastNodes);
         } else if ('hastGzip' in source) {
-          const decompressed = strFromU8(
-            await decompressAsync(decode(source.hastGzip), { consume: true }),
+          sourceString = toText(
+            JSON.parse(await decompressHastAsync(source.hastGzip)) as HastNodes,
           );
-          sourceString = toText(JSON.parse(decompressed) as HastNodes);
         } else {
           sourceString = toText(source);
         }
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts b/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
index 8e5fc1abb..9dc22c4e4 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
@@ -6,8 +6,7 @@
  */
 
 import type { Root as HastRoot, Element, Text, RootContent } from 'hast';
-import { compressSync, strToU8 } from 'fflate';
-import { encode } from 'uint8-to-base64';
+import { compressHast } from '../hastUtils';
 
 /**
  * Extracts all text content from a HAST node recursively.
@@ -535,7 +534,7 @@ export function serializeHastRoot(hast: HastRoot): SerializedHastRoot {
 
 /** Converts a HastRoot to a gzip-compressed, base64-encoded wrapper. */
 export function compressHastRoot(hast: HastRoot): SerializedHastGzip {
-  return { hastGzip: encode(compressSync(strToU8(JSON.stringify(hast)), { level: 9 })) };
+  return { hastGzip: compressHast(JSON.stringify(hast)) };
 }
 
 /** Returns the appropriate serializer function for the given output format. */
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.test.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.test.ts
index 4062deac1..5c80ec240 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.test.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.test.ts
@@ -1,6 +1,5 @@
 import { describe, it, expect } from 'vitest';
-import { decompress, strFromU8 } from 'fflate';
-import { decode } from 'uint8-to-base64';
+import { decompressHastAsync } from '../hastUtils';
 import { highlightTypes } from './highlightTypes';
 import type { TypesMeta } from '../loadServerTypesMeta';
 
@@ -607,44 +606,33 @@ describe('highlightTypes', () => {
     /**
      * Helper to decompress precomputed data from HAST node
      */
-    function decompressPrecompute(node: any): Promise {
-      return new Promise((resolve, reject) => {
-        if (!hasDataPrecompute(node)) {
-          reject(new Error('Node does not have dataPrecompute'));
-          return;
-        }
+    async function decompressPrecompute(node: any): Promise {
+      if (!hasDataPrecompute(node)) {
+        throw new Error('Node does not have dataPrecompute');
+      }
 
-        const precomputeData = JSON.parse(node.properties.dataPrecompute);
-        const variantName = Object.keys(precomputeData)[0];
-        const variant = precomputeData[variantName];
-
-        // Handle different source formats
-        if (typeof variant.source === 'object' && variant.source.hastGzip) {
-          // Decompress the base64-encoded gzipped source
-          const compressed = decode(variant.source.hastGzip);
-          decompress(compressed, { consume: true }, (err, output) => {
-            if (err) {
-              reject(err);
-            } else {
-              const decompressed = strFromU8(output);
-              const hast = JSON.parse(decompressed);
-              resolve({ ...variant, decompressedHast: hast });
-            }
-          });
-        } else if (typeof variant.source === 'object' && variant.source.hastJson) {
-          // Parse JSON directly
-          const hast = JSON.parse(variant.source.hastJson);
-          resolve({ ...variant, decompressedHast: hast });
-        } else if (typeof variant.source === 'object' && variant.source.type === 'root') {
-          // Direct HAST object (already parsed, no compression)
-          resolve({ ...variant, decompressedHast: variant.source });
-        } else if (typeof variant.source === 'string') {
-          // Plain string source
-          resolve({ ...variant, decompressedHast: null, plainSource: variant.source });
-        } else {
-          reject(new Error('No valid source found in variant'));
-        }
-      });
+      const precomputeData = JSON.parse(node.properties.dataPrecompute);
+      const variantName = Object.keys(precomputeData)[0];
+      const variant = precomputeData[variantName];
+
+      // Handle different source formats
+      if (typeof variant.source === 'object' && variant.source.hastGzip) {
+        // Decompress the base64-encoded gzipped source
+        const decompressed = await decompressHastAsync(variant.source.hastGzip);
+        const hast = JSON.parse(decompressed);
+        return { ...variant, decompressedHast: hast };
+      }
+      if (typeof variant.source === 'object' && variant.source.hastJson) {
+        const hast = JSON.parse(variant.source.hastJson);
+        return { ...variant, decompressedHast: hast };
+      }
+      if (typeof variant.source === 'object' && variant.source.type === 'root') {
+        return { ...variant, decompressedHast: variant.source };
+      }
+      if (typeof variant.source === 'string') {
+        return { ...variant, decompressedHast: null, plainSource: variant.source };
+      }
+      throw new Error('No valid source found in variant');
     }
 
     it('should produce valid highlighted output for TypeScript type signature', async () => {
diff --git a/packages/docs-infra/src/useCode/Pre.tsx b/packages/docs-infra/src/useCode/Pre.tsx
index a79050a18..d18e193df 100644
--- a/packages/docs-infra/src/useCode/Pre.tsx
+++ b/packages/docs-infra/src/useCode/Pre.tsx
@@ -3,10 +3,8 @@
 import * as React from 'react';
 import { toText } from 'hast-util-to-text';
 import { ElementContent } from 'hast';
-import { decompressSync, strFromU8 } from 'fflate';
-import { decode } from 'uint8-to-base64';
 import type { HastRoot, VariantSource } from '../CodeHighlighter/types';
-import { hastToJsx } from '../pipeline/hastUtils';
+import { hastToJsx, decompressHast } from '../pipeline/hastUtils';
 
 const hastChildrenCache = new WeakMap();
 const textChildrenCache = new WeakMap();
@@ -58,7 +56,7 @@ export function Pre({
     }
 
     if ('hastGzip' in children) {
-      return JSON.parse(strFromU8(decompressSync(decode(children.hastGzip)))) as HastRoot;
+      return JSON.parse(decompressHast(children.hastGzip)) as HastRoot;
     }
 
     return children;
diff --git a/packages/docs-infra/src/useCode/useFileNavigation.tsx b/packages/docs-infra/src/useCode/useFileNavigation.tsx
index beb07d8f1..1946ebae2 100644
--- a/packages/docs-infra/src/useCode/useFileNavigation.tsx
+++ b/packages/docs-infra/src/useCode/useFileNavigation.tsx
@@ -1,7 +1,6 @@
 import * as React from 'react';
-import { decompressSync, strFromU8 } from 'fflate';
 import type { Root as HastRoot } from 'hast';
-import { decode } from 'uint8-to-base64';
+import { decompressHast } from '../pipeline/hastUtils';
 import type {
   VariantCode,
   VariantSource,
@@ -526,7 +525,7 @@ export function useFileNavigation({
       if ('hastJson' in selectedFile) {
         hastSelectedFile = JSON.parse(selectedFile.hastJson);
       } else if ('hastGzip' in selectedFile) {
-        hastSelectedFile = JSON.parse(strFromU8(decompressSync(decode(selectedFile.hastGzip))));
+        hastSelectedFile = JSON.parse(decompressHast(selectedFile.hastGzip));
       } else {
         hastSelectedFile = selectedFile;
       }
diff --git a/packages/docs-infra/src/useCode/useSourceEnhancing.ts b/packages/docs-infra/src/useCode/useSourceEnhancing.ts
index 768d5b61c..0a6c30c2f 100644
--- a/packages/docs-infra/src/useCode/useSourceEnhancing.ts
+++ b/packages/docs-infra/src/useCode/useSourceEnhancing.ts
@@ -2,8 +2,7 @@
 
 import * as React from 'react';
 import type { Root as HastRoot } from 'hast';
-import { decompressSync, strFromU8 } from 'fflate';
-import { decode } from 'uint8-to-base64';
+import { decompressHast } from '../pipeline/hastUtils';
 import type {
   SourceEnhancers,
   SourceComments,
@@ -43,7 +42,7 @@ function resolveHastRoot(source: VariantSource | undefined): HastRoot | null {
   }
 
   if ('hastGzip' in source) {
-    return JSON.parse(strFromU8(decompressSync(decode(source.hastGzip)))) as HastRoot;
+    return JSON.parse(decompressHast(source.hastGzip)) as HastRoot;
   }
 
   if (isHastRoot(source)) {

From 37a5a0cb0c10cca317908722edef67a8ec117fb8 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 12:58:24 -0400
Subject: [PATCH 12/61] Update index and types

---
 .../docs-infra/pipeline/hast-utils/types.md   | 70 +++++++++++++++++++
 docs/app/docs-infra/pipeline/page.mdx         |  8 +++
 2 files changed, 78 insertions(+)

diff --git a/docs/app/docs-infra/pipeline/hast-utils/types.md b/docs/app/docs-infra/pipeline/hast-utils/types.md
index 938841c21..f178b6588 100644
--- a/docs/app/docs-infra/pipeline/hast-utils/types.md
+++ b/docs/app/docs-infra/pipeline/hast-utils/types.md
@@ -4,6 +4,76 @@
 
 ## API Reference
 
+### compressHast
+
+Compress a JSON string using DEFLATE with the shared HAST dictionary.
+Returns a base64-encoded string suitable for embedding in serialized props.
+
+**Parameters:**
+
+| Parameter | Type     | Default | Description |
+| :-------- | :------- | :------ | :---------- |
+| json      | `string` | -       | -           |
+
+**Return Value:**
+
+```tsx
+type ReturnValue = string;
+```
+
+### compressHastAsync
+
+Compress a string asynchronously using DEFLATE with the shared HAST dictionary.
+Returns a base64-encoded string.
+
+**Parameters:**
+
+| Parameter | Type     | Default | Description |
+| :-------- | :------- | :------ | :---------- |
+| input     | `string` | -       | -           |
+
+**Return Value:**
+
+```tsx
+type ReturnValue = Promise;
+```
+
+### decompressHast
+
+Decompress a base64-encoded DEFLATE payload that was compressed with
+`compressHast`. Returns the original JSON string.
+
+Throws if the payload was not compressed with the matching dictionary.
+
+**Parameters:**
+
+| Parameter | Type     | Default | Description |
+| :-------- | :------- | :------ | :---------- |
+| base64    | `string` | -       | -           |
+
+**Return Value:**
+
+```tsx
+type ReturnValue = string;
+```
+
+### decompressHastAsync
+
+Decompress a base64-encoded DEFLATE payload asynchronously.
+Returns the original JSON string.
+
+**Parameters:**
+
+| Parameter | Type     | Default | Description |
+| :-------- | :------- | :------ | :---------- |
+| base64    | `string` | -       | -           |
+
+**Return Value:**
+
+```tsx
+type ReturnValue = Promise;
+```
+
 ### hastOrJsonToJsx
 
 **Parameters:**
diff --git a/docs/app/docs-infra/pipeline/page.mdx b/docs/app/docs-infra/pipeline/page.mdx
index e50647d1a..c8dd7b041 100644
--- a/docs/app/docs-infra/pipeline/page.mdx
+++ b/docs/app/docs-infra/pipeline/page.mdx
@@ -291,6 +291,14 @@ The `hastUtils` module provides utilities for converting between HAST (Hypertext
   - Additional Types
   - When to Use
 - Exports:
+  - compressHast
+    - Parameters: json
+  - compressHastAsync
+    - Parameters: input
+  - decompressHast
+    - Parameters: base64
+  - decompressHastAsync
+    - Parameters: base64
   - hastOrJsonToJsx
     - Parameters: hastOrJson, components
   - hastToJsx

From 2f8424ce4f5ce812b6d9eb1c53e7552e80108be4 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 12:58:36 -0400
Subject: [PATCH 13/61] Add more items to dictionary

---
 .../src/pipeline/hastUtils/hastCompression.ts | 178 +++++++++++++++++-
 1 file changed, 172 insertions(+), 6 deletions(-)

diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
index 9a5c72f09..5f5f880ff 100644
--- a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
@@ -7,7 +7,7 @@ import { encode, decode } from 'uint8-to-base64';
  * Contains byte sequences that frequently appear in JSON-serialized HAST trees
  * (syntax-highlighted TypeScript type documentation). The dictionary is
  * embedded in both the server build and the client bundle, so it must stay
- * small — currently ~600 bytes uncompressed.
+ * small — currently ~3 KB uncompressed.
  *
  * IMPORTANT: Changing this dictionary is a **breaking change** for any
  * previously-compressed payloads. When the dictionary is updated, all cached
@@ -27,21 +27,75 @@ const HAST_DICTIONARY = strToU8(
     '"type":"root"',
     '"type":"element"',
     '"type":"text"',
-    // Starry Night highlighting class names
-    'pl-k","pl-',
-    'pl-smi',
+    // Starry Night / Pretty Lights class names (full set)
     'pl-c1',
+    'pl-c2',
+    'pl-c',
+    'pl-corl',
+    'pl-cce',
     'pl-en',
+    'pl-ent',
+    'pl-e',
+    'pl-k',
+    'pl-smi',
+    'pl-smw',
+    'pl-s1',
+    'pl-sre',
+    'pl-sra',
+    'pl-sr',
     'pl-s',
-    'pl-v',
     'pl-pds',
+    'pl-pse',
+    'pl-v',
+    'pl-bu',
+    'pl-ii',
+    'pl-ml',
+    'pl-mh',
+    'pl-ms',
+    'pl-mi1',
+    'pl-mi2',
+    'pl-mi',
+    'pl-mb',
+    'pl-md',
+    'pl-mc',
+    'pl-mdr',
+    'pl-ba',
+    'pl-sg',
     // Frame & line structure
     '"className":["frame"]',
     '"className":["line"]',
     '"dataFrameStartLine":',
     '"dataFrameEndLine":',
+    '"dataFrameType":"highlighted"',
+    '"dataFrameIndent":',
     '"dataLn":',
-    // Common TypeScript tokens in type documentation
+    // Common TypeScript keywords (appear as highlighted spans)
+    'interface',
+    'export',
+    'import',
+    'function',
+    'return',
+    'extends',
+    'implements',
+    'class',
+    'typeof',
+    'keyof',
+    'readonly',
+    'abstract',
+    'public',
+    'private',
+    'protected',
+    'static',
+    'async',
+    'await',
+    'const',
+    'true',
+    'false',
+    'never',
+    'any',
+    'unknown',
+    'type',
+    // Common TypeScript primitive and utility types
     'string',
     'number',
     'boolean',
@@ -52,9 +106,121 @@ const HAST_DICTIONARY = strToU8(
     'Array',
     'Record',
     'Partial',
+    'Required',
+    'Readonly',
+    'Pick',
+    'Omit',
+    'Exclude',
+    'Extract',
+    'NonNullable',
+    'ReturnType',
+    'Parameters',
+    'Promise',
+    // Common React types
     'React.ReactNode',
+    'React.ReactElement',
     'React.HTMLAttributes',
+    'React.AriaAttributes',
+    'React.CSSProperties',
+    'React.ComponentPropsWithRef',
+    'React.Ref',
+    'React.RefObject',
+    'React.ElementRef',
+    'React.ComponentProps',
+    'React.FC',
+    'React.Dispatch',
+    'React.SetStateAction',
+    // Common DOM types
     'HTMLElement',
+    'HTMLDivElement',
+    'HTMLButtonElement',
+    'HTMLInputElement',
+    'HTMLSelectElement',
+    'HTMLTextAreaElement',
+    'HTMLAnchorElement',
+    'HTMLFormElement',
+    'HTMLSpanElement',
+    'HTMLLabelElement',
+    'Element',
+    'EventTarget',
+    // Common event types
+    'MouseEvent',
+    'ChangeEvent',
+    'KeyboardEvent',
+    'FocusEvent',
+    'FormEvent',
+    'PointerEvent',
+    'TouchEvent',
+    // Common punctuation patterns in type signatures
+    ' | ',
+    ' & ',
+    ' => ',
+    '{ ',
+    ' }',
+    '(): ',
+    '(event: ',
+    ': string',
+    ': number',
+    ': boolean',
+    ': void',
+    // Common prop names (from typeOrder)
+    'className',
+    'children',
+    'disabled',
+    'style',
+    'render',
+    'defaultValue',
+    'value',
+    'onValueChange',
+    'defaultOpen',
+    'open',
+    'onOpenChange',
+    'defaultChecked',
+    'checked',
+    'onCheckedChange',
+    'orientation',
+    'keepMounted',
+    'required',
+    'readOnly',
+    'name',
+    'label',
+    'container',
+    'anchor',
+    'align',
+    'side',
+    'sideOffset',
+    'alignOffset',
+    // Common data attributes
+    'data-disabled',
+    'data-open',
+    'data-closed',
+    'data-checked',
+    'data-unchecked',
+    'data-pressed',
+    'data-selected',
+    'data-highlighted',
+    'data-orientation',
+    'data-valid',
+    'data-invalid',
+    'data-required',
+    'data-readonly',
+    // Common component part names
+    'Root',
+    'Trigger',
+    'Popup',
+    'Positioner',
+    'Portal',
+    'Arrow',
+    'Content',
+    'Item',
+    'Indicator',
+    'Group',
+    'Track',
+    'Thumb',
+    // Common type suffixes
+    'Props',
+    'DataAttributes',
+    'CssVars',
   ].join(''),
 );
 

From 49e57788ecad41fe4c4657b2a1c31bed29c23421 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 13:37:49 -0400
Subject: [PATCH 14/61] Rename hastGzip to hastCompressed

---
 .../components/code-highlighter/types.md      |  8 ++---
 .../patterns/prop-compression/page.mdx        |  4 +--
 .../docs-infra/pipeline/hast-utils/types.md   | 24 +++++++-------
 .../pipeline/load-code-variant/page.mdx       |  2 +-
 .../page.mdx                                  |  4 +--
 .../pipeline/load-server-types/types.md       | 14 ++++-----
 docs/app/docs-infra/pipeline/page.mdx         |  2 +-
 .../src/CodeHighlighter/CodeHighlighter.tsx   |  4 +--
 .../docs-infra/src/CodeHighlighter/types.ts   |  4 +--
 .../DeferredHighlightClient.tsx               | 12 +++----
 .../abstractCreateTypes/typesToJsx.test.ts    | 12 +++----
 .../src/abstractCreateTypes/typesToJsx.ts     | 31 +++++++++++--------
 .../src/pipeline/hastUtils/hastCompression.ts |  2 +-
 .../src/pipeline/hastUtils/hastUtils.tsx      | 24 +++++++-------
 .../loadCodeVariant/loadCodeVariant.ts        |  8 ++---
 .../loadCodeVariant/transformSource.ts        |  4 +--
 .../loadPrecomputedCodeHighlighter.ts         |  4 +--
 .../loadPrecomputedTypes.ts                   |  2 +-
 .../pipeline/loadServerTypes/hastTypeUtils.ts | 20 ++++++------
 .../loadServerTypes/highlightTypes.test.ts    | 12 +++----
 .../highlightTypesMeta.test.ts                |  4 +--
 .../loadServerTypes/highlightTypesMeta.ts     |  4 +--
 .../loadServerTypes/loadServerTypes.ts        | 10 ++++--
 .../transformHtmlCodeBlock.ts                 |  2 +-
 packages/docs-infra/src/useCode/Pre.tsx       |  4 +--
 .../src/useCode/useFileNavigation.tsx         |  4 +--
 .../src/useCode/useSourceEnhancing.ts         |  8 ++---
 .../src/withDocsInfra/withDocsInfra.test.ts   | 26 ++++++++--------
 .../src/withDocsInfra/withDocsInfra.ts        |  2 +-
 29 files changed, 135 insertions(+), 126 deletions(-)

diff --git a/docs/app/docs-infra/components/code-highlighter/types.md b/docs/app/docs-infra/components/code-highlighter/types.md
index 2c78b421f..05c3cc2bf 100644
--- a/docs/app/docs-infra/components/code-highlighter/types.md
+++ b/docs/app/docs-infra/components/code-highlighter/types.md
@@ -637,7 +637,7 @@ type LoadFallbackCodeOptions = {
    * Output format for the loaded file
    * @default 'hast'
    */
-  output?: 'hast' | 'hastJson' | 'hastGzip';
+  output?: 'hast' | 'hastJson' | 'hastCompressed';
   /** Function to load code metadata from a URL */
   loadCodeMeta?: LoadCodeMeta;
   /** Function to load specific variant metadata */
@@ -679,7 +679,7 @@ type LoadFileOptions = {
    * Output format for the loaded file
    * @default 'hast'
    */
-  output?: 'hast' | 'hastJson' | 'hastGzip';
+  output?: 'hast' | 'hastJson' | 'hastCompressed';
 };
 ```
 
@@ -703,7 +703,7 @@ type LoadVariantOptions = {
    * Output format for the loaded file
    * @default 'hast'
    */
-  output?: 'hast' | 'hastJson' | 'hastGzip';
+  output?: 'hast' | 'hastJson' | 'hastCompressed';
   /** Promise resolving to a source parser for syntax highlighting */
   sourceParser?: Promise;
   /** Function to load raw source code and dependencies */
@@ -814,7 +814,7 @@ type VariantExtraFiles = {
 ### VariantSource
 
 ```typescript
-type VariantSource = string | HastRoot | { hastJson: string } | { hastGzip: string };
+type VariantSource = string | HastRoot | { hastJson: string } | { hastCompressed: string };
 ```
 
 ## Export Groups
diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx
index 85409cd1e..32d1b25fd 100644
--- a/docs/app/docs-infra/patterns/prop-compression/page.mdx
+++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx
@@ -254,7 +254,7 @@ build-time loader processes each factory call (`createDemo(import.meta.url,
 ...)`) and injects a `precompute` object containing the gzip-compressed HAST.
 The
 [`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx)
-loader's `hastGzip` output mode is a concrete implementation of this.
+loader's `hastCompressed` output mode is a concrete implementation of this.
 
 At runtime, the factory receives precomputed data through its options - no
 heavy highlighting or parsing libraries are shipped to the client. When
@@ -332,5 +332,5 @@ progressively improving readability as highlighted blocks become eligible.
 This pattern is used throughout the docs-infra system:
 
 - **[`CodeHighlighter`](../../components/code-highlighter/page.mdx)**: Renders plain text as the `Suspense` fallback, then progressively enhances with decompressed highlighted code
-- **[`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx)**: Build-time loader that produces compressed HAST via the `hastGzip` output format
+- **[`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx)**: Build-time loader that produces compressed HAST via the `hastCompressed` output format
 - **[`useCode`](../../hooks/use-code/page.mdx)**: Hook that manages decompression and caching of compressed props on the client
diff --git a/docs/app/docs-infra/pipeline/hast-utils/types.md b/docs/app/docs-infra/pipeline/hast-utils/types.md
index f178b6588..0ed184f89 100644
--- a/docs/app/docs-infra/pipeline/hast-utils/types.md
+++ b/docs/app/docs-infra/pipeline/hast-utils/types.md
@@ -78,10 +78,10 @@ type ReturnValue = Promise;
 
 **Parameters:**
 
-| Parameter   | Type                                                        | Default | Description |
-| :---------- | :---------------------------------------------------------- | :------ | :---------- |
-| hastOrJson  | `HastNodes \| { hastJson: string } \| { hastGzip: string }` | -       | -           |
-| components? | `Partial`                                       | -       | -           |
+| Parameter   | Type                                                              | Default | Description |
+| :---------- | :---------------------------------------------------------------- | :------ | :---------- |
+| hastOrJson  | `HastNodes \| { hastJson: string } \| { hastCompressed: string }` | -       | -           |
+| components? | `Partial`                                             | -       | -           |
 
 **Return Value:**
 
@@ -108,11 +108,11 @@ type ReturnValue = React.ReactNode;
 
 **Parameters:**
 
-| Parameter    | Type                                                                  | Default | Description |
-| :----------- | :-------------------------------------------------------------------- | :------ | :---------- |
-| source       | `string \| HastNodes \| { hastJson: string } \| { hastGzip: string }` | -       | -           |
-| highlighted? | `boolean`                                                             | -       | -           |
-| components?  | `Partial`                                                 | -       | -           |
+| Parameter    | Type                                                                        | Default | Description |
+| :----------- | :-------------------------------------------------------------------------- | :------ | :---------- |
+| source       | `string \| HastNodes \| { hastJson: string } \| { hastCompressed: string }` | -       | -           |
+| highlighted? | `boolean`                                                                   | -       | -           |
+| components?  | `Partial`                                                       | -       | -           |
 
 **Return Value:**
 
@@ -124,9 +124,9 @@ type ReturnValue = React.ReactNode;
 
 **Parameters:**
 
-| Parameter | Type                                                                  | Default | Description |
-| :-------- | :-------------------------------------------------------------------- | :------ | :---------- |
-| source    | `string \| HastNodes \| { hastJson: string } \| { hastGzip: string }` | -       | -           |
+| Parameter | Type                                                                        | Default | Description |
+| :-------- | :-------------------------------------------------------------------------- | :------ | :---------- |
+| source    | `string \| HastNodes \| { hastJson: string } \| { hastCompressed: string }` | -       | -           |
 
 **Return Value:**
 
diff --git a/docs/app/docs-infra/pipeline/load-code-variant/page.mdx b/docs/app/docs-infra/pipeline/load-code-variant/page.mdx
index 0e9913dab..2dd481040 100644
--- a/docs/app/docs-infra/pipeline/load-code-variant/page.mdx
+++ b/docs/app/docs-infra/pipeline/load-code-variant/page.mdx
@@ -444,7 +444,7 @@ The loader tracks loaded files and throws errors on circular dependencies:
 ### Output Formats
 
 - `output: 'hastJson'` - JSON stringified HAST (development)
-- `output: 'hastGzip'` - Compressed HAST (production, smaller bundles)
+- `output: 'hastCompressed'` - Compressed HAST (production, smaller bundles)
 
 ---
 
diff --git a/docs/app/docs-infra/pipeline/load-precomputed-code-highlighter/page.mdx b/docs/app/docs-infra/pipeline/load-precomputed-code-highlighter/page.mdx
index 6b148fab1..15fe24873 100644
--- a/docs/app/docs-infra/pipeline/load-precomputed-code-highlighter/page.mdx
+++ b/docs/app/docs-infra/pipeline/load-precomputed-code-highlighter/page.mdx
@@ -288,7 +288,7 @@ config.module.rules.push({
       loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
       options: {
         // Output format for HAST nodes
-        output: 'hastGzip', // 'hast' | 'hastJson' | 'hastGzip'
+        output: 'hastCompressed', // 'hast' | 'hastJson' | 'hastCompressed'
 
         // Comment extraction for sourceEnhancers
         notableCommentsPrefix: ['@highlight', '@focus'],
@@ -340,7 +340,7 @@ const normal = 'not highlighted';
 
 - **`'hast'`**: Raw HAST objects (largest, fastest to parse)
 - **`'hastJson'`**: JSON-stringified HAST (smaller, needs parsing)
-- **`'hastGzip'`**: Gzip-compressed JSON (smallest, needs decompression)
+- **`'hastCompressed'`**: Gzip-compressed JSON (smallest, needs decompression)
 
 ### Performance Options
 
diff --git a/docs/app/docs-infra/pipeline/load-server-types/types.md b/docs/app/docs-infra/pipeline/load-server-types/types.md
index cf64742c4..1924926e0 100644
--- a/docs/app/docs-infra/pipeline/load-server-types/types.md
+++ b/docs/app/docs-infra/pipeline/load-server-types/types.md
@@ -338,8 +338,8 @@ type LoadServerTypesOptions = {
    * - `'hast'`: Live HAST Root objects (default)
    * - `'hastJson'`: JSON-serialized `{ hastJson: string }` wrappers — defers
    *   tree allocation from module-evaluation time to render time
-   * - `'hastGzip'`: Gzip-compressed + base64-encoded `{ hastGzip: string }`
-   *   wrappers — smallest payload, decompressed at render time
+   * - `'hastCompressed'`: Dictionary-compressed + base64-encoded `{ hastCompressed: string }`
+   *   wrappers — smallest payload, decompressed with shared dictionary at render time
    * @default 'hast'
    */
   output?: TypesOutputFormat;
@@ -435,13 +435,13 @@ type LoadServerTypesResult = {
 };
 ```
 
-### SerializedHastGzip
+### SerializedHastCompressed
 
-A gzip-compressed, base64-encoded wrapper around a HastRoot.
-Smaller than JSON for transport; decoded and decompressed at render time.
+A DEFLATE-compressed (with shared dictionary), base64-encoded wrapper around a HastRoot.
+Smaller than JSON for transport; decompressed with the matching dictionary at render time.
 
 ```typescript
-type SerializedHastGzip = { hastGzip: string };
+type SerializedHastCompressed = { hastCompressed: string };
 ```
 
 ### SerializedHastRoot
@@ -459,5 +459,5 @@ type SerializedHastRoot = { hastJson: string };
 Controls the output format of HAST fields in type metadata.
 
 ```typescript
-type TypesOutputFormat = 'hast' | 'hastJson' | 'hastGzip';
+type TypesOutputFormat = 'hast' | 'hastJson' | 'hastCompressed';
 ```
diff --git a/docs/app/docs-infra/pipeline/page.mdx b/docs/app/docs-infra/pipeline/page.mdx
index c8dd7b041..30ac41fae 100644
--- a/docs/app/docs-infra/pipeline/page.mdx
+++ b/docs/app/docs-infra/pipeline/page.mdx
@@ -779,7 +779,7 @@ A server-side function for loading and processing TypeScript types with syntax h
 - Exports:
   - loadServerTypes
     - Parameters: options
-- Types: HighlightedClassProperty, HighlightedClassTypeMeta, HighlightedComponentTypeMeta, HighlightedEnumMemberMeta, HighlightedFunctionTypeMeta, HighlightedHookTypeMeta, HighlightedMethod, HighlightedParameter, HighlightedProperty, HighlightedRawTypeMeta, HighlightedTypesMeta, LoadServerTypesOptions, LoadServerTypesResult, SerializedHastGzip, SerializedHastRoot, TypesOutputFormat
+- Types: HighlightedClassProperty, HighlightedClassTypeMeta, HighlightedComponentTypeMeta, HighlightedEnumMemberMeta, HighlightedFunctionTypeMeta, HighlightedHookTypeMeta, HighlightedMethod, HighlightedParameter, HighlightedProperty, HighlightedRawTypeMeta, HighlightedTypesMeta, LoadServerTypesOptions, LoadServerTypesResult, SerializedHastCompressed, SerializedHastRoot, TypesOutputFormat
 
 
 
diff --git a/packages/docs-infra/src/CodeHighlighter/CodeHighlighter.tsx b/packages/docs-infra/src/CodeHighlighter/CodeHighlighter.tsx
index e1f7d769f..b02c47d09 100644
--- a/packages/docs-infra/src/CodeHighlighter/CodeHighlighter.tsx
+++ b/packages/docs-infra/src/CodeHighlighter/CodeHighlighter.tsx
@@ -169,7 +169,7 @@ async function CodeSourceLoader(props: CodeSourceLoaderProps) {
         }
       }
 
-      let output: 'hast' | 'hastJson' | 'hastGzip' = 'hastGzip';
+      let output: 'hast' | 'hastJson' | 'hastCompressed' = 'hastCompressed';
       if (props.deferParsing === 'json') {
         output = 'hastJson';
       } else if (props.deferParsing === 'none') {
@@ -332,7 +332,7 @@ async function CodeInitialSourceLoader(props: CodeInitialSourceLoa
     throw new Errors.ErrorCodeHighlighterServerMissingUrl();
   }
 
-  let output: 'hast' | 'hastJson' | 'hastGzip' = 'hastGzip';
+  let output: 'hast' | 'hastJson' | 'hastCompressed' = 'hastCompressed';
   if (props.deferParsing === 'json') {
     output = 'hastJson';
   } else if (props.deferParsing === 'none') {
diff --git a/packages/docs-infra/src/CodeHighlighter/types.ts b/packages/docs-infra/src/CodeHighlighter/types.ts
index 4cc35c680..af15c23fa 100644
--- a/packages/docs-infra/src/CodeHighlighter/types.ts
+++ b/packages/docs-infra/src/CodeHighlighter/types.ts
@@ -27,7 +27,7 @@ export interface HastRoot extends Root {
   data?: RootData & { totalLines?: number };
 }
 
-export type VariantSource = string | HastRoot | { hastJson: string } | { hastGzip: string };
+export type VariantSource = string | HastRoot | { hastJson: string } | { hastCompressed: string };
 
 /**
  * Additional files associated with a code variant.
@@ -193,7 +193,7 @@ export interface LoadFileOptions {
   /** Output format for the loaded file
    * @default 'hast'
    */
-  output?: 'hast' | 'hastJson' | 'hastGzip';
+  output?: 'hast' | 'hastJson' | 'hastCompressed';
 }
 
 /**
diff --git a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
index 949310dba..ae09ca2da 100644
--- a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
+++ b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
@@ -8,8 +8,8 @@ type HighlightAt = 'hydration' | 'idle';
 interface DeferredHighlightClientProps {
   /** JSON-serialized array of HAST children. Used when data is not compressed. */
   hastJson?: string;
-  /** Gzip-compressed, base64-encoded array of HAST children. */
-  hastGzip?: string;
+  /** DEFLATE-compressed (with shared dictionary), base64-encoded array of HAST children. */
+  hastCompressed?: string;
   /** When to replace the fallback with the fully-highlighted version. */
   highlightAt: HighlightAt;
   /** Server-rendered links-only fallback. */
@@ -25,7 +25,7 @@ interface DeferredHighlightClientProps {
  */
 export function DeferredHighlightClient({
   hastJson,
-  hastGzip,
+  hastCompressed,
   highlightAt,
   children,
 }: DeferredHighlightClientProps) {
@@ -34,8 +34,8 @@ export function DeferredHighlightClient({
   React.useEffect(() => {
     const render = () => {
       let nodes;
-      if (hastGzip) {
-        nodes = JSON.parse(decompressHast(hastGzip));
+      if (hastCompressed) {
+        nodes = JSON.parse(decompressHast(hastCompressed));
       } else {
         nodes = JSON.parse(hastJson!);
       }
@@ -55,7 +55,7 @@ export function DeferredHighlightClient({
     }
     const id = setTimeout(render, 0);
     return () => clearTimeout(id);
-  }, [hastJson, hastGzip, highlightAt]);
+  }, [hastJson, hastCompressed, highlightAt]);
 
   if (highlighted !== null) {
     return highlighted;
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
index c5a12912f..e9fcde3bb 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
@@ -180,7 +180,7 @@ function createHighlightedFunction(
     returnValue?:
       | HastRoot
       | { hastJson: string }
-      | { hastGzip: string }
+      | { hastCompressed: string }
       | Record<
           string,
           {
@@ -207,10 +207,10 @@ function createHighlightedFunction(
 }
 
 /**
- * Compress a HAST root to a { hastGzip: string } wrapper for testing.
+ * Compress a HAST root to a { hastCompressed: string } wrapper for testing.
  */
-function compressHastRoot(hast: HastRoot): { hastGzip: string } {
-  return { hastGzip: compressHast(JSON.stringify(hast)) };
+function compressHastRoot(hast: HastRoot): { hastCompressed: string } {
+  return { hastCompressed: compressHast(JSON.stringify(hast)) };
 }
 
 describe('typesToJsx', () => {
@@ -330,7 +330,7 @@ describe('typesToJsx', () => {
         }
       });
 
-      it('should treat hastGzip returnValue as simple return type', () => {
+      it('should treat hastCompressed returnValue as simple return type', () => {
         const hook = createHighlightedHook('useCounter', {
           returnValue: compressHastRoot(createHastRoot('number')) as unknown as HastRoot,
         });
@@ -360,7 +360,7 @@ describe('typesToJsx', () => {
     });
 
     describe('function types', () => {
-      it('should treat hastGzip returnValue as simple return type', () => {
+      it('should treat hastCompressed returnValue as simple return type', () => {
         const func = createHighlightedFunction('getCount', {
           returnValue: compressHastRoot(createHastRoot('number')) as unknown as HastRoot,
         });
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
index b213ab034..7fddc04c8 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
@@ -337,16 +337,16 @@ export interface EnhancedExportData {
 /**
  * Type guard to check if a value is a HastRoot node or a serialized HAST wrapper.
  * Handles live `{ type: 'root', children: [...] }`, serialized `{ hastJson: string }`,
- * and compressed `{ hastGzip: string }`.
+ * and compressed `{ hastCompressed: string }`.
  */
 function isHastRoot(
   value: unknown,
-): value is HastRoot | { hastJson: string } | { hastGzip: string } {
+): value is HastRoot | { hastJson: string } | { hastCompressed: string } {
   if (typeof value !== 'object' || value === null) {
     return false;
   }
   // Serialized HAST from loadPrecomputedTypes
-  if ('hastJson' in value || 'hastGzip' in value) {
+  if ('hastJson' in value || 'hastCompressed' in value) {
     return true;
   }
   // Live HAST Root
@@ -417,20 +417,20 @@ function getOrCreateProcessor(enhancers: PluggableList): ReturnType): Re
 }
 
 export function hastOrJsonToJsx(
-  hastOrJson: HastNodes | { hastJson: string } | { hastGzip: string },
+  hastOrJson: HastNodes | { hastJson: string } | { hastCompressed: string },
   components?: Partial,
 ): React.ReactNode {
   let hast: HastNodes;
@@ -22,11 +22,11 @@ export function hastOrJsonToJsx(
     } catch (error) {
       throw new Error(`Failed to parse hastJson: ${JSON.stringify(error)}`);
     }
-  } else if ('hastGzip' in hastOrJson) {
+  } else if ('hastCompressed' in hastOrJson) {
     try {
-      hast = JSON.parse(decompressHast(hastOrJson.hastGzip));
+      hast = JSON.parse(decompressHast(hastOrJson.hastCompressed));
     } catch (error) {
-      throw new Error(`Failed to parse hastGzip: ${JSON.stringify(error)}`);
+      throw new Error(`Failed to parse hastCompressed: ${JSON.stringify(error)}`);
     }
   } else {
     hast = hastOrJson;
@@ -36,7 +36,7 @@ export function hastOrJsonToJsx(
 }
 
 export function stringOrHastToString(
-  source: string | HastNodes | { hastJson: string } | { hastGzip: string },
+  source: string | HastNodes | { hastJson: string } | { hastCompressed: string },
 ): string {
   if (typeof source === 'string') {
     return source;
@@ -49,11 +49,11 @@ export function stringOrHastToString(
     } catch (error) {
       throw new Error(`Failed to parse hastJson: ${JSON.stringify(error)}`);
     }
-  } else if ('hastGzip' in source) {
+  } else if ('hastCompressed' in source) {
     try {
-      hast = JSON.parse(decompressHast(source.hastGzip));
+      hast = JSON.parse(decompressHast(source.hastCompressed));
     } catch (error) {
-      throw new Error(`Failed to parse hastGzip: ${JSON.stringify(error)}`);
+      throw new Error(`Failed to parse hastCompressed: ${JSON.stringify(error)}`);
     }
   } else {
     hast = source;
@@ -63,7 +63,7 @@ export function stringOrHastToString(
 }
 
 export function stringOrHastToJsx(
-  source: string | HastNodes | { hastJson: string } | { hastGzip: string },
+  source: string | HastNodes | { hastJson: string } | { hastCompressed: string },
   highlighted?: boolean,
   components?: Partial,
 ): React.ReactNode {
@@ -78,11 +78,11 @@ export function stringOrHastToJsx(
     } catch (error) {
       throw new Error(`Failed to parse hastJson: ${JSON.stringify(error)}`);
     }
-  } else if ('hastGzip' in source) {
+  } else if ('hastCompressed' in source) {
     try {
-      hast = JSON.parse(decompressHast(source.hastGzip));
+      hast = JSON.parse(decompressHast(source.hastCompressed));
     } catch (error) {
-      throw new Error(`Failed to parse hastGzip: ${JSON.stringify(error)}`);
+      throw new Error(`Failed to parse hastCompressed: ${JSON.stringify(error)}`);
     }
   } else {
     hast = source;
diff --git a/packages/docs-infra/src/pipeline/loadCodeVariant/loadCodeVariant.ts b/packages/docs-infra/src/pipeline/loadCodeVariant/loadCodeVariant.ts
index 143e7762c..0d1958e73 100644
--- a/packages/docs-infra/src/pipeline/loadCodeVariant/loadCodeVariant.ts
+++ b/packages/docs-infra/src/pipeline/loadCodeVariant/loadCodeVariant.ts
@@ -387,16 +387,16 @@ async function loadSingleFile(
         );
       }
 
-      if (options.output === 'hastGzip' && process.env.NODE_ENV === 'production') {
-        const hastGzip = await compressHastAsync(JSON.stringify(finalSource));
-        finalSource = { hastGzip };
+      if (options.output === 'hastCompressed' && process.env.NODE_ENV === 'production') {
+        const hastCompressed = await compressHastAsync(JSON.stringify(finalSource));
+        finalSource = { hastCompressed };
 
         currentMark = performanceMeasure(
           currentMark,
           { mark: 'Compressed File', measure: 'File Compression' },
           [functionName, url || fileName],
         );
-      } else if (options.output === 'hastJson' || options.output === 'hastGzip') {
+      } else if (options.output === 'hastJson' || options.output === 'hastCompressed') {
         // in development, we skip compression but still convert to JSON
         finalSource = { hastJson: JSON.stringify(finalSource) };
 
diff --git a/packages/docs-infra/src/pipeline/loadCodeVariant/transformSource.ts b/packages/docs-infra/src/pipeline/loadCodeVariant/transformSource.ts
index c275a8c2c..47d47b758 100644
--- a/packages/docs-infra/src/pipeline/loadCodeVariant/transformSource.ts
+++ b/packages/docs-infra/src/pipeline/loadCodeVariant/transformSource.ts
@@ -23,9 +23,9 @@ export async function transformSource(
           sourceString = source;
         } else if ('hastJson' in source) {
           sourceString = toText(JSON.parse(source.hastJson) as HastNodes);
-        } else if ('hastGzip' in source) {
+        } else if ('hastCompressed' in source) {
           sourceString = toText(
-            JSON.parse(await decompressHastAsync(source.hastGzip)) as HastNodes,
+            JSON.parse(await decompressHastAsync(source.hastCompressed)) as HastNodes,
           );
         } else {
           sourceString = toText(source);
diff --git a/packages/docs-infra/src/pipeline/loadPrecomputedCodeHighlighter/loadPrecomputedCodeHighlighter.ts b/packages/docs-infra/src/pipeline/loadPrecomputedCodeHighlighter/loadPrecomputedCodeHighlighter.ts
index 28fadc439..0c1e4f6fd 100644
--- a/packages/docs-infra/src/pipeline/loadPrecomputedCodeHighlighter/loadPrecomputedCodeHighlighter.ts
+++ b/packages/docs-infra/src/pipeline/loadPrecomputedCodeHighlighter/loadPrecomputedCodeHighlighter.ts
@@ -64,7 +64,7 @@ export type LoaderOptions = {
     notableMs?: number;
     showWrapperMeasures?: boolean;
   };
-  output?: 'hast' | 'hastJson' | 'hastGzip';
+  output?: 'hast' | 'hastJson' | 'hastCompressed';
   /**
    * Prefixes for comments that should be stripped from the source output.
    * Comments starting with these prefixes will be removed from the returned source.
@@ -242,7 +242,7 @@ export async function loadPrecomputedCodeHighlighter(
               sourceTransformers, // For TypeScript to JavaScript conversion
               sourceEnhancers, // For post-parsing modifications (e.g., emphasis)
               maxDepth: 5,
-              output: options.output || 'hastGzip',
+              output: options.output || 'hastCompressed',
             },
           );
 
diff --git a/packages/docs-infra/src/pipeline/loadPrecomputedTypes/loadPrecomputedTypes.ts b/packages/docs-infra/src/pipeline/loadPrecomputedTypes/loadPrecomputedTypes.ts
index 02c65b948..0f4094116 100644
--- a/packages/docs-infra/src/pipeline/loadPrecomputedTypes/loadPrecomputedTypes.ts
+++ b/packages/docs-infra/src/pipeline/loadPrecomputedTypes/loadPrecomputedTypes.ts
@@ -173,7 +173,7 @@ export async function loadPrecomputedTypes(
       ordering: options.ordering,
       descriptionReplacements: options.descriptionReplacements,
       sync: true,
-      output: 'hastGzip',
+      output: 'hastCompressed',
     });
 
     currentMark = performanceMeasure(
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts b/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
index 9dc22c4e4..5d41d7468 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/hastTypeUtils.ts
@@ -517,31 +517,31 @@ export interface SerializedHastRoot {
 }
 
 /**
- * A gzip-compressed, base64-encoded wrapper around a HastRoot.
- * Smaller than JSON for transport; decoded and decompressed at render time.
+ * A DEFLATE-compressed (with shared dictionary), base64-encoded wrapper around a HastRoot.
+ * Smaller than JSON for transport; decompressed with the matching dictionary at render time.
  */
-export interface SerializedHastGzip {
-  hastGzip: string;
+export interface SerializedHastCompressed {
+  hastCompressed: string;
 }
 
 /** Controls the output format of HAST fields in type metadata. */
-export type TypesOutputFormat = 'hast' | 'hastJson' | 'hastGzip';
+export type TypesOutputFormat = 'hast' | 'hastJson' | 'hastCompressed';
 
 /** Converts a HastRoot to a JSON-serialized wrapper. */
 export function serializeHastRoot(hast: HastRoot): SerializedHastRoot {
   return { hastJson: JSON.stringify(hast) };
 }
 
-/** Converts a HastRoot to a gzip-compressed, base64-encoded wrapper. */
-export function compressHastRoot(hast: HastRoot): SerializedHastGzip {
-  return { hastGzip: compressHast(JSON.stringify(hast)) };
+/** Converts a HastRoot to a dictionary-compressed, base64-encoded wrapper. */
+export function compressHastRoot(hast: HastRoot): SerializedHastCompressed {
+  return { hastCompressed: compressHast(JSON.stringify(hast)) };
 }
 
 /** Returns the appropriate serializer function for the given output format. */
 export function resolveSerializer(
   output: TypesOutputFormat,
-): (hast: HastRoot) => HastRoot | SerializedHastRoot | SerializedHastGzip {
-  if (output === 'hastGzip') {
+): (hast: HastRoot) => HastRoot | SerializedHastRoot | SerializedHastCompressed {
+  if (output === 'hastCompressed') {
     return compressHastRoot;
   }
   if (output === 'hastJson') {
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.test.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.test.ts
index 5c80ec240..3556cf789 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.test.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypes.test.ts
@@ -616,9 +616,9 @@ describe('highlightTypes', () => {
       const variant = precomputeData[variantName];
 
       // Handle different source formats
-      if (typeof variant.source === 'object' && variant.source.hastGzip) {
-        // Decompress the base64-encoded gzipped source
-        const decompressed = await decompressHastAsync(variant.source.hastGzip);
+      if (typeof variant.source === 'object' && variant.source.hastCompressed) {
+        // Decompress the base64-encoded compressed source
+        const decompressed = await decompressHastAsync(variant.source.hastCompressed);
         const hast = JSON.parse(decompressed);
         return { ...variant, decompressedHast: hast };
       }
@@ -694,9 +694,9 @@ describe('highlightTypes', () => {
 
         // Verify source was compressed and can be decompressed
         expect(decompressed.source).toBeDefined();
-        if (typeof decompressed.source === 'object' && 'hastGzip' in decompressed.source) {
-          expect(typeof decompressed.source.hastGzip).toBe('string');
-          expect(decompressed.source.hastGzip.length).toBeGreaterThan(0);
+        if (typeof decompressed.source === 'object' && 'hastCompressed' in decompressed.source) {
+          expect(typeof decompressed.source.hastCompressed).toBe('string');
+          expect(decompressed.source.hastCompressed.length).toBeGreaterThan(0);
         }
 
         // Snapshot the decompressed HAST structure
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
index 686081051..940341db4 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
@@ -8,9 +8,9 @@ import type {
   FunctionTypeMeta,
 } from '../loadServerTypesMeta';
 import { getHastTextContent } from './hastTypeUtils';
-import type { SerializedHastRoot, SerializedHastGzip } from './hastTypeUtils';
+import type { SerializedHastRoot, SerializedHastCompressed } from './hastTypeUtils';
 
-type HastField = HastRoot | SerializedHastRoot | SerializedHastGzip;
+type HastField = HastRoot | SerializedHastRoot | SerializedHastCompressed;
 
 /**
  * Helper to check if a highlighted property has the expected fields
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
index cd994e74b..0405c5e75 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
@@ -44,13 +44,13 @@ import {
   getHastTextContent,
   resolveSerializer,
   type SerializedHastRoot,
-  type SerializedHastGzip,
+  type SerializedHastCompressed,
   type TypesOutputFormat,
 } from './hastTypeUtils';
 import { extractTypeProps as extractTypePropsFromCode } from './extractTypeProps';
 
 /** A HAST root or its serialized/compressed wrapper. */
-type HastField = HastRoot | SerializedHastRoot | SerializedHastGzip;
+type HastField = HastRoot | SerializedHastRoot | SerializedHastCompressed;
 
 /**
  * Strips generic type arguments from a type string.
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/loadServerTypes.ts b/packages/docs-infra/src/pipeline/loadServerTypes/loadServerTypes.ts
index fa60a953d..014b054dc 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/loadServerTypes.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/loadServerTypes.ts
@@ -36,7 +36,11 @@ export type {
   HighlightedParameter,
   HighlightedClassProperty,
 };
-export type { SerializedHastRoot, SerializedHastGzip, TypesOutputFormat } from './hastTypeUtils';
+export type {
+  SerializedHastRoot,
+  SerializedHastCompressed,
+  TypesOutputFormat,
+} from './hastTypeUtils';
 
 const functionName = 'Load Server Types';
 
@@ -57,8 +61,8 @@ export interface LoadServerTypesOptions extends SyncTypesOptions {
    * - `'hast'`: Live HAST Root objects (default)
    * - `'hastJson'`: JSON-serialized `{ hastJson: string }` wrappers — defers
    *   tree allocation from module-evaluation time to render time
-   * - `'hastGzip'`: Gzip-compressed + base64-encoded `{ hastGzip: string }`
-   *   wrappers — smallest payload, decompressed at render time
+   * - `'hastCompressed'`: Dictionary-compressed + base64-encoded `{ hastCompressed: string }`
+   *   wrappers — smallest payload, decompressed with shared dictionary at render time
    *
    * @default 'hast'
    */
diff --git a/packages/docs-infra/src/pipeline/transformHtmlCodeBlock/transformHtmlCodeBlock.ts b/packages/docs-infra/src/pipeline/transformHtmlCodeBlock/transformHtmlCodeBlock.ts
index 8293582dc..fda6828bf 100644
--- a/packages/docs-infra/src/pipeline/transformHtmlCodeBlock/transformHtmlCodeBlock.ts
+++ b/packages/docs-infra/src/pipeline/transformHtmlCodeBlock/transformHtmlCodeBlock.ts
@@ -414,7 +414,7 @@ export const transformHtmlCodeBlock: Plugin = () => {
                       sourceEnhancers, // For @highlight emphasis comments
                       disableTransforms: variantData.skipTransforms || false,
                       // TODO: output option
-                      output: 'hastGzip',
+                      output: 'hastCompressed',
                     },
                   );
 
diff --git a/packages/docs-infra/src/useCode/Pre.tsx b/packages/docs-infra/src/useCode/Pre.tsx
index d18e193df..7547782cc 100644
--- a/packages/docs-infra/src/useCode/Pre.tsx
+++ b/packages/docs-infra/src/useCode/Pre.tsx
@@ -55,8 +55,8 @@ export function Pre({
       return JSON.parse(children.hastJson) as HastRoot;
     }
 
-    if ('hastGzip' in children) {
-      return JSON.parse(decompressHast(children.hastGzip)) as HastRoot;
+    if ('hastCompressed' in children) {
+      return JSON.parse(decompressHast(children.hastCompressed)) as HastRoot;
     }
 
     return children;
diff --git a/packages/docs-infra/src/useCode/useFileNavigation.tsx b/packages/docs-infra/src/useCode/useFileNavigation.tsx
index 1946ebae2..05a5db43e 100644
--- a/packages/docs-infra/src/useCode/useFileNavigation.tsx
+++ b/packages/docs-infra/src/useCode/useFileNavigation.tsx
@@ -524,8 +524,8 @@ export function useFileNavigation({
       let hastSelectedFile: HastRoot;
       if ('hastJson' in selectedFile) {
         hastSelectedFile = JSON.parse(selectedFile.hastJson);
-      } else if ('hastGzip' in selectedFile) {
-        hastSelectedFile = JSON.parse(decompressHast(selectedFile.hastGzip));
+      } else if ('hastCompressed' in selectedFile) {
+        hastSelectedFile = JSON.parse(decompressHast(selectedFile.hastCompressed));
       } else {
         hastSelectedFile = selectedFile;
       }
diff --git a/packages/docs-infra/src/useCode/useSourceEnhancing.ts b/packages/docs-infra/src/useCode/useSourceEnhancing.ts
index 0a6c30c2f..c533eca1c 100644
--- a/packages/docs-infra/src/useCode/useSourceEnhancing.ts
+++ b/packages/docs-infra/src/useCode/useSourceEnhancing.ts
@@ -23,9 +23,9 @@ function isHastRoot(source: unknown): source is HastRoot {
 
 /**
  * Resolves a VariantSource to a HastRoot if possible.
- * Handles decompression of gzipped HAST and parsing of JSON HAST.
+ * Handles decompression of compressed HAST and parsing of JSON HAST.
  *
- * @param source - The source to resolve (can be HAST, hastJson, hastGzip, or string)
+ * @param source - The source to resolve (can be HAST, hastJson, hastCompressed, or string)
  * @returns The resolved HastRoot or null if the source cannot be resolved
  */
 function resolveHastRoot(source: VariantSource | undefined): HastRoot | null {
@@ -41,8 +41,8 @@ function resolveHastRoot(source: VariantSource | undefined): HastRoot | null {
     return JSON.parse(source.hastJson) as HastRoot;
   }
 
-  if ('hastGzip' in source) {
-    return JSON.parse(decompressHast(source.hastGzip)) as HastRoot;
+  if ('hastCompressed' in source) {
+    return JSON.parse(decompressHast(source.hastCompressed)) as HastRoot;
   }
 
   if (isHastRoot(source)) {
diff --git a/packages/docs-infra/src/withDocsInfra/withDocsInfra.test.ts b/packages/docs-infra/src/withDocsInfra/withDocsInfra.test.ts
index 6e10a7a2a..3b4dd60bc 100644
--- a/packages/docs-infra/src/withDocsInfra/withDocsInfra.test.ts
+++ b/packages/docs-infra/src/withDocsInfra/withDocsInfra.test.ts
@@ -76,7 +76,7 @@ describe('withDocsInfra', () => {
           loaders: [
             {
               loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-              options: { performance: {}, output: 'hastGzip' },
+              options: { performance: {}, output: 'hastCompressed' },
             },
           ],
         },
@@ -125,7 +125,7 @@ describe('withDocsInfra', () => {
           loaders: [
             {
               loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-              options: { performance: {}, output: 'hastGzip' },
+              options: { performance: {}, output: 'hastCompressed' },
             },
           ],
         },
@@ -161,7 +161,7 @@ describe('withDocsInfra', () => {
           loaders: [
             {
               loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-              options: { performance: {}, output: 'hastGzip' },
+              options: { performance: {}, output: 'hastCompressed' },
             },
           ],
         },
@@ -191,7 +191,7 @@ describe('withDocsInfra', () => {
           loaders: [
             {
               loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-              options: { performance: {}, output: 'hastGzip' },
+              options: { performance: {}, output: 'hastCompressed' },
             },
           ],
         },
@@ -250,7 +250,7 @@ describe('withDocsInfra', () => {
           loaders: [
             {
               loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-              options: { performance: {}, output: 'hastGzip' },
+              options: { performance: {}, output: 'hastCompressed' },
             },
           ],
         },
@@ -323,7 +323,7 @@ describe('withDocsInfra', () => {
           mockDefaultLoaders.babel,
           {
             loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-            options: { performance: {}, output: 'hastGzip' },
+            options: { performance: {}, output: 'hastCompressed' },
           },
         ],
       });
@@ -528,7 +528,7 @@ describe('withDocsInfra', () => {
           loaders: [
             {
               loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-              options: { performance: {}, output: 'hastGzip' },
+              options: { performance: {}, output: 'hastCompressed' },
             },
           ],
         },
@@ -564,7 +564,7 @@ describe('withDocsInfra', () => {
           loaders: [
             {
               loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-              options: { performance: {}, output: 'hastGzip' },
+              options: { performance: {}, output: 'hastCompressed' },
             },
           ],
         },
@@ -634,7 +634,7 @@ describe('withDocsInfra', () => {
           loaders: [
             {
               loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-              options: { performance: performanceOptions, output: 'hastGzip' },
+              options: { performance: performanceOptions, output: 'hastCompressed' },
             },
           ],
         },
@@ -708,7 +708,7 @@ describe('withDocsInfra', () => {
           mockWebpackOptions.defaultLoaders.babel,
           {
             loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-            options: { performance: performanceOptions, output: 'hastGzip' },
+            options: { performance: performanceOptions, output: 'hastCompressed' },
           },
         ],
       });
@@ -760,7 +760,7 @@ describe('withDocsInfra', () => {
         loaders: [
           {
             loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-            options: { performance: performanceOptions, output: 'hastGzip' },
+            options: { performance: performanceOptions, output: 'hastCompressed' },
           },
         ],
       });
@@ -814,7 +814,7 @@ describe('withDocsInfra', () => {
 
       expect(additionalIndexRule?.use[1]?.options).toEqual({
         performance: performanceOptions,
-        output: 'hastGzip',
+        output: 'hastCompressed',
       });
       expect(additionalClientRule?.use[1]?.options).toEqual({ performance: performanceOptions });
     });
@@ -827,7 +827,7 @@ describe('withDocsInfra', () => {
         loaders: [
           {
             loader: '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter',
-            options: { performance: {}, output: 'hastGzip' },
+            options: { performance: {}, output: 'hastCompressed' },
           },
         ],
       });
diff --git a/packages/docs-infra/src/withDocsInfra/withDocsInfra.ts b/packages/docs-infra/src/withDocsInfra/withDocsInfra.ts
index 16d56da22..846a42c15 100644
--- a/packages/docs-infra/src/withDocsInfra/withDocsInfra.ts
+++ b/packages/docs-infra/src/withDocsInfra/withDocsInfra.ts
@@ -287,7 +287,7 @@ export function withDocsInfra(options: WithDocsInfraOptions = {}) {
     errorIfOutOfDate: errorIfTypesIndexOutOfDate,
   };
 
-  let output: 'hast' | 'hastJson' | 'hastGzip' = 'hastGzip';
+  let output: 'hast' | 'hastJson' | 'hastCompressed' = 'hastCompressed';
   if (deferCodeParsing === 'json') {
     output = 'hastJson';
   } else if (deferCodeParsing === 'none') {

From 13553018eb3db11aecf19a99520ec97695bc7ee7 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 14:25:43 -0400
Subject: [PATCH 15/61] Avoid recompressing

---
 .../DeferredHighlightClient.tsx               |  46 +++++--
 .../src/abstractCreateTypes/typesToJsx.ts     | 117 ++++++++++++------
 2 files changed, 112 insertions(+), 51 deletions(-)

diff --git a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
index ae09ca2da..b908c2684 100644
--- a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
+++ b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
@@ -1,14 +1,14 @@
 'use client';
 import * as React from 'react';
-import type { Root as HastRoot } from 'hast';
+import type { Root as HastRoot, Element as HastElement } from 'hast';
 import { decompressHast, hastToJsx } from '../pipeline/hastUtils';
 
 type HighlightAt = 'hydration' | 'idle';
 
 interface DeferredHighlightClientProps {
-  /** JSON-serialized array of HAST children. Used when data is not compressed. */
+  /** JSON-serialized HAST tree (root > pre > code > children). */
   hastJson?: string;
-  /** DEFLATE-compressed (with shared dictionary), base64-encoded array of HAST children. */
+  /** DEFLATE-compressed, base64-encoded HAST tree. */
   hastCompressed?: string;
   /** When to replace the fallback with the fully-highlighted version. */
   highlightAt: HighlightAt;
@@ -16,12 +16,31 @@ interface DeferredHighlightClientProps {
   children?: React.ReactNode;
 }
 
+/**
+ * Find the children of the first `` element in a parsed HAST tree.
+ */
+function findCodeChildren(node: HastRoot | HastElement): HastRoot['children'] | null {
+  if (node.type === 'element' && node.tagName === 'code') {
+    return node.children;
+  }
+  for (const child of node.children) {
+    if (child.type === 'element') {
+      const found = findCodeChildren(child as HastElement);
+      if (found) {
+        return found;
+      }
+    }
+  }
+  return null;
+}
+
 /**
  * Renders a links-only fallback on the server and replaces it with the
  * fully syntax-highlighted version on the client at the configured time.
  *
- * The component only handles the inner content of a `` element —
- * the outer `
` / `TypePre` wrapper stays server-rendered.
+ * Receives the full HAST tree (root > pre > code > children) and extracts
+ * the code element's children for rendering. The outer `
` / `TypePre`
+ * wrapper stays server-rendered.
  */
 export function DeferredHighlightClient({
   hastJson,
@@ -33,13 +52,16 @@ export function DeferredHighlightClient({
 
   React.useEffect(() => {
     const render = () => {
-      let nodes;
-      if (hastCompressed) {
-        nodes = JSON.parse(decompressHast(hastCompressed));
-      } else {
-        nodes = JSON.parse(hastJson!);
-      }
-      const hast: HastRoot = { type: 'root', children: nodes };
+      const raw = hastCompressed ? decompressHast(hastCompressed) : hastJson!;
+      const parsed = JSON.parse(raw);
+
+      // Extract code element's children from the full tree.
+      const root: HastRoot =
+        parsed.type === 'root'
+          ? parsed
+          : { type: 'root', children: Array.isArray(parsed) ? parsed : [parsed] };
+      const codeChildren = findCodeChildren(root);
+      const hast: HastRoot = { type: 'root', children: codeChildren ?? root.children };
       setHighlighted(hastToJsx(hast));
     };
 
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
index 7fddc04c8..33af7de7f 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts
@@ -2,7 +2,7 @@ import * as React from 'react';
 import type { Nodes as HastNodes, Element as HastElement } from 'hast';
 import type { PluggableList } from 'unified';
 import { unified } from 'unified';
-import { compressHast, decompressHast, hastToJsx as hastToJsxBase } from '../pipeline/hastUtils';
+import { decompressHast, hastToJsx as hastToJsxBase } from '../pipeline/hastUtils';
 import type {
   HighlightedComponentTypeMeta,
   HighlightedHookTypeMeta,
@@ -489,11 +489,50 @@ function findCodeElement(node: HastNodes): HastElement | null {
   return null;
 }
 
+/**
+ * Find the first `
` element in a HAST tree (typically root > pre).
+ */
+function findPreElement(node: HastNodes): HastElement | null {
+  if (node.type === 'element') {
+    const el = node as HastElement;
+    if (el.tagName === 'pre') {
+      return el;
+    }
+  }
+  if (node.type === 'root') {
+    for (const child of (node as HastRoot).children) {
+      if (child.type === 'element' && (child as HastElement).tagName === 'pre') {
+        return child as HastElement;
+      }
+    }
+  }
+  return null;
+}
+
+/**
+ * Convert HAST element properties to React-compatible props.
+ * Handles className array → string conversion.
+ */
+function hastPropsToReactProps(properties: Record = {}): Record {
+  const result: Record = {};
+  for (const [key, value] of Object.entries(properties)) {
+    if (key === 'className' && Array.isArray(value)) {
+      result[key] = value.join(' ');
+    } else {
+      result[key] = value;
+    }
+  }
+  return result;
+}
+
 /**
  * Deferred HAST-to-JSX conversion for expensive fields (detailedType, formattedCode).
- * Server-renders a links-only fallback inside the normal component wrappers
- * (TypePre, etc.) and injects a DeferredHighlightClient that replaces the inner
+ * Server-renders a links-only fallback inside an explicit pre > code wrapper
+ * and injects a DeferredHighlightClient that replaces the inner
  * code content with the fully-highlighted version on the client.
+ *
+ * Passes the original serialized HAST directly to the client component
+ * to avoid unnecessary re-serialization.
  */
 function hastToJsxDeferred(
   hastOrJson: SerializedHastInput,
@@ -501,8 +540,22 @@ function hastToJsxDeferred(
   enhancers: PluggableList | undefined,
   highlightAt: 'hydration' | 'idle',
 ): React.ReactNode {
-  const useCompression =
-    typeof hastOrJson === 'object' && hastOrJson !== null && 'hastCompressed' in hastOrJson;
+  // Extract the original serialized form directly — no re-serialization
+  let hastJson: string | undefined;
+  let hastCompressed: string | undefined;
+  if (typeof hastOrJson === 'object' && hastOrJson !== null) {
+    if ('hastCompressed' in hastOrJson) {
+      hastCompressed = (hastOrJson as { hastCompressed: string }).hastCompressed;
+    } else if ('hastJson' in hastOrJson) {
+      hastJson = (hastOrJson as { hastJson: string }).hastJson;
+    }
+  }
+  if (!hastJson && !hastCompressed) {
+    // Live HAST tree — must serialize
+    hastJson = JSON.stringify(hastOrJson);
+  }
+
+  // Deserialize and run enhancers for the server-side fallback
   const { hast: parsedHast, freshCopy } = deserializeHast(hastOrJson);
   let hast = parsedHast;
 
@@ -522,45 +575,31 @@ function hastToJsxDeferred(
     return hastToJsxBase(hast, components);
   }
 
-  const innerChildren = [...codeElement.children];
-
-  // Serialize inner children — use compression when the input was compressed
-  const clientProps: {
-    hastJson?: string;
-    hastCompressed?: string;
-    highlightAt: 'hydration' | 'idle';
-  } = {
-    highlightAt,
-  };
-  if (useCompression) {
-    clientProps.hastCompressed = compressHast(JSON.stringify(innerChildren));
-  } else {
-    clientProps.hastJson = JSON.stringify(innerChildren);
-  }
-
-  // Strip spans from inner children for links-only fallback
+  // Build links-only fallback from enhanced inner children
   const linksOnlyRoot = stripHighlightingSpans({
     type: 'root',
-    children: innerChildren as HastRoot['children'],
+    children: [...codeElement.children] as HastRoot['children'],
   });
   const linksOnlyJsx = hastToJsxBase(linksOnlyRoot, components);
 
-  // Empty the code element — the custom code component injects DeferredHighlightClient
-  codeElement.children = [];
-
-  const deferredComponents: ComponentMap = {
-    ...components,
-    code: (props: Record) => {
-      const { children: unusedChildren, ...codeProps } = props;
-      return React.createElement(
-        'code',
-        codeProps,
-        React.createElement(DeferredHighlightClient, clientProps, linksOnlyJsx),
-      );
-    },
-  };
-
-  return hastToJsxBase(hast, deferredComponents);
+  // Find the 
 element for wrapper props
+  const preElement = findPreElement(hast);
+  const PreComponent = (components?.pre ?? 'pre') as React.ElementType;
+
+  // Build pre > code > DeferredHighlightClient wrapper explicitly
+  return React.createElement(
+    PreComponent,
+    hastPropsToReactProps(preElement?.properties),
+    React.createElement(
+      'code',
+      hastPropsToReactProps(codeElement.properties),
+      React.createElement(
+        DeferredHighlightClient,
+        { hastJson, hastCompressed, highlightAt },
+        linksOnlyJsx,
+      ),
+    ),
+  );
 }
 
 /**

From 55aee58d4b86dccd7705c1bead9ca4604a528fc7 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 14:44:01 -0400
Subject: [PATCH 16/61] Improve impact section

---
 .../patterns/prop-compression/page.mdx        | 220 ++++++------------
 1 file changed, 76 insertions(+), 144 deletions(-)

diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx
index 32d1b25fd..228219a17 100644
--- a/docs/app/docs-infra/patterns/prop-compression/page.mdx
+++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx
@@ -49,7 +49,8 @@ We send the plain text variant of the code snippet in the initial HTML:
 
 ### 2. Serialize Minimal Props for Hydration
 
-Then, because this code is a client component, we send the props to the client in a compressed format, such as a stringified JSON object:
+Because this code lives inside a client component, we send the props in a
+compressed format, such as a stringified JSON object:
 
 ```json
 {
@@ -58,124 +59,44 @@ Then, because this code is a client component, we send the props to the client i
 }
 ```
 
-This HTML is parsed, then painted by the browser very quickly, and when the JS
-downloads, hydrates the entire page very quickly. This requires us to have two
-copies of the plain text code in the HTML. This doubles the cost of code for
-uncompressed HTML, but compression substantially reduces the duplicate transfer
-cost in practice.
+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
 
-We can highlight the code, even after already streaming the plain text code back to the client:
+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:
 
 ```json
 {
   "highlightedCode": {
     "type": "root",
     "children": [
-      {
-        "type": "element",
-        "tagName": "span",
-        "properties": {
-          "className": ["pl-en"]
-        },
-        "children": [
-          {
-            "type": "text",
-            "value": "console"
-          }
-        ]
-      },
-      {
-        "type": "text",
-        "value": "."
-      },
-      {
-        "type": "element",
-        "tagName": "span",
-        "properties": {
-          "className": ["pl-c1"]
-        },
-        "children": [
-          {
-            "type": "text",
-            "value": "log"
-          }
-        ]
-      },
-      {
-        "type": "text",
-        "value": "("
-      },
-      {
-        "type": "element",
-        "tagName": "span",
-        "properties": {
-          "className": ["pl-s"]
-        },
-        "children": [
-          {
-            "type": "element",
-            "tagName": "span",
-            "properties": {
-              "className": ["pl-pds"]
-            },
-            "children": [
-              {
-                "type": "text",
-                "value": "'"
-              }
-            ]
-          },
-          {
-            "type": "text",
-            "value": "Hello, world!"
-          },
-          {
-            "type": "element",
-            "tagName": "span",
-            "properties": {
-              "className": ["pl-pds"]
-            },
-            "children": [
-              {
-                "type": "text",
-                "value": "'"
-              }
-            ]
-          }
-        ]
-      },
-      {
-        "type": "text",
-        "value": ");"
-      }
+      { "type": "element", "tagName": "span", "properties": { "className": ["pl-en"] },
+        "children": [{ "type": "text", "value": "console" }] },
+      { "type": "text", "value": "." },
+      { "type": "element", "tagName": "span", "properties": { "className": ["pl-c1"] },
+        "children": [{ "type": "text", "value": "log" }] },
+      ...
     ]
   }
 }
 ```
 
-This added structured data is 704 bytes, compared to the plain text version of
-the code, which is 87 bytes (7.1x larger). An HTML string of this highlighted
-code would be smaller at 193 bytes, but rendering it safely often requires
-shipping extra parsing logic.
+For `console.log('Hello, world!')`, this HAST tree is 704 bytes — 7.1× larger
+than the 87-byte plain text. An HTML string would be 193 bytes, but rendering
+it safely often requires shipping extra parsing logic.
 
-To improve hydration time, we defer parsing by compressing the highlighted code
-as a JSON object, and only parsing it when we need to render the highlighted
-code. We can do this with an `IntersectionObserver`, which parses highlighted
-data only when the snippet enters the viewport.
+We compress the HAST with gzip and defer parsing until the snippet enters the
+viewport via an `IntersectionObserver`. The compressed payload is 320 bytes
+(2.7× larger than plain text, 66% larger than the HTML string).
 
-This approach intentionally relies on the browser's built-in JSON parser
-(`JSON.parse`) instead of shipping a separate HTML parser. In practice, this is
-simpler to reason about, avoids parser bundle cost, and keeps behavior
-predictable across clients because JSON parsing is part of the JavaScript
-runtime.
-
-320 bytes gzipped (2.7x larger than plain text, 66% larger than the HTML string).
-
-```
-H4sIAKYqx2kCA62TT2vDMAzFv8qmyzbIBrlm540dyi47dj2IWG0DqmVs9R8l371WW0opJLQlJ9vvyU8/gb0D3QaCCqKIQgH1vGEXyUM1PlvEtCBvruLsFxempYA+CyFKoKgNJah2UDOmdCqYRlsLcKj4bfs/xaijxmezvJC/vDuJ7YDd2SKPXUY5bcjsMQR+zzGTjkyljQWukJd2rMUnYYI213fVfMCF9zBTXd7KxDLr5XkdhCd14jwcGVx36NUML70T/hCzFE9rieyeB5n2XrQeuLfPXvZ/f7h/fN2GoqLI9okyWNm2e7/QiqvTAwAA
-```
+This approach relies on the browser's built-in `JSON.parse` instead of shipping
+a separate HTML parser — simpler to reason about, no parser bundle cost, and
+predictable behavior across clients.
 
 ---
 
@@ -197,7 +118,7 @@ outside of React.
 
 ### Large Snippet
 
-To give another example where we have [a large code snippet](https://github.com/mui/mui-public/blob/530ec94f97bfe64ae018a9fd8ff0c326cbd61298/docs/app/bench/docs-infra/components/code-highlighter/snippets/large/snippet.ts):
+With [a large code snippet](https://github.com/mui/mui-public/blob/530ec94f97bfe64ae018a9fd8ff0c326cbd61298/docs/app/bench/docs-infra/components/code-highlighter/snippets/large/snippet.ts), the savings become significant:
 
 | Scenario             | Initial HTML | Initial Props | Suspended Props | Total Weight | %    |
 | -------------------- | ------------ | ------------- | --------------- | ------------ | ---- |
@@ -206,10 +127,51 @@ To give another example where we have [a large code snippet](https://github.com/
 | HTML String in Props | 203 KB       | 203 KB        | 0 bytes         | = 406 KB     | 864% |
 | Compressed Props     | 47 KB        | 47 KB         | 54 KB           | = 149 KB     | 317% |
 
-With compressed props, we get a significant reduction in total weight when
-sending highlighted code to client components.
+---
+
+## Measured Impact
+
+### Core Web Vitals (Base UI Demos)
+
+When this pattern was applied to the
+[Base UI docs](https://github.com/mui/base-ui/pull/2443), profiling showed
+significant improvements:
+
+| Metric                    | Before | After | Change        |
+| ------------------------- | ------ | ----- | ------------- |
+| FCP (Menubar page)        | 0.27s  | 0.21s | -22%          |
+| HTML parse time           | 18.4ms | 7.8ms | -58%          |
+| HTML + CSS render time    | 43ms   | 27ms  | -37%          |
+| LCP (Menu page, 6 demos)  | 0.43s  | 0.33s | -23%          |
+| Total Blocking Time       | N/A    | 0s    | No long tasks |
+
+By sending only plain text in the initial HTML, the browser parses fewer DOM
+nodes and skips complex `` 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.
 
-### When Not to Use This Pattern
+### Page Weight (Type Documentation)
+
+Applying the same pattern to type documentation (prop tables, detailed type
+signatures) shows a different profile. Type pages contain many smaller
+highlighted fragments spread across dozens of props, so even modest per-prop
+savings compound quickly.
+
+| Page                        | Uncompressed HTML | Compressed HTML |
+| --------------------------- | ----------------- | --------------- |
+| Base UI Combobox            | -29% (-765 KB)    | +10% (+26 KB)   |
+| docs-infra CodeHighlighter  | -21% (-264 KB)    | +1% (+1 KB)     |
+
+The Combobox page previously exceeded the practical 2 MB crawl-budget limit;
+after compression it fits comfortably under it. The tradeoff is that compressed
+props cannot be further compressed within the HTML transfer encoding, so the
+gzipped size increases slightly. For pages already well within budget, the
+compressed HTML increase is negligible.
+
+---
+
+## 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
@@ -223,9 +185,11 @@ in the server-rendered markup.
 
 ---
 
-## Decompression & Rendering
+## Implementation Details
+
+### Decompression & Rendering
 
-After the component enters the viewport, we can decompress and parse the JSON
+After the component enters the viewport, we decompress and parse the JSON
 object to load a [HAST](../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
@@ -244,9 +208,7 @@ 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
+### How Compressed Data Arrives
 
 Compressed props need to be produced somewhere. The
 [Built Factories](../built-factories/page.mdx) pattern solves this: a
@@ -256,14 +218,12 @@ The
 [`loadPrecomputedCodeHighlighter`](../../pipeline/load-precomputed-code-highlighter/page.mdx)
 loader's `hastCompressed` output mode is a concrete implementation of this.
 
-At runtime, the factory receives precomputed data through its options - no
+At runtime, the factory receives precomputed data through its options — 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
+### Abstracting Complexity from Users
 
 The [Props Context Layering](../props-context-layering/page.mdx) pattern keeps
 this compression logic out of consumer code. Props carry the precomputed
@@ -299,34 +259,6 @@ This is roughly how the [`CodeHighlighter`](../../components/code-highlighter/pa
 
 ---
 
-## Measured Impact
-
-When this pattern was applied to the
-[Base UI docs](https://github.com/mui/base-ui/pull/2443), profiling showed
-significant improvements to core web vitals:
-
-| Metric                   | Before | After | Change        |
-| ------------------------ | ------ | ----- | ------------- |
-| FCP (Menubar page)       | 0.27s  | 0.21s | -22%          |
-| HTML parse time          | 18.4ms | 7.8ms | -58%          |
-| HTML + CSS render time   | 43ms   | 27ms  | -37%          |
-| LCP (Menu page, 6 demos) | 0.43s  | 0.33s | -23%          |
-| Total Blocking Time      | N/A    | 0s    | No long tasks |
-
-By sending only plain text in the initial HTML, the browser parses fewer DOM
-nodes and skips complex `` styling entirely. Rendering plain text without
-syntax-highlighted class names is inherently cheaper for both the HTML parser
-and the CSS engine.
-
-Total Blocking Time remains at zero because plain text keeps the initial render
-cheap, and deferred highlighted payloads let pages with many code blocks
-enhance them incrementally during idle time instead of front-loading all
-highlighting work into initial hydration. This allows demo components to
-hydrate and become interactive before any code is highlighted, while still
-progressively improving readability as highlighted blocks become eligible.
-
----
-
 ## Real-World Usage
 
 This pattern is used throughout the docs-infra system:

From 41e6ec4b47401180bf94e91c7ec58522b4822556 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 15:55:13 -0400
Subject: [PATCH 17/61] Add CodeComponentsContext

---
 docs/app/docs-infra/hooks/page.mdx            |  3 +-
 docs/app/docs-infra/hooks/use-code/types.md   | 14 ++++++++
 docs/app/docs-infra/layout.tsx                | 33 ++++++++++---------
 docs/app/docs-infra/patterns/page.mdx         | 11 ++++---
 docs/code-components.tsx                      | 20 +++++++++++
 packages/docs-infra/package.json              |  2 +-
 .../DeferredHighlightClient.tsx               | 23 ++++++++-----
 .../src/abstractCreateTypes/typesToJsx.ts     | 32 +++++-------------
 .../src/useCode/CodeComponentsContext.tsx     | 11 +++++++
 packages/docs-infra/src/useCode/index.ts      |  1 +
 10 files changed, 98 insertions(+), 52 deletions(-)
 create mode 100644 docs/code-components.tsx
 create mode 100644 packages/docs-infra/src/useCode/CodeComponentsContext.tsx

diff --git a/docs/app/docs-infra/hooks/page.mdx b/docs/app/docs-infra/hooks/page.mdx
index be7cb1a4e..2f12f6015 100644
--- a/docs/app/docs-infra/hooks/page.mdx
+++ b/docs/app/docs-infra/hooks/page.mdx
@@ -82,7 +82,8 @@ The `useCode` hook provides programmatic access to code display, editing, and tr
 - Exports:
   - useCode
     - Parameters: contentProps, opts
-- Types: UseCodeOpts, UseCodeResult
+  - useCodeComponents
+- Types: CodeComponentsContext, UseCodeOpts, UseCodeResult
 
 
 
diff --git a/docs/app/docs-infra/hooks/use-code/types.md b/docs/app/docs-infra/hooks/use-code/types.md
index d170773d9..ecf09e0a4 100644
--- a/docs/app/docs-infra/hooks/use-code/types.md
+++ b/docs/app/docs-infra/hooks/use-code/types.md
@@ -19,8 +19,22 @@
 type ReturnValue = UseCodeResult<{}>;
 ```
 
+### useCodeComponents
+
+**useCodeComponents Return Value:**
+
+```tsx
+type ReturnValue = Partial | undefined;
+```
+
 ## Additional Types
 
+### CodeComponentsContext
+
+```typescript
+type CodeComponentsContext = React.Context | undefined>;
+```
+
 ### UseCodeOpts
 
 ```typescript
diff --git a/docs/app/docs-infra/layout.tsx b/docs/app/docs-infra/layout.tsx
index deb51a8da..81c4ea890 100644
--- a/docs/app/docs-infra/layout.tsx
+++ b/docs/app/docs-infra/layout.tsx
@@ -3,6 +3,7 @@ import type { Metadata } from 'next';
 import Link from 'next/link';
 import { TypesDataProvider } from '@mui/internal-docs-infra/useType';
 import { Navigation } from '@/components/Navigation';
+import { CodeComponentsProvider } from '@/code-components';
 import styles from '../layout.module.css';
 import { sitemap } from '../sitemap';
 import { Search } from '../search';
@@ -19,24 +20,26 @@ export default function RootLayout({
   children: React.ReactNode;
 }>) {
   return (
-    
-      
-
-
- MUI Docs Infra - + + +
+
+
+ MUI Docs Infra + +
-
-
- -
-
- +
+ +
+
+ +
+
{children}
-
{children}
-
- + + ); } diff --git a/docs/app/docs-infra/patterns/page.mdx b/docs/app/docs-infra/patterns/page.mdx index 63c161da2..1f186d41c 100644 --- a/docs/app/docs-infra/patterns/page.mdx +++ b/docs/app/docs-infra/patterns/page.mdx @@ -114,11 +114,14 @@ HAST is the core data structure used throughout this package for syntax highligh - Size Comparison - Small Snippet - Large Snippet - - When Not to Use This Pattern - - Decompression & Rendering - - How Compressed Data Arrives - - Abstracting Complexity from Users - Measured Impact + - Core Web Vitals (Base UI Demos) + - Page Weight (Type Documentation) + - When Not to Use This Pattern + - Implementation Details + - Decompression & Rendering + - How Compressed Data Arrives + - Abstracting Complexity from Users - Real-World Usage diff --git a/docs/code-components.tsx b/docs/code-components.tsx new file mode 100644 index 000000000..4ffdbc87e --- /dev/null +++ b/docs/code-components.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import { CodeComponentsContext } from '@mui/internal-docs-infra/useCode'; +import type { MDXComponents } from 'mdx/types'; +import { TypeRef } from './components/TypeRef'; +import { TypePropRef } from './components/TypePropRef'; + +export const codeComponents: MDXComponents = { + TypeRef, + TypePropRef, +}; + +export function CodeComponentsProvider({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/docs-infra/package.json b/packages/docs-infra/package.json index ad223bc33..51cbca7e4 100644 --- a/packages/docs-infra/package.json +++ b/packages/docs-infra/package.json @@ -1,6 +1,6 @@ { "name": "@mui/internal-docs-infra", - "version": "0.7.0", + "version": "0.8.0", "author": "MUI Team", "description": "MUI Infra - internal documentation creation tools.", "bin": { diff --git a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx index b908c2684..30b86669f 100644 --- a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx +++ b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import type { Root as HastRoot, Element as HastElement } from 'hast'; import { decompressHast, hastToJsx } from '../pipeline/hastUtils'; +import { useCodeComponents } from '../useCode/CodeComponentsContext'; type HighlightAt = 'hydration' | 'idle'; @@ -48,10 +49,11 @@ export function DeferredHighlightClient({ highlightAt, children, }: DeferredHighlightClientProps) { - const [highlighted, setHighlighted] = React.useState(null); + const components = useCodeComponents(); + const [hast, setHast] = React.useState(null); React.useEffect(() => { - const render = () => { + const parse = () => { const raw = hastCompressed ? decompressHast(hastCompressed) : hastJson!; const parsed = JSON.parse(raw); @@ -61,23 +63,28 @@ export function DeferredHighlightClient({ ? parsed : { type: 'root', children: Array.isArray(parsed) ? parsed : [parsed] }; const codeChildren = findCodeChildren(root); - const hast: HastRoot = { type: 'root', children: codeChildren ?? root.children }; - setHighlighted(hastToJsx(hast)); + const hastRoot: HastRoot = { type: 'root', children: codeChildren ?? root.children }; + setHast(hastRoot); }; if (highlightAt === 'hydration') { - render(); + parse(); return undefined; } // 'idle' — defer until the browser is idle if (typeof requestIdleCallback !== 'undefined') { - const id = requestIdleCallback(render); + const id = requestIdleCallback(parse); return () => cancelIdleCallback(id); } - const id = setTimeout(render, 0); + const id = setTimeout(parse, 0); return () => clearTimeout(id); - }, [hastJson, hastCompressed, highlightAt]); + }, [hastJson, hastCompressed, highlightAt, components]); + + const highlighted = React.useMemo( + () => (hast !== null ? hastToJsx(hast, components) : null), + [hast, components], + ); if (highlighted !== null) { return highlighted; diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts index 33af7de7f..c96ece958 100644 --- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts +++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.ts @@ -2,7 +2,7 @@ import * as React from 'react'; import type { Nodes as HastNodes, Element as HastElement } from 'hast'; import type { PluggableList } from 'unified'; import { unified } from 'unified'; -import { decompressHast, hastToJsx as hastToJsxBase } from '../pipeline/hastUtils'; +import { compressHast, decompressHast, hastToJsx as hastToJsxBase } from '../pipeline/hastUtils'; import type { HighlightedComponentTypeMeta, HighlightedHookTypeMeta, @@ -540,26 +540,11 @@ function hastToJsxDeferred( enhancers: PluggableList | undefined, highlightAt: 'hydration' | 'idle', ): React.ReactNode { - // Extract the original serialized form directly — no re-serialization - let hastJson: string | undefined; - let hastCompressed: string | undefined; - if (typeof hastOrJson === 'object' && hastOrJson !== null) { - if ('hastCompressed' in hastOrJson) { - hastCompressed = (hastOrJson as { hastCompressed: string }).hastCompressed; - } else if ('hastJson' in hastOrJson) { - hastJson = (hastOrJson as { hastJson: string }).hastJson; - } - } - if (!hastJson && !hastCompressed) { - // Live HAST tree — must serialize - hastJson = JSON.stringify(hastOrJson); - } - - // Deserialize and run enhancers for the server-side fallback + // Deserialize and run enhancers to produce the enhanced HAST const { hast: parsedHast, freshCopy } = deserializeHast(hastOrJson); let hast = parsedHast; - // Run enhancers (adds links etc.) + // Run enhancers (adds TypeRef links, inline code, etc.) if (enhancers && enhancers.length > 0) { const input = freshCopy ? hast : structuredClone(hast); const processor = getOrCreateProcessor(enhancers); @@ -575,6 +560,11 @@ function hastToJsxDeferred( return hastToJsxBase(hast, components); } + // Serialize the enhanced HAST (post-enhancer) for the client. + // Compress when possible to reduce serialized prop size. + const enhancedJson = JSON.stringify(hast); + const hastCompressed = compressHast(enhancedJson); + // Build links-only fallback from enhanced inner children const linksOnlyRoot = stripHighlightingSpans({ type: 'root', @@ -593,11 +583,7 @@ function hastToJsxDeferred( React.createElement( 'code', hastPropsToReactProps(codeElement.properties), - React.createElement( - DeferredHighlightClient, - { hastJson, hastCompressed, highlightAt }, - linksOnlyJsx, - ), + React.createElement(DeferredHighlightClient, { hastCompressed, highlightAt }, linksOnlyJsx), ), ); } diff --git a/packages/docs-infra/src/useCode/CodeComponentsContext.tsx b/packages/docs-infra/src/useCode/CodeComponentsContext.tsx new file mode 100644 index 000000000..1608f55d5 --- /dev/null +++ b/packages/docs-infra/src/useCode/CodeComponentsContext.tsx @@ -0,0 +1,11 @@ +'use client'; +import * as React from 'react'; +import type { Components } from 'hast-util-to-jsx-runtime'; + +type CodeComponents = Partial; + +export const CodeComponentsContext = React.createContext(undefined); + +export function useCodeComponents(): CodeComponents | undefined { + return React.useContext(CodeComponentsContext); +} diff --git a/packages/docs-infra/src/useCode/index.ts b/packages/docs-infra/src/useCode/index.ts index fc4d6e801..e0bd8e11f 100644 --- a/packages/docs-infra/src/useCode/index.ts +++ b/packages/docs-infra/src/useCode/index.ts @@ -1 +1,2 @@ +export * from './CodeComponentsContext'; export * from './useCode'; From af0d1ef2a14ebe5999f97077e8cac92292dd8905 Mon Sep 17 00:00:00 2001 From: dav-is Date: Tue, 31 Mar 2026 16:29:59 -0400 Subject: [PATCH 18/61] Fix frame detection --- .../stripHighlightingSpans.test.ts | 32 +++++++++++++++++++ .../stripHighlightingSpans.ts | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts index 85f4286a4..cca7c211e 100644 --- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts +++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts @@ -368,6 +368,38 @@ describe('stripHighlightingSpans', () => { expect(frame.children).toEqual([{ type: 'text', value: 'const x = 1' }]); }); + it('should preserve frame spans when className is a string (addLineGutters format)', () => { + const root: HastRoot = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'span', + properties: { + className: 'frame', + dataFrameStartLine: 1, + dataFrameEndLine: 3, + }, + children: [ + { + type: 'element', + tagName: 'span', + properties: { className: ['pl-en'] }, + children: [{ type: 'text', value: 'type' }], + }, + { type: 'text', value: ' Foo = {}' }, + ], + }, + ], + }; + const result = stripHighlightingSpans(root); + const frame = result.children[0] as HastElement; + expect(frame.tagName).toBe('span'); + expect(frame.properties?.className).toBe('frame'); + expect(frame.properties?.dataFrameStartLine).toBe(1); + expect(frame.children).toEqual([{ type: 'text', value: 'type Foo = {}' }]); + }); + it('should strip line spans but preserve their content', () => { const root: HastRoot = { type: 'root', diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts index 87c615849..8b6f3c894 100644 --- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts +++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts @@ -22,7 +22,7 @@ export function stripHighlightingSpans(root: HastRoot): HastRoot { function isFrameSpan(element: HastElement): boolean { const className = element.properties?.className; - return Array.isArray(className) && className.includes('frame'); + return className === 'frame' || (Array.isArray(className) && className.includes('frame')); } function processChildren(children: RootContent[]): RootContent[] { From 6ee1ea8b4f4bacd6d364d0dd7340cf6fb887ba34 Mon Sep 17 00:00:00 2001 From: dav-is Date: Tue, 31 Mar 2026 16:32:10 -0400 Subject: [PATCH 19/61] Prettier --- .../patterns/prop-compression/page.mdx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx index 228219a17..8fbb1e89f 100644 --- a/docs/app/docs-infra/patterns/prop-compression/page.mdx +++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx @@ -137,13 +137,13 @@ When this pattern was applied to the [Base UI docs](https://github.com/mui/base-ui/pull/2443), profiling showed significant improvements: -| Metric | Before | After | Change | -| ------------------------- | ------ | ----- | ------------- | -| FCP (Menubar page) | 0.27s | 0.21s | -22% | -| HTML parse time | 18.4ms | 7.8ms | -58% | -| HTML + CSS render time | 43ms | 27ms | -37% | -| LCP (Menu page, 6 demos) | 0.43s | 0.33s | -23% | -| Total Blocking Time | N/A | 0s | No long tasks | +| Metric | Before | After | Change | +| ------------------------ | ------ | ----- | ------------- | +| FCP (Menubar page) | 0.27s | 0.21s | -22% | +| HTML parse time | 18.4ms | 7.8ms | -58% | +| HTML + CSS render time | 43ms | 27ms | -37% | +| LCP (Menu page, 6 demos) | 0.43s | 0.33s | -23% | +| Total Blocking Time | N/A | 0s | No long tasks | By sending only plain text in the initial HTML, the browser parses fewer DOM nodes and skips complex `` styling entirely. Total Blocking Time remains @@ -158,10 +158,10 @@ signatures) shows a different profile. Type pages contain many smaller highlighted fragments spread across dozens of props, so even modest per-prop savings compound quickly. -| Page | Uncompressed HTML | Compressed HTML | -| --------------------------- | ----------------- | --------------- | -| Base UI Combobox | -29% (-765 KB) | +10% (+26 KB) | -| docs-infra CodeHighlighter | -21% (-264 KB) | +1% (+1 KB) | +| Page | Uncompressed HTML | Compressed HTML | +| -------------------------- | ----------------- | --------------- | +| Base UI Combobox | -29% (-765 KB) | +10% (+26 KB) | +| docs-infra CodeHighlighter | -21% (-264 KB) | +1% (+1 KB) | The Combobox page previously exceeded the practical 2 MB crawl-budget limit; after compression it fits comfortably under it. The tradeoff is that compressed From 9e50c1b224af7f72bfa25d84fc8414591294fb90 Mon Sep 17 00:00:00 2001 From: dav-is Date: Tue, 31 Mar 2026 17:08:27 -0400 Subject: [PATCH 20/61] Remove data-frame-start-line, data-frame-end-line, and data-frame attrs --- .../stripHighlightingSpans.test.ts | 11 +- .../enhanceCodeEmphasis.test.ts | 140 ++++++++---------- .../src/pipeline/hastUtils/hastCompression.ts | 2 - .../__snapshots__/highlightTypes.test.ts.snap | 12 -- .../loadServerTypes/extractTypeProps.test.ts | 2 - .../__snapshots__/highlightTypes.test.ts.snap | 12 -- .../parseSource/addLineGutters.test.ts | 20 --- .../pipeline/parseSource/addLineGutters.ts | 25 ++-- .../src/pipeline/parseSource/createFrame.ts | 7 - .../parseSource/restructureFrames.test.ts | 10 -- .../pipeline/parseSource/restructureFrames.ts | 4 +- packages/docs-infra/src/useCode/Pre.tsx | 19 ++- 12 files changed, 90 insertions(+), 174 deletions(-) diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts index cca7c211e..07ac05170 100644 --- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts +++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts @@ -232,7 +232,7 @@ describe('stripHighlightingSpans', () => { { type: 'element', tagName: 'span', - properties: { className: ['frame'], dataFrameStartLine: 1, dataFrameEndLine: 3 }, + properties: { className: ['frame'] }, children: [ { type: 'element', @@ -316,7 +316,7 @@ describe('stripHighlightingSpans', () => { { type: 'element', tagName: 'span', - properties: { className: ['frame'], dataFrameStartLine: 1, dataFrameEndLine: 3 }, + properties: { className: ['frame'] }, children: [ { type: 'text', value: 'type Props = {\n disabled: ' }, { @@ -340,8 +340,6 @@ describe('stripHighlightingSpans', () => { tagName: 'span', properties: { className: ['frame'], - dataFrameStartLine: 1, - dataFrameEndLine: 5, dataFrameType: 'highlighted', }, children: [ @@ -361,8 +359,6 @@ describe('stripHighlightingSpans', () => { expect(frame.tagName).toBe('span'); expect(frame.properties).toEqual({ className: ['frame'], - dataFrameStartLine: 1, - dataFrameEndLine: 5, dataFrameType: 'highlighted', }); expect(frame.children).toEqual([{ type: 'text', value: 'const x = 1' }]); @@ -377,8 +373,6 @@ describe('stripHighlightingSpans', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 3, }, children: [ { @@ -396,7 +390,6 @@ describe('stripHighlightingSpans', () => { const frame = result.children[0] as HastElement; expect(frame.tagName).toBe('span'); expect(frame.properties?.className).toBe('frame'); - expect(frame.properties?.dataFrameStartLine).toBe(1); expect(frame.children).toEqual([{ type: 'text', value: 'type Foo = {}' }]); }); diff --git a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts index 0e06256e5..c63b17503 100644 --- a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts +++ b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts @@ -76,10 +76,10 @@ describe('enhanceCodeEmphasis', () => { ); expect(result).toMatchInlineSnapshot(` - "export default function Button() { + "export default function Button() { return ( - <button className="primary">Click me</button> - ); + <button className="primary">Click me</button> + ); }" `); }); @@ -96,10 +96,10 @@ const e = 5; // @highlight`, expect(result).toMatchInlineSnapshot( ` - "const a = 1; - const b = 2; - const c = 3; - const d = 4; + "const a = 1; + const b = 2; + const c = 3; + const d = 4; " `, ); @@ -115,9 +115,9 @@ const e = 5; // @highlight`, ); expect(result).toMatchInlineSnapshot(` - "export default function Component() { - const [count, setCount] = useState(0); - return <div>{count}</div>; + "export default function Component() { + const [count, setCount] = useState(0); + return <div>{count}</div>; }" `); }); @@ -132,9 +132,9 @@ const e = 5; // @highlight`, ); expect(result).toMatchInlineSnapshot(` - "export default function Component() { - const url = getUrl(); - return <a href={url}>Link</a>; + "export default function Component() { + const url = getUrl(); + return <a href={url}>Link</a>; }" `); }); @@ -157,13 +157,13 @@ const e = 5; // @highlight`, ); expect(result).toMatchInlineSnapshot(` - "export default function Component() { + "export default function Component() { return ( - <div> + <div> <h1>Heading 1</h1> <p>Some content</p> </div> - ); + ); }" `); }); @@ -179,7 +179,7 @@ const e = 5; // @highlight`, ); expect(result).toMatchInlineSnapshot(` - "function test() { + "function test() { return null; }" `); @@ -198,9 +198,9 @@ const e = 5; // @highlight`, expect(result).toMatchInlineSnapshot( ` - "function test() { - return null; - }" + "function test() { + return null; + }" `, ); }); @@ -220,12 +220,12 @@ const e = 5; // @highlight`, ); expect(result).toMatchInlineSnapshot(` - "export default function Component() { + "export default function Component() { return ( - <div> + <div> <h1>Heading 1</h1> </div> - ); + ); }" `); }); @@ -247,13 +247,13 @@ const e = 5; // @highlight`, ); expect(result).toMatchInlineSnapshot(` - "export default function Component() { - return ( + "export default function Component() { + return ( <div> <h1>Heading 1</h1> </div> ); - }" + }" `); }); }); @@ -272,11 +272,11 @@ const e = 5; // @highlight`, ); expect(result).toMatchInlineSnapshot(` - "export default function Component() { + "export default function Component() { return ( <div> - <h1>Heading 1</h1> - </div> + <h1>Heading 1</h1> + </div> ); }" `); @@ -299,11 +299,11 @@ const another = 99; // @highlight`, ); expect(result).toMatchInlineSnapshot(` - "const value = 42; - function example() { - const x = 1; + "const value = 42; + function example() { + const x = 1; const y = 2; - return x + y; + return x + y; } " `); @@ -321,7 +321,7 @@ const c = 3;`, ); expect(result).toMatchInlineSnapshot(` - "const a = 1; + "const a = 1; const b = 2; const c = 3;" `); @@ -337,7 +337,7 @@ const c = 3;`, ); expect(result).toMatchInlineSnapshot(` - "const a = 1; + "const a = 1; const b = 2; const c = 3;" `); @@ -352,7 +352,7 @@ const c = 3;`, ); expect(result).toMatchInlineSnapshot(` - "const a = 1; + "const a = 1; const b = 2; const c = 3;" `); @@ -367,7 +367,7 @@ const b = 2;`, // Should not add any emphasis since there's no quoted text expect(result).toMatchInlineSnapshot(` - "const a = 1; + "const a = 1; const b = 2;" `); }); @@ -388,11 +388,11 @@ const b = 2;`, ); expect(result).toMatchInlineSnapshot(` - "export default function Component() { + "export default function Component() { return ( <div> - <h1>Heading 1</h1> - <p>Content</p> + <h1>Heading 1</h1> + <p>Content</p> </div> ); }" @@ -418,15 +418,15 @@ const b = 2;`, ); expect(result).toMatchInlineSnapshot(` - "export default function Dashboard() { - const [data, setData] = useState([]); - return ( + "export default function Dashboard() { + const [data, setData] = useState([]); + return ( <div> <Header /> - <Chart data={data} /> + <Chart data={data} /> <Table data={data} /> <Footer /> - </div> + </div> ); }" `); @@ -577,16 +577,10 @@ const g = 7;`, expect(result).toContain('data-frame-type="highlighted"'); expect(result).toContain('data-frame-type="padding-bottom"'); - // Verify frame boundaries - expect(result).toMatch( - /data-frame-start-line="2" data-frame-end-line="3" data-frame-type="padding-top"/, - ); - expect(result).toMatch( - /data-frame-start-line="4" data-frame-end-line="4" data-frame-type="highlighted"/, - ); - expect(result).toMatch( - /data-frame-start-line="5" data-frame-end-line="6" data-frame-type="padding-bottom"/, - ); + // Verify frame boundaries via data-frame-type + expect(result).toContain('data-frame-type="padding-top"'); + expect(result).toContain('data-frame-type="highlighted"'); + expect(result).toContain('data-frame-type="padding-bottom"'); }); it('should limit total focus area with focusFramesMaxSize', async () => { @@ -617,15 +611,9 @@ const l = 12;`, // focusFramesMaxSize = 5, so remaining for padding = 5 - 3 = 2 // floor(2/2)=1 top, ceil(2/2)=1 bottom // Even though paddingFrameMaxSize is 5, focusFramesMaxSize caps it - expect(result).toMatch( - /data-frame-start-line="5" data-frame-end-line="5" data-frame-type="padding-top"/, - ); - expect(result).toMatch( - /data-frame-start-line="6" data-frame-end-line="8" data-frame-type="highlighted"/, - ); - expect(result).toMatch( - /data-frame-start-line="9" data-frame-end-line="9" data-frame-type="padding-bottom"/, - ); + expect(result).toContain('data-frame-type="padding-top"'); + expect(result).toContain('data-frame-type="highlighted"'); + expect(result).toContain('data-frame-type="padding-bottom"'); }); it('should not add padding when paddingFrameMaxSize is 0', async () => { @@ -669,20 +657,12 @@ const e = 5;`, // Two highlight regions: line 1 and line 4 // @focus is on line 4, so padding goes around line 4 // Padding top: line 3, padding bottom: line 5 - expect(result).toMatch( - /data-frame-start-line="3" data-frame-end-line="3" data-frame-type="padding-top"/, - ); - expect(result).toMatch( - /data-frame-start-line="4" data-frame-end-line="4" data-frame-type="highlighted"/, - ); - expect(result).toMatch( - /data-frame-start-line="5" data-frame-end-line="5" data-frame-type="padding-bottom"/, - ); + expect(result).toContain('data-frame-type="padding-top"'); + expect(result).toContain('data-frame-type="highlighted"'); + expect(result).toContain('data-frame-type="padding-bottom"'); // Line 1 still highlighted but no padding around it (unfocused) - expect(result).toMatch( - /data-frame-start-line="1" data-frame-end-line="1" data-frame-type="highlighted-unfocused"/, - ); + expect(result).toContain('data-frame-type="highlighted-unfocused"'); }); it('should support @focus on @highlight-start', async () => { @@ -725,17 +705,13 @@ const d = 4;`, ); // @focus on line 3 with description "important line" - expect(result).toMatch( - /data-frame-start-line="3" data-frame-end-line="3" data-frame-type="highlighted"/, - ); + expect(result).toContain('data-frame-type="highlighted"'); expect(result).toContain('data-hl-description="Important line"'); expect(result).toContain('data-frame-type="padding-top"'); expect(result).toContain('data-frame-type="padding-bottom"'); // Line 1 is highlighted-unfocused - expect(result).toMatch( - /data-frame-start-line="1" data-frame-end-line="1" data-frame-type="highlighted-unfocused"/, - ); + expect(result).toContain('data-frame-type="highlighted-unfocused"'); }); }); diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts index 5c4ae516b..c8e404720 100644 --- a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts +++ b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts @@ -64,8 +64,6 @@ const HAST_DICTIONARY = strToU8( // Frame & line structure '"className":["frame"]', '"className":["line"]', - '"dataFrameStartLine":', - '"dataFrameEndLine":', '"dataFrameType":"highlighted"', '"dataFrameIndent":', '"dataLn":', diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/__snapshots__/highlightTypes.test.ts.snap b/packages/docs-infra/src/pipeline/loadServerTypes/__snapshots__/highlightTypes.test.ts.snap index 7967783b7..4a50fe070 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypes/__snapshots__/highlightTypes.test.ts.snap +++ b/packages/docs-infra/src/pipeline/loadServerTypes/__snapshots__/highlightTypes.test.ts.snap @@ -95,8 +95,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 1, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -253,8 +251,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 3, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -714,8 +710,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 4, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -860,8 +854,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 3, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -1010,8 +1002,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 3, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -1216,8 +1206,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 1, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/extractTypeProps.test.ts b/packages/docs-infra/src/pipeline/loadServerTypes/extractTypeProps.test.ts index 7469c0992..1b7254a1c 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypes/extractTypeProps.test.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypes/extractTypeProps.test.ts @@ -302,8 +302,6 @@ describe('extractTypeProps', () => { // All frames should have frame properties for (const frame of [...commentFrames, ...normalFrames]) { expect(frame.properties?.className).toBe('frame'); - expect(frame.properties?.dataFrameStartLine).toBeDefined(); - expect(frame.properties?.dataFrameEndLine).toBeDefined(); } }); diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/__snapshots__/highlightTypes.test.ts.snap b/packages/docs-infra/src/pipeline/loadServerTypesMeta/__snapshots__/highlightTypes.test.ts.snap index 7967783b7..4a50fe070 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/__snapshots__/highlightTypes.test.ts.snap +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/__snapshots__/highlightTypes.test.ts.snap @@ -95,8 +95,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 1, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -253,8 +251,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 3, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -714,8 +710,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 4, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -860,8 +854,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 3, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -1010,8 +1002,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 3, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", @@ -1216,8 +1206,6 @@ exports[`highlightTypes > snapshot tests - precomputed output verification > sho ], "properties": { "className": "frame", - "dataFrameEndLine": 1, - "dataFrameStartLine": 1, }, "tagName": "span", "type": "element", diff --git a/packages/docs-infra/src/pipeline/parseSource/addLineGutters.test.ts b/packages/docs-infra/src/pipeline/parseSource/addLineGutters.test.ts index 1691aed13..e00684c96 100644 --- a/packages/docs-infra/src/pipeline/parseSource/addLineGutters.test.ts +++ b/packages/docs-infra/src/pipeline/parseSource/addLineGutters.test.ts @@ -22,8 +22,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 1, }, children: [ { @@ -57,8 +55,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 2, }, children: [ { @@ -98,8 +94,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 3, }, children: [ { @@ -156,8 +150,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 2, }, children: [ { @@ -205,8 +197,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 3, }, children: [ { @@ -253,8 +243,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 4, }, children: [ { @@ -306,8 +294,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 2, }, children: [ { @@ -347,8 +333,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 2, }, children: [ { @@ -408,8 +392,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 1, }, children: [ { @@ -475,8 +457,6 @@ describe('starryNightGutter', () => { tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: 1, - dataFrameEndLine: 3, }, children: [ { diff --git a/packages/docs-infra/src/pipeline/parseSource/addLineGutters.ts b/packages/docs-infra/src/pipeline/parseSource/addLineGutters.ts index 904001496..8719d2f7f 100644 --- a/packages/docs-infra/src/pipeline/parseSource/addLineGutters.ts +++ b/packages/docs-infra/src/pipeline/parseSource/addLineGutters.ts @@ -59,7 +59,6 @@ export function starryNightGutter( let startTextRemainder = ''; let lineNumber = 0; let frameLines: Array = []; - let frameStartLine = 1; // Track the starting line number for the current frame while (index + 1 < tree.children.length) { index += 1; @@ -105,9 +104,8 @@ export function starryNightGutter( // Check if we need to create a frame (only if sourceLines provided, otherwise keep everything in one frame) if (sourceLines && lineNumber % frameSize === 0) { - replacement.push(createFrame(frameLines, frameStartLine, lineNumber)); + replacement.push(createFrame(frameLines)); frameLines = []; - frameStartLine = lineNumber + 1; } start = index + 1; @@ -136,7 +134,7 @@ export function starryNightGutter( // Add any remaining lines as the final frame if (frameLines.length > 0) { - replacement.push(createFrame(frameLines, frameStartLine, lineNumber)); + replacement.push(createFrame(frameLines)); } // If there are multiple frames and sourceLines provided, add dataAsString to each frame @@ -145,13 +143,20 @@ export function starryNightGutter( if ( frame.type === 'element' && frame.tagName === 'span' && - frame.properties?.className === 'frame' && - typeof frame.properties.dataFrameStartLine === 'number' && - typeof frame.properties.dataFrameEndLine === 'number' + frame.properties?.className === 'frame' ) { - const startLine = frame.properties.dataFrameStartLine - 1; // Convert to 0-based index - const endLine = frame.properties.dataFrameEndLine; // This is already inclusive - frame.properties.dataAsString = sourceLines.slice(startLine, endLine).join('\n'); + // Extract line range from child .line elements + const lineChildren = frame.children.filter( + (c): c is Element => + c.type === 'element' && + c.properties?.className === 'line' && + typeof c.properties.dataLn === 'number', + ); + if (lineChildren.length > 0) { + const startLine = Number(lineChildren[0].properties.dataLn) - 1; + const endLine = Number(lineChildren[lineChildren.length - 1].properties.dataLn); + frame.properties.dataAsString = sourceLines.slice(startLine, endLine).join('\n'); + } } } } diff --git a/packages/docs-infra/src/pipeline/parseSource/createFrame.ts b/packages/docs-infra/src/pipeline/parseSource/createFrame.ts index c9c430916..9ba8f1541 100644 --- a/packages/docs-infra/src/pipeline/parseSource/createFrame.ts +++ b/packages/docs-infra/src/pipeline/parseSource/createFrame.ts @@ -9,8 +9,6 @@ import type { FrameRange } from './calculateFrameRanges'; */ export function createFrame( children: Array, - startLine?: number, - endLine?: number, frameType?: FrameRange['type'], indentLevel?: number, ): Element { @@ -18,11 +16,6 @@ export function createFrame( className: 'frame', }; - if (startLine !== undefined && endLine !== undefined) { - properties.dataFrameStartLine = startLine; - properties.dataFrameEndLine = endLine; - } - if (frameType && frameType !== 'normal') { properties.dataFrameType = frameType; } diff --git a/packages/docs-infra/src/pipeline/parseSource/restructureFrames.test.ts b/packages/docs-infra/src/pipeline/parseSource/restructureFrames.test.ts index 7474b34b4..dea3e4ab5 100644 --- a/packages/docs-infra/src/pipeline/parseSource/restructureFrames.test.ts +++ b/packages/docs-infra/src/pipeline/parseSource/restructureFrames.test.ts @@ -32,8 +32,6 @@ function createTestFrame(lines: Element[], startLine: number, endLine: number): tagName: 'span', properties: { className: 'frame', - dataFrameStartLine: startLine, - dataFrameEndLine: endLine, }, children, }; @@ -75,20 +73,14 @@ describe('restructureFrames', () => { // First frame: normal const frame1 = root.children[0] as Element; - expect(frame1.properties?.dataFrameStartLine).toBe(1); - expect(frame1.properties?.dataFrameEndLine).toBe(2); expect(frame1.properties?.dataFrameType).toBeUndefined(); // Second frame: highlighted const frame2 = root.children[1] as Element; - expect(frame2.properties?.dataFrameStartLine).toBe(3); - expect(frame2.properties?.dataFrameEndLine).toBe(3); expect(frame2.properties?.dataFrameType).toBe('highlighted'); // Third frame: normal const frame3 = root.children[2] as Element; - expect(frame3.properties?.dataFrameStartLine).toBe(4); - expect(frame3.properties?.dataFrameEndLine).toBe(5); expect(frame3.properties?.dataFrameType).toBeUndefined(); }); @@ -129,8 +121,6 @@ describe('restructureFrames', () => { expect(root.children).toHaveLength(1); const frame = root.children[0] as Element; expect(frame.properties?.dataFrameType).toBe('highlighted'); - expect(frame.properties?.dataFrameStartLine).toBe(1); - expect(frame.properties?.dataFrameEndLine).toBe(1); }); }); diff --git a/packages/docs-infra/src/pipeline/parseSource/restructureFrames.ts b/packages/docs-infra/src/pipeline/parseSource/restructureFrames.ts index e91d95ac0..65f4c5699 100644 --- a/packages/docs-infra/src/pipeline/parseSource/restructureFrames.ts +++ b/packages/docs-infra/src/pipeline/parseSource/restructureFrames.ts @@ -116,9 +116,7 @@ export function restructureFrames( ? regionIndentLevels.get(highlightedRegionIndex) : undefined; - newFrames.push( - createFrame(children, range.startLine, range.endLine, range.type, indentLevel), - ); + newFrames.push(createFrame(children, range.type, indentLevel)); } // Increment region index after each highlighted frame (focused or unfocused) diff --git a/packages/docs-infra/src/useCode/Pre.tsx b/packages/docs-infra/src/useCode/Pre.tsx index 7547782cc..a4499f08a 100644 --- a/packages/docs-infra/src/useCode/Pre.tsx +++ b/packages/docs-infra/src/useCode/Pre.tsx @@ -67,6 +67,7 @@ export function Pre({ }); const observer = React.useRef(null); + const frameIndexMap = React.useRef(new WeakMap()); const bindIntersectionObserver = React.useCallback( (root: HTMLPreElement | null) => { if (!root) { @@ -78,6 +79,8 @@ export function Pre({ return; } + const indexMap = frameIndexMap.current; + observer.current = new IntersectionObserver( (entries) => setVisibleFrames((prev) => { @@ -85,10 +88,14 @@ export function Pre({ const invisible: number[] = []; entries.forEach((entry) => { + const index = indexMap.get(entry.target); + if (index === undefined) { + return; + } if (entry.isIntersecting) { - visible.push(Number(entry.target.getAttribute('data-frame'))); + visible.push(index); } else { - invisible.push(Number(entry.target.getAttribute('data-frame'))); + invisible.push(index); } }); @@ -117,15 +124,18 @@ export function Pre({ { rootMargin: hydrateMargin }, ); - //
.........
+ //
.........
+ let frameIndex = 0; root.childNodes[0].childNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; - if (!element.hasAttribute('data-frame')) { + if (!element.classList.contains('frame')) { console.warn('Expected frame element in useCode
', element);
             return;
           }
 
+          indexMap.set(element, frameIndex);
+          frameIndex += 1;
           observer.current?.observe(element);
         }
       });
@@ -165,7 +175,6 @@ export function Pre({
           
Date: Tue, 31 Mar 2026 17:13:43 -0400
Subject: [PATCH 21/61] Fix lint

---
 .../src/pipeline/parseSource/restructureFrames.test.ts        | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/docs-infra/src/pipeline/parseSource/restructureFrames.test.ts b/packages/docs-infra/src/pipeline/parseSource/restructureFrames.test.ts
index dea3e4ab5..e58f717f0 100644
--- a/packages/docs-infra/src/pipeline/parseSource/restructureFrames.test.ts
+++ b/packages/docs-infra/src/pipeline/parseSource/restructureFrames.test.ts
@@ -19,7 +19,7 @@ function createLine(lineNumber: number, text: string, indent: string = ''): Elem
 /**
  * Helper to create a frame element containing lines.
  */
-function createTestFrame(lines: Element[], startLine: number, endLine: number): Element {
+function createTestFrame(lines: Element[]): Element {
   const children: ElementContent[] = [];
   for (let i = 0; i < lines.length; i += 1) {
     children.push(lines[i]);
@@ -41,7 +41,7 @@ function createTestFrame(lines: Element[], startLine: number, endLine: number):
  * Helper to create a HastRoot with a single frame.
  */
 function createRoot(lines: Element[], totalLines?: number): HastRoot {
-  const frame = createTestFrame(lines, 1, lines.length);
+  const frame = createTestFrame(lines);
   return {
     type: 'root',
     children: [frame],

From f122ac02d5f9bfd1ecef7f64ad5760dd35053d5d Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 18:10:41 -0400
Subject: [PATCH 22/61] Remove `shortTypeText` and `typeText` from highlighted
 typed

---
 docs/app/docs-infra/hooks/use-types/types.md  | 12 ------------
 .../pipeline/load-server-types/types.md       | 12 ------------
 .../abstractCreateTypes/typesToJsx.test.ts    | 17 -----------------
 .../highlightTypesMeta.test.ts                | 19 +++----------------
 .../loadServerTypes/highlightTypesMeta.ts     | 18 ++++++------------
 5 files changed, 9 insertions(+), 69 deletions(-)

diff --git a/docs/app/docs-infra/hooks/use-types/types.md b/docs/app/docs-infra/hooks/use-types/types.md
index d598a05c8..86a92c8a2 100644
--- a/docs/app/docs-infra/hooks/use-types/types.md
+++ b/docs/app/docs-infra/hooks/use-types/types.md
@@ -34,8 +34,6 @@ The components rendering each field are configured in `createTypes()`.
 
 ```typescript
 type EnhancedClassProperty = {
-  /** Plain text type string */
-  typeText: string;
   /** Plain text default value */
   defaultText?: string;
   /** Whether the property is required */
@@ -49,8 +47,6 @@ type EnhancedClassProperty = {
    * @see for markdown generation
    */
   seeText?: string;
-  /** Plain text version of shortType for accessibility */
-  shortTypeText?: string;
   /** Whether this is a static property */
   isStatic?: boolean;
   /** Whether this property is readonly */
@@ -231,8 +227,6 @@ The components rendering each field are configured in `createTypes()`.
 
 ```typescript
 type EnhancedParameter = {
-  /** Plain text type string */
-  typeText: string;
   /** Plain text default value */
   defaultText?: string;
   /** Plain text version of description for markdown generation */
@@ -244,8 +238,6 @@ type EnhancedParameter = {
    * @see for markdown generation
    */
   seeText?: string;
-  /** Plain text version of shortType for accessibility */
-  shortTypeText?: string;
   /** Parameter name */
   name: string;
   /** Whether the parameter is optional */
@@ -274,8 +266,6 @@ The components rendering each field are configured in `createTypes()`.
 
 ```typescript
 type EnhancedProperty = {
-  /** Plain text type string */
-  typeText: string;
   /** Plain text default value */
   defaultText?: string;
   /** Whether the property is required */
@@ -289,8 +279,6 @@ type EnhancedProperty = {
    * @see for markdown generation
    */
   seeText?: string;
-  /** Plain text version of shortType for accessibility */
-  shortTypeText?: string;
   /** Full type signature. Rendered by the `TypePre` component configured in `createTypes()`. */
   type: React.ReactNode;
   /** Compact type summary. Rendered by the `ShortTypeCode` component configured in `createTypes()`. */
diff --git a/docs/app/docs-infra/pipeline/load-server-types/types.md b/docs/app/docs-infra/pipeline/load-server-types/types.md
index 1924926e0..303da45dd 100644
--- a/docs/app/docs-infra/pipeline/load-server-types/types.md
+++ b/docs/app/docs-infra/pipeline/load-server-types/types.md
@@ -53,16 +53,12 @@ type HighlightedClassProperty = {
   type: HastField;
   /** Short simplified type for table display (e.g., "Union", "function") */
   shortType?: HastField;
-  /** Plain text version of shortType for accessibility */
-  shortTypeText?: string;
   /** Default value with syntax highlighting as HAST */
   default?: HastField;
   /** Detailed expanded type view (only when different from basic type) */
   detailedType?: HastField;
   /** Plain text version of description for markdown generation */
   descriptionText?: string;
-  /** Plain text type string */
-  typeText: string;
   /** Plain text default value */
   defaultText?: string;
   /** Whether the property is required */
@@ -204,8 +200,6 @@ type HighlightedParameter = {
   type: HastField;
   /** Short simplified type for table display (e.g., "Union", "function") */
   shortType?: HastField;
-  /** Plain text version of shortType for accessibility */
-  shortTypeText?: string;
   /** Default value with syntax highlighting as HAST */
   default?: HastField;
   /** Detailed type with expanded type references as HAST */
@@ -214,8 +208,6 @@ type HighlightedParameter = {
   name: string;
   /** Plain text version of description for markdown generation */
   descriptionText?: string;
-  /** Plain text type string */
-  typeText: string;
   /** Plain text default value */
   defaultText?: string;
   /** Plain text version of example for markdown generation */
@@ -244,16 +236,12 @@ type HighlightedProperty = {
   type: HastField;
   /** Short simplified type for table display (e.g., "Union", "function") */
   shortType?: HastField;
-  /** Plain text version of shortType for accessibility */
-  shortTypeText?: string;
   /** Default value with syntax highlighting as HAST */
   default?: HastField;
   /** Detailed expanded type view (only when different from basic type) */
   detailedType?: HastField;
   /** Plain text version of description for markdown generation */
   descriptionText?: string;
-  /** Plain text type string */
-  typeText: string;
   /** Plain text default value */
   defaultText?: string;
   /** Whether the property is required */
diff --git a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
index e9fcde3bb..81411a2bc 100644
--- a/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/typesToJsx.test.ts
@@ -63,7 +63,6 @@ function createHighlightedComponent(
       string,
       {
         type: HastRoot;
-        typeText: string;
         required?: boolean;
         description?: HastRoot;
         default?: HastRoot;
@@ -84,7 +83,6 @@ function createHighlightedComponent(
       props: options.props ?? {
         disabled: {
           type: createHastRoot('boolean'),
-          typeText: 'boolean',
           required: false,
         },
       },
@@ -104,7 +102,6 @@ function createHighlightedHook(
     parameters?: Array<{
       name: string;
       type: HastRoot;
-      typeText: string;
       required?: boolean;
       description?: HastRoot;
       detailedType?: HastRoot;
@@ -115,7 +112,6 @@ function createHighlightedHook(
           string,
           {
             type: HastRoot;
-            typeText: string;
             required?: boolean;
             description?: HastRoot;
             detailedType?: HastRoot;
@@ -172,7 +168,6 @@ function createHighlightedFunction(
     parameters?: Array<{
       name: string;
       type: HastRoot;
-      typeText: string;
       required?: boolean;
       description?: HastRoot;
       detailedType?: HastRoot;
@@ -185,7 +180,6 @@ function createHighlightedFunction(
           string,
           {
             type: HastRoot;
-            typeText: string;
             required?: boolean;
             description?: HastRoot;
             detailedType?: HastRoot;
@@ -234,13 +228,11 @@ describe('typesToJsx', () => {
           props: {
             disabled: {
               type: createHastRoot('boolean'),
-              typeText: 'boolean',
               required: false,
               description: createHastRoot('Whether the button is disabled'),
             },
             onClick: {
               type: createHastRoot('() => void'),
-              typeText: '() => void',
               required: true,
             },
           },
@@ -292,7 +284,6 @@ describe('typesToJsx', () => {
             {
               name: 'options',
               type: createHastRoot('ButtonOptions'),
-              typeText: 'ButtonOptions',
               required: true,
               description: createHastRoot('Configuration options'),
             },
@@ -312,12 +303,10 @@ describe('typesToJsx', () => {
           returnValue: {
             getRootProps: {
               type: createHastRoot('() => ButtonRootProps'),
-              typeText: '() => ButtonRootProps',
               required: true,
             },
             disabled: {
               type: createHastRoot('boolean'),
-              typeText: 'boolean',
               required: true,
             },
           },
@@ -546,7 +535,6 @@ describe('typesToJsx', () => {
         props: {
           disabled: {
             type: createHastRoot('boolean'),
-            typeText: 'boolean',
             detailedType: createHighlightedCodeBlock('boolean'),
           },
         },
@@ -574,7 +562,6 @@ describe('typesToJsx', () => {
         props: {
           disabled: {
             type: createHastRoot('boolean'),
-            typeText: 'boolean',
             detailedType: createHighlightedCodeBlock('boolean'),
           },
         },
@@ -603,7 +590,6 @@ describe('typesToJsx', () => {
         props: {
           disabled: {
             type: createHastRoot('boolean'),
-            typeText: 'boolean',
             detailedType: createHighlightedCodeBlock('boolean'),
           },
         },
@@ -641,7 +627,6 @@ describe('typesToJsx', () => {
           {
             name: 'options',
             type: createHastRoot('ButtonOptions'),
-            typeText: 'ButtonOptions',
             required: true,
             detailedType: createHighlightedCodeBlock('ButtonOptions'),
           },
@@ -666,7 +651,6 @@ describe('typesToJsx', () => {
         props: {
           disabled: {
             type: createHastRoot('boolean'),
-            typeText: 'boolean',
             description: createHastRoot('Whether disabled'),
             detailedType: createHighlightedCodeBlock('boolean'),
           },
@@ -695,7 +679,6 @@ describe('typesToJsx', () => {
         props: {
           disabled: {
             type: createHastRoot('boolean'),
-            typeText: 'boolean',
             // No detailedType
           },
         },
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
index 940341db4..90a0a331e 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.test.ts
@@ -541,8 +541,7 @@ describe('highlightTypesMeta', () => {
 
       const component = result[0];
       if (component.type === 'component') {
-        // shortType should still be "Union"
-        expect(component.data.props.variant.shortTypeText).toBe('Union');
+        expect(extractText(component.data.props.variant.shortType!)).toBe('Union');
       }
     });
   });
@@ -1048,7 +1047,6 @@ describe('highlightTypesMeta', () => {
         const prop = component.data.props.className;
 
         // shortType: special-cased to "string | function" for className
-        expect(prop.shortTypeText).toBe('string | function');
         expect(extractText(prop.shortType!)).toBe('string | function');
 
         // type: full original text as HAST
@@ -1098,7 +1096,6 @@ describe('highlightTypesMeta', () => {
         const prop = component.data.props.onClick;
 
         // shortType: "function" for on* props
-        expect(prop.shortTypeText).toBe('function');
         expect(extractText(prop.shortType!)).toBe('function');
 
         // type: full original text
@@ -1148,7 +1145,6 @@ describe('highlightTypesMeta', () => {
         const { render } = component.data.props;
 
         // shortType: special-cased to "ReactElement | function" for render
-        expect(render.shortTypeText).toBe('ReactElement | function');
         expect(extractText(render.shortType!)).toBe('ReactElement | function');
 
         // type: full original text, formatted by prettier (>60 chars triggers multiline)
@@ -1201,7 +1197,6 @@ describe('highlightTypesMeta', () => {
         const prop = component.data.props.style;
 
         // shortType: special-cased to "React.CSSProperties | function" for style
-        expect(prop.shortTypeText).toBe('React.CSSProperties | function');
         expect(extractText(prop.shortType!)).toBe('React.CSSProperties | function');
 
         // type: full original text, formatted by prettier (>60 chars triggers multiline)
@@ -1250,7 +1245,6 @@ describe('highlightTypesMeta', () => {
         const prop = component.data.props.variant;
 
         // shortType: "Union" for union types
-        expect(prop.shortTypeText).toBe('Union');
         expect(extractText(prop.shortType!)).toBe('Union');
 
         // type: full original text, formatted by prettier (>60 chars triggers multiline)
@@ -1293,7 +1287,6 @@ describe('highlightTypesMeta', () => {
 
         // shortType: undefined for simple types (no shortening needed)
         expect(prop.shortType).toBeUndefined();
-        expect(prop.shortTypeText).toBeUndefined();
 
         // type: full original text
         expect(extractText(prop.type)).toBe('boolean');
@@ -1333,7 +1326,6 @@ describe('highlightTypesMeta', () => {
 
         // shortType: undefined (not a function or union)
         expect(prop.shortType).toBeUndefined();
-        expect(prop.shortTypeText).toBeUndefined();
 
         // type: full original text
         expect(extractText(prop.type)).toBe('React.ReactNode');
@@ -1369,7 +1361,6 @@ describe('highlightTypesMeta', () => {
 
         // shortType: undefined
         expect(prop.shortType).toBeUndefined();
-        expect(prop.shortTypeText).toBeUndefined();
 
         // type: full original text
         expect(extractText(prop.type)).toBe('React.Ref');
@@ -1410,7 +1401,6 @@ describe('highlightTypesMeta', () => {
         const prop = component.data.props.getValue;
 
         // shortType: "function" for get* props
-        expect(prop.shortTypeText).toBe('function');
         expect(extractText(prop.shortType!)).toBe('function');
 
         // type: full original text
@@ -1583,7 +1573,6 @@ describe('highlightTypesMeta', () => {
         // type should contain full original type (with | undefined)
         expect(extractText(prop.type)).toBe('string | undefined');
         // shortType should be "string" (stripped | undefined) so UI shows clean version
-        expect(prop.shortTypeText).toBe('string');
         expect(extractText(prop.shortType!)).toBe('string');
       }
     });
@@ -1613,7 +1602,6 @@ describe('highlightTypesMeta', () => {
         // type should contain full original type, formatted by prettier (singleQuote: true)
         expect(extractText(prop.type)).toBe(`'a' | 'b' | undefined`);
         // shortType should be '"a" | "b"' (stripped | undefined)
-        expect(prop.shortTypeText).toBe('"a" | "b"');
         expect(extractText(prop.shortType!)).toBe('"a" | "b"');
       }
     });
@@ -1645,7 +1633,7 @@ describe('highlightTypesMeta', () => {
         // Short union stays on one line
         expect(extractText(prop.type)).toBe(`'a' | 'b' | undefined`);
         // shortType should be "Union" for 3-member union (not stripped because required)
-        expect(prop.shortTypeText).toBe('Union');
+        expect(extractText(prop.shortType!)).toBe('Union');
       }
     });
 
@@ -1681,7 +1669,7 @@ describe('highlightTypesMeta', () => {
 | undefined`,
         );
         // shortType should be "Union" for 5-member union (after stripping | undefined)
-        expect(prop.shortTypeText).toBe('Union');
+        expect(extractText(prop.shortType!)).toBe('Union');
       }
     });
 
@@ -1710,7 +1698,6 @@ describe('highlightTypesMeta', () => {
         // type should contain original type
         expect(extractText(prop.type)).toBe('string');
         // shortType should be undefined (no stripping needed, simple type)
-        expect(prop.shortTypeText).toBeUndefined();
         expect(prop.shortType).toBeUndefined();
       }
     });
diff --git a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
index 0405c5e75..af1e2699b 100644
--- a/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
+++ b/packages/docs-infra/src/pipeline/loadServerTypes/highlightTypesMeta.ts
@@ -134,7 +134,7 @@ async function highlightRawProperties(
  */
 export interface HighlightedProperty extends Omit<
   FormattedProperty,
-  'description' | 'example' | 'see'
+  'typeText' | 'description' | 'example' | 'see'
 > {
   /** Description with syntax highlighting as HAST */
   description?: HastField;
@@ -146,8 +146,6 @@ export interface HighlightedProperty extends Omit<
   type: HastField;
   /** Short simplified type for table display (e.g., "Union", "function") */
   shortType?: HastField;
-  /** Plain text version of shortType for accessibility */
-  shortTypeText?: string;
   /** Default value with syntax highlighting as HAST */
   default?: HastField;
   /** Detailed expanded type view (only when different from basic type) */
@@ -170,7 +168,7 @@ export interface HighlightedClassProperty extends HighlightedProperty {
  */
 export interface HighlightedParameter extends Omit<
   FormattedParameter,
-  'description' | 'example' | 'see'
+  'typeText' | 'description' | 'example' | 'see'
 > {
   /** Description with syntax highlighting as HAST */
   description?: HastField;
@@ -182,8 +180,6 @@ export interface HighlightedParameter extends Omit<
   type: HastField;
   /** Short simplified type for table display (e.g., "Union", "function") */
   shortType?: HastField;
-  /** Plain text version of shortType for accessibility */
-  shortTypeText?: string;
   /** Default value with syntax highlighting as HAST */
   default?: HastField;
   /** Detailed type with expanded type references as HAST */
@@ -1093,17 +1089,17 @@ async function highlightPropertyMeta(
     ? await formatInlineTypeAsHast(prop.defaultText, defaultValueUnionPrintWidth)
     : undefined;
 
+  const { typeText: omittedTypeText, ...propWithoutTypeText } = prop;
   const highlighted: HighlightedProperty = {
-    ...prop,
+    ...propWithoutTypeText,
     // description and example are already serialized by highlightTypes (or highlightRawProperties)
     // see bypasses highlightTypes — serialize here
     ...('see' in prop && prop.see !== undefined ? { see: s(prop.see) } : {}),
     type: s(type),
   };
 
-  if (shortType && shortTypeText) {
+  if (shortType) {
     highlighted.shortType = s(shortType);
-    highlighted.shortTypeText = shortTypeText;
   }
 
   if (defaultValue) {
@@ -1177,7 +1173,6 @@ async function highlightClassPropertyMeta(
   const type = wrapInlineTypeInPre(await formatInlineTypeAsHast(formattedTypeText));
 
   const highlighted: HighlightedClassProperty = {
-    typeText: prop.typeText,
     type: s(type),
   };
 
@@ -1193,9 +1188,8 @@ async function highlightClassPropertyMeta(
     highlighted.description = s(prop.description);
   }
 
-  if (shortType && shortTypeText) {
+  if (shortType) {
     highlighted.shortType = s(shortType);
-    highlighted.shortTypeText = shortTypeText;
   }
 
   if (detailedType) {

From 7589772e2d700f9d45085e2d1b509acd058f5a77 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 18:11:04 -0400
Subject: [PATCH 23/61] Add some context about page weight

---
 .../docs-infra/patterns/prop-compression/page.mdx    | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx
index 8fbb1e89f..f6ee82d12 100644
--- a/docs/app/docs-infra/patterns/prop-compression/page.mdx
+++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx
@@ -153,10 +153,14 @@ into initial hydration.
 
 ### Page Weight (Type Documentation)
 
-Applying the same pattern to type documentation (prop tables, detailed type
-signatures) shows a different profile. Type pages contain many smaller
-highlighted fragments spread across dozens of props, so even modest per-prop
-savings compound quickly.
+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.
 
 | Page                       | Uncompressed HTML | Compressed HTML |
 | -------------------------- | ----------------- | --------------- |

From 55a74b31f595e039f62e09cf57abce40d39a3a9e Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Tue, 31 Mar 2026 18:22:07 -0400
Subject: [PATCH 24/61] Strip data-lined property

---
 .../stripHighlightingSpans.test.ts            | 32 +++++++++++++++++++
 .../stripHighlightingSpans.ts                 | 18 +++++++----
 2 files changed, 43 insertions(+), 7 deletions(-)

diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
index 07ac05170..2ab3ae798 100644
--- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
@@ -393,6 +393,38 @@ describe('stripHighlightingSpans', () => {
     expect(frame.children).toEqual([{ type: 'text', value: 'type Foo = {}' }]);
   });
 
+  it('should remove dataLined from frame spans', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: {
+            className: ['frame'],
+            dataLined: '',
+            dataFrameType: 'comment',
+          },
+          children: [
+            {
+              type: 'element',
+              tagName: 'span',
+              properties: { className: ['line'], dataLn: 1 },
+              children: [{ type: 'text', value: '// hello' }],
+            },
+          ],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    const frame = result.children[0] as HastElement;
+    expect(frame.properties).toEqual({
+      className: ['frame'],
+      dataFrameType: 'comment',
+    });
+    expect(frame.children).toEqual([{ type: 'text', value: '// hello' }]);
+  });
+
   it('should strip line spans but preserve their content', () => {
     const root: HastRoot = {
       type: 'root',
diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
index 8b6f3c894..c97af7621 100644
--- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
+++ b/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
@@ -1,4 +1,4 @@
-import type { Root as HastRoot, RootContent, Element as HastElement } from 'hast';
+import type { Root as HastRoot, RootContent, Element as HastElement, ElementContent } from 'hast';
 
 /**
  * Strip syntax-highlighting `` elements from a HAST tree while preserving
@@ -36,12 +36,16 @@ function processChildren(children: RootContent[]): RootContent[] {
       return processChildren(element.children as RootContent[]);
     }
     // Keep semantic spans, links, and other elements — process their children
-    return [
-      {
-        ...element,
-        children: processChildren(element.children as RootContent[]),
-      } as RootContent,
-    ];
+    const processed: HastElement = {
+      ...element,
+      children: processChildren(element.children as RootContent[]) as ElementContent[],
+    };
+    // Strip data-lined from frame spans since line spans are removed
+    if (isFrameSpan(element) && processed.properties?.dataLined !== undefined) {
+      const { dataLined: omittedDataLined, ...rest } = processed.properties;
+      processed.properties = rest;
+    }
+    return [processed as RootContent];
   });
   return mergeAdjacentText(flat);
 }

From b656016f22a9f6e9ac8e2d1e200d369d05c52d46 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Wed, 1 Apr 2026 16:59:17 -0400
Subject: [PATCH 25/61] Improve dictionary

---
 .../hastUtils/hastCompression.test.ts         | 387 ++++++++++++++++++
 .../src/pipeline/hastUtils/hastCompression.ts | 104 ++++-
 2 files changed, 486 insertions(+), 5 deletions(-)
 create mode 100644 packages/docs-infra/src/pipeline/hastUtils/hastCompression.test.ts

diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.test.ts b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.test.ts
new file mode 100644
index 000000000..c4356be33
--- /dev/null
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.test.ts
@@ -0,0 +1,387 @@
+import { describe, it, expect } from 'vitest';
+import { deflateSync, inflateSync, strToU8, strFromU8 } from 'fflate';
+import { encode, decode } from 'uint8-to-base64';
+import { extractTypeProps } from '../loadServerTypes/extractTypeProps';
+import { formatDetailedTypeAsHast } from '../loadServerTypes/typeHighlighting';
+import {
+  compressHast,
+  decompressHast,
+  compressHastAsync,
+  decompressHastAsync,
+  HAST_DICTIONARY,
+} from './hastCompression';
+
+describe('hastCompression', () => {
+  const SAMPLE_HAST_JSON = JSON.stringify({
+    type: 'root',
+    children: [
+      {
+        type: 'element',
+        tagName: 'pre',
+        properties: {},
+        children: [
+          {
+            type: 'element',
+            tagName: 'code',
+            properties: {},
+            children: [
+              {
+                type: 'element',
+                tagName: 'span',
+                properties: { className: ['pl-k'] },
+                children: [{ type: 'text', value: 'interface' }],
+              },
+              { type: 'text', value: ' ' },
+              {
+                type: 'element',
+                tagName: 'span',
+                properties: { className: ['pl-en'] },
+                children: [{ type: 'text', value: 'ButtonProps' }],
+              },
+              { type: 'text', value: ' {\n  ' },
+              {
+                type: 'element',
+                tagName: 'span',
+                properties: { className: ['pl-smi'] },
+                children: [{ type: 'text', value: 'children' }],
+              },
+              { type: 'text', value: ': ' },
+              {
+                type: 'element',
+                tagName: 'span',
+                properties: { className: ['pl-c1'] },
+                children: [{ type: 'text', value: 'React.ReactNode' }],
+              },
+              { type: 'text', value: ';\n  ' },
+              {
+                type: 'element',
+                tagName: 'span',
+                properties: { className: ['pl-smi'] },
+                children: [{ type: 'text', value: 'disabled' }],
+              },
+              { type: 'text', value: '?: ' },
+              {
+                type: 'element',
+                tagName: 'span',
+                properties: { className: ['pl-c1'] },
+                children: [{ type: 'text', value: 'boolean' }],
+              },
+              { type: 'text', value: ';\n}' },
+            ],
+          },
+        ],
+      },
+    ],
+  });
+
+  const SIMPLE_JSON = JSON.stringify({ hello: 'world' });
+
+  const TYPE_PROP_HAST_JSON = JSON.stringify({
+    type: 'root',
+    children: [
+      {
+        type: 'element',
+        tagName: 'span',
+        properties: { className: ['frame'], dataFrameType: 'comment' },
+        children: [
+          {
+            type: 'element',
+            tagName: 'span',
+            properties: { className: ['line'], dataLn: 1 },
+            children: [{ type: 'text', value: 'The class name to apply to the root element.' }],
+          },
+        ],
+      },
+      {
+        type: 'element',
+        tagName: 'span',
+        properties: { className: ['frame'], dataFrameType: 'property' },
+        children: [
+          {
+            type: 'element',
+            tagName: 'span',
+            properties: { className: ['line'], dataLn: 2 },
+            children: [
+              { type: 'text', value: 'className?: ' },
+              {
+                type: 'element',
+                tagName: 'span',
+                properties: { dataType: 'TypeRef', name: 'NumberField.Root.State' },
+                children: [{ type: 'text', value: 'NumberField.Root.State' }],
+              },
+              { type: 'text', value: ' | undefined' },
+            ],
+          },
+        ],
+      },
+      {
+        type: 'element',
+        tagName: 'span',
+        properties: { className: ['frame'], dataFrameType: 'property' },
+        children: [
+          {
+            type: 'element',
+            tagName: 'span',
+            properties: { className: ['line'], dataLn: 3 },
+            children: [
+              {
+                type: 'text',
+                value: '(state: NavigationMenu.Root.State) => React.CSSProperties | undefined',
+              },
+            ],
+          },
+        ],
+      },
+      {
+        type: 'element',
+        tagName: 'span',
+        properties: { className: ['frame'], dataFrameType: 'property' },
+        children: [
+          {
+            type: 'element',
+            tagName: 'span',
+            properties: { className: ['line'], dataLn: 4 },
+            children: [
+              {
+                type: 'text',
+                value:
+                  '(value: TValue | null, eventDetails: NavigationMenu.Root.ChangeEventDetails) => void',
+              },
+            ],
+          },
+        ],
+      },
+      {
+        type: 'element',
+        tagName: 'span',
+        properties: { className: ['frame'], dataFrameType: 'property' },
+        children: [
+          {
+            type: 'element',
+            tagName: 'span',
+            properties: { className: ['line'], dataLn: 5 },
+            children: [
+              {
+                type: 'text',
+                value: '(props: HTMLProps, state: NavigationMenu.Root.State) => ReactElement',
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  });
+
+  async function createExtractedHastJson(source: string): Promise {
+    const highlighted = await formatDetailedTypeAsHast(source);
+    const extracted = extractTypeProps(highlighted);
+    return JSON.stringify(extracted.hast);
+  }
+
+  describe('sync roundtrip', () => {
+    it('compresses and decompresses HAST JSON to the original string', () => {
+      const compressed = compressHast(SAMPLE_HAST_JSON);
+      const decompressed = decompressHast(compressed);
+      expect(decompressed).toBe(SAMPLE_HAST_JSON);
+    });
+
+    it('compresses and decompresses simple JSON', () => {
+      const compressed = compressHast(SIMPLE_JSON);
+      const decompressed = decompressHast(compressed);
+      expect(decompressed).toBe(SIMPLE_JSON);
+    });
+
+    it('handles an empty string', () => {
+      const compressed = compressHast('');
+      const decompressed = decompressHast(compressed);
+      expect(decompressed).toBe('');
+    });
+  });
+
+  describe('async roundtrip', () => {
+    it('compresses and decompresses HAST JSON to the original string', async () => {
+      const compressed = await compressHastAsync(SAMPLE_HAST_JSON);
+      const decompressed = await decompressHastAsync(compressed);
+      expect(decompressed).toBe(SAMPLE_HAST_JSON);
+    });
+
+    it('compresses and decompresses simple JSON', async () => {
+      const compressed = await compressHastAsync(SIMPLE_JSON);
+      const decompressed = await decompressHastAsync(compressed);
+      expect(decompressed).toBe(SIMPLE_JSON);
+    });
+  });
+
+  describe('sync and async produce compatible results', () => {
+    it('async compressed data can be decompressed synchronously', async () => {
+      const compressed = await compressHastAsync(SAMPLE_HAST_JSON);
+      const decompressed = decompressHast(compressed);
+      expect(decompressed).toBe(SAMPLE_HAST_JSON);
+    });
+
+    it('sync compressed data can be decompressed asynchronously', async () => {
+      const compressed = compressHast(SAMPLE_HAST_JSON);
+      const decompressed = await decompressHastAsync(compressed);
+      expect(decompressed).toBe(SAMPLE_HAST_JSON);
+    });
+  });
+
+  describe('dictionary effectiveness', () => {
+    it('produces smaller output than DEFLATE without a dictionary for HAST-like data', () => {
+      const withDict = compressHast(SAMPLE_HAST_JSON);
+      const withoutDict = encode(deflateSync(strToU8(SAMPLE_HAST_JSON), { level: 9 }));
+
+      expect(withDict.length).toBeLessThan(withoutDict.length);
+    });
+
+    it('produces smaller output for repeated type-prop HAST patterns', () => {
+      const withDict = compressHast(TYPE_PROP_HAST_JSON);
+      const withoutDict = encode(deflateSync(strToU8(TYPE_PROP_HAST_JSON), { level: 9 }));
+
+      expect(withDict.length).toBeLessThan(withoutDict.length);
+    });
+
+    it('produces smaller output for real extracted JSX and hooks payloads', async () => {
+      const rawJson = await createExtractedHastJson(`{
+  /** Hook-driven callback that returns JSX output */
+  render?: (state: NavigationMenu.Root.State) => React.JSX.Element;
+  /** Memoized JSX list for child items */
+  getItems?: () => Array;
+  /** State change callback from an interaction */
+  onChange?: React.Dispatch>;
+}`);
+
+      const withDict = compressHast(rawJson);
+      const withoutDict = encode(deflateSync(strToU8(rawJson), { level: 9 }));
+
+      expect(rawJson).toContain('"dataFrameType":"comment"');
+      expect(rawJson).toContain('"className":"frame"');
+      expect(rawJson).toContain('"value":"React"');
+      expect(rawJson).toContain('"value":"JSX"');
+      expect(withDict.length).toBeLessThan(withoutDict.length);
+    });
+
+    it('produces smaller output for real extracted hook return-signature payloads', async () => {
+      const rawJson = await createExtractedHastJson(`{
+  /** Return signature from a stateful hook */
+  useValue?: () => [value: string, setValue: Dispatch>];
+  /** Stable callback returned from hook internals */
+  useAction?: () => ReturnType void>>;
+  /** Memoized selector result from hook internals */
+  useItems?: () => ReturnType>>;
+}`);
+
+      const withDict = compressHast(rawJson);
+      const withoutDict = encode(deflateSync(strToU8(rawJson), { level: 9 }));
+
+      expect(rawJson).toContain('"dataFrameType":"comment"');
+      expect(rawJson).toContain('"className":"frame"');
+      expect(rawJson).toContain('"value":"Dispatch"');
+      expect(rawJson).toContain('"value":"SetStateAction"');
+      expect(withDict.length).toBeLessThan(withoutDict.length);
+    });
+
+    it('produces smaller output for real extracted type-prop HAST payloads', async () => {
+      const highlighted = await formatDetailedTypeAsHast(`{
+  /** Whether the button is interactive */
+  disabled?: boolean;
+  /** Optional inline style override */
+  style?: React.CSSProperties;
+  /** Render props for a navigation menu item */
+  render?: (props: HTMLProps, state: NavigationMenu.Root.State) => ReactElement;
+}`);
+
+      const extracted = extractTypeProps(highlighted);
+      const rawJson = JSON.stringify(extracted.hast);
+      const withDict = compressHast(rawJson);
+      const withoutDict = encode(deflateSync(strToU8(rawJson), { level: 9 }));
+
+      expect(rawJson).toContain('"dataFrameType":"comment"');
+      expect(rawJson).toContain('"value":"NavigationMenu"');
+      expect(rawJson).toContain('"value":"Root"');
+      expect(rawJson).toContain('"value":"State"');
+      expect(rawJson).toContain('"dataLn":');
+      expect(withDict.length).toBeLessThan(withoutDict.length);
+    });
+
+    it('may not help (or even hurt) for data that does not match the dictionary', () => {
+      // Random-ish data unlikely to benefit from the HAST dictionary
+      const randomData = JSON.stringify(
+        Array.from({ length: 200 }, (_, i) => ({
+          id: `item-${i}`,
+          score: Math.sin(i) * 1000,
+          tags: [`alpha-${i}`, `beta-${i}`],
+        })),
+      );
+
+      const withDict = compressHast(randomData);
+      const withoutDict = encode(deflateSync(strToU8(randomData), { level: 9 }));
+
+      // The dictionary-compressed version should not be dramatically larger
+      // (DEFLATE gracefully ignores an unhelpful dictionary), but it does not
+      // need to be smaller either.
+      expect(withDict.length).toBeLessThan(withoutDict.length * 1.1);
+    });
+
+    it('keeps the raw dictionary under the intended size budget', () => {
+      expect(HAST_DICTIONARY.byteLength).toBeLessThan(4 * 1024);
+    });
+  });
+
+  describe('dictionary mutation tolerance', () => {
+    it('can still decompress after prepending new entries to the dictionary', () => {
+      const compressed = compressHast(SAMPLE_HAST_JSON);
+
+      // DEFLATE dictionaries use only the *last* 32 KiB of the buffer, so
+      // prepending keeps the tail identical and existing payloads still work.
+      const extraEntries = 'SomeNewComponent,anotherProp,data-extra';
+      const prependedDict = new Uint8Array(extraEntries.length + HAST_DICTIONARY.length);
+      prependedDict.set(strToU8(extraEntries), 0);
+      prependedDict.set(HAST_DICTIONARY, extraEntries.length);
+
+      const raw = decode(compressed);
+      const decompressed = strFromU8(inflateSync(raw, { dictionary: prependedDict }));
+      expect(decompressed).toBe(SAMPLE_HAST_JSON);
+    });
+
+    it('fails to decompress when dictionary entries are removed', () => {
+      const compressed = compressHast(SAMPLE_HAST_JSON);
+
+      // Use a truncated dictionary (remove the last 500 bytes)
+      const truncatedDict = HAST_DICTIONARY.slice(0, HAST_DICTIONARY.length - 500);
+
+      // DEFLATE may throw with a wrong dictionary, or it may decode to
+      // corrupted bytes. Either outcome means the payload is unusable.
+      const raw = decode(compressed);
+      let output: string;
+      try {
+        output = strFromU8(inflateSync(raw, { dictionary: truncatedDict }));
+      } catch {
+        return;
+      }
+      expect(output).not.toBe(SAMPLE_HAST_JSON);
+    });
+
+    it('produces corrupted output when dictionary entries are reordered', () => {
+      const compressed = compressHast(SAMPLE_HAST_JSON);
+
+      // Reverse the dictionary bytes — same content, different order
+      const reversed = new Uint8Array(HAST_DICTIONARY.length);
+      for (let i = 0; i < HAST_DICTIONARY.length; i += 1) {
+        reversed[i] = HAST_DICTIONARY[HAST_DICTIONARY.length - 1 - i];
+      }
+
+      // DEFLATE may not throw with a wrong dictionary — it can silently
+      // produce garbage. We verify the output is not the original.
+      const raw = decode(compressed);
+      let output: string;
+      try {
+        output = strFromU8(inflateSync(raw, { dictionary: reversed }));
+      } catch {
+        // Throwing is also acceptable — the data is unusable either way
+        return;
+      }
+      expect(output).not.toBe(SAMPLE_HAST_JSON);
+    });
+  });
+});
diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
index c8e404720..1817685c5 100644
--- a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
@@ -9,14 +9,15 @@ import { encode, decode } from 'uint8-to-base64';
  * embedded in both the server build and the client bundle, so it must stay
  * small — currently ~3 KB uncompressed.
  *
- * IMPORTANT: Changing this dictionary is a **breaking change** for any
- * previously-compressed payloads. When the dictionary is updated, all cached
- * or persisted `hastCompressed` strings become undecodable. Bump the dictionary only
- * between major precomputed data regeneration cycles.
+ * IMPORTANT: Once compressed payloads are shipped or cached, removing or
+ * reordering entries in this dictionary becomes a **breaking change**. Any
+ * such change requires regenerating all persisted `hastCompressed` strings.
  */
-const HAST_DICTIONARY = strToU8(
+export const HAST_DICTIONARY = strToU8(
   [
     // JSON structural patterns (most frequent first)
+    '{"type":"element","tagName":"span","properties":{"className":["frame"],"dataFrameType":"',
+    '{"type":"element","tagName":"span","properties":{"className":["line"],"dataLn":',
     '{"type":"element","tagName":"span","properties":{"className":["',
     '{"type":"element","tagName":"a","properties":{"href":"',
     '{"type":"text","value":"',
@@ -65,8 +66,11 @@ const HAST_DICTIONARY = strToU8(
     '"className":["frame"]',
     '"className":["line"]',
     '"dataFrameType":"highlighted"',
+    '"dataFrameType":"comment"',
+    '"dataFrameType":"property"',
     '"dataFrameIndent":',
     '"dataLn":',
+    '"dataType":"TypeRef"',
     // Common TypeScript keywords (appear as highlighted spans)
     'interface',
     'export',
@@ -128,6 +132,15 @@ const HAST_DICTIONARY = strToU8(
     'React.FC',
     'React.Dispatch',
     'React.SetStateAction',
+    'React.useState',
+    'React.useEffect',
+    'React.useMemo',
+    'React.useCallback',
+    'React.useRef',
+    'React.useContext',
+    'React.useReducer',
+    'React.JSX.Element',
+    'JSX.Element',
     // Common DOM types
     'HTMLElement',
     'HTMLDivElement',
@@ -139,6 +152,7 @@ const HAST_DICTIONARY = strToU8(
     'HTMLFormElement',
     'HTMLSpanElement',
     'HTMLLabelElement',
+    'ShadowRoot',
     'Element',
     'EventTarget',
     // Common event types
@@ -157,10 +171,39 @@ const HAST_DICTIONARY = strToU8(
     ' }',
     '(): ',
     '(event: ',
+    '(state: ',
+    '(props: HTMLProps, state: ',
+    ', eventDetails: ',
     ': string',
     ': number',
     ': boolean',
     ': void',
+    ') => string | undefined',
+    ') => React.CSSProperties | undefined',
+    ') => ReactElement',
+    '(open: boolean) => void',
+    '(): JSX.Element',
+    '(): React.JSX.Element',
+    'useMemo<',
+    'useCallback<',
+    'useReducer<',
+    'React.useMemo<',
+    'React.useCallback<',
+    'React.useReducer<',
+    'Dispatch',
+    '',
+    '/>',
+    '=> <',
+    '{children}',
+    'className={',
     // Common prop names (from typeOrder)
     'className',
     'children',
@@ -169,6 +212,9 @@ const HAST_DICTIONARY = strToU8(
     'render',
     'defaultValue',
     'value',
+    'onClick',
+    'onChange',
+    'onSubmit',
     'onValueChange',
     'defaultOpen',
     'open',
@@ -202,6 +248,9 @@ const HAST_DICTIONARY = strToU8(
     'data-invalid',
     'data-required',
     'data-readonly',
+    'aria-label',
+    'aria-describedby',
+    'aria-expanded',
     // Common component part names
     'Root',
     'Trigger',
@@ -215,10 +264,55 @@ const HAST_DICTIONARY = strToU8(
     'Group',
     'Track',
     'Thumb',
+    'AlertDialog.',
+    'Autocomplete.',
+    'NumberField.',
+    'NavigationMenu.',
+    'Accordion.',
+    'Checkbox.',
+    'Combobox.',
+    'ContextMenu.',
+    'Dialog.',
+    'Popover.',
+    'Radio.',
+    'Select.',
+    'Slider.',
+    'Switch.',
+    'Tabs.',
+    'Toggle.',
+    'Tooltip.',
+    // Common React hook names
+    'useState',
+    'useEffect',
+    'useMemo',
+    'useCallback',
+    'useRef',
+    'useContext',
+    'useReducer',
     // Common type suffixes
     'Props',
+    '.State',
+    '.ChangeEventDetails',
     'DataAttributes',
     'CssVars',
+    // Common Base UI event and state tokens
+    'BaseUIEvent',
+    'TransitionStatus',
+    'SeparatorState',
+    'reason',
+    'allowPropagation',
+    'isCanceled',
+    'isPropagationAllowed',
+    'itemValue',
+    'inline-start',
+    'inline-end',
+    'trigger-press',
+    'outside-press',
+    'focus-out',
+    'list-navigation',
+    'escape-key',
+    'item-press',
+    'close-press',
   ].join(''),
 );
 

From aa6a058647434a6d25ece86d3b9ac1ae372de1e8 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Wed, 1 Apr 2026 17:07:07 -0400
Subject: [PATCH 26/61] Update docs

---
 .../docs-infra/pipeline/hast-utils/types.md   | 19 +++++++++++++++++++
 docs/app/docs-infra/pipeline/page.mdx         |  1 +
 2 files changed, 20 insertions(+)

diff --git a/docs/app/docs-infra/pipeline/hast-utils/types.md b/docs/app/docs-infra/pipeline/hast-utils/types.md
index 0ed184f89..9728e9ddf 100644
--- a/docs/app/docs-infra/pipeline/hast-utils/types.md
+++ b/docs/app/docs-infra/pipeline/hast-utils/types.md
@@ -133,3 +133,22 @@ type ReturnValue = React.ReactNode;
 ```tsx
 type ReturnValue = string;
 ```
+
+## Additional Types
+
+### HAST_DICTIONARY
+
+Shared dictionary for DEFLATE compression of HAST JSON.
+
+Contains byte sequences that frequently appear in JSON-serialized HAST trees
+(syntax-highlighted TypeScript type documentation). The dictionary is
+embedded in both the server build and the client bundle, so it must stay
+small — currently \~3 KB uncompressed.
+
+IMPORTANT: Once compressed payloads are shipped or cached, removing or
+reordering entries in this dictionary becomes a **breaking change**. Any
+such change requires regenerating all persisted `hastCompressed` strings.
+
+```typescript
+type HAST_DICTIONARY = Uint8Array;
+```
diff --git a/docs/app/docs-infra/pipeline/page.mdx b/docs/app/docs-infra/pipeline/page.mdx
index 30ac41fae..57ff95d6e 100644
--- a/docs/app/docs-infra/pipeline/page.mdx
+++ b/docs/app/docs-infra/pipeline/page.mdx
@@ -307,6 +307,7 @@ The `hastUtils` module provides utilities for converting between HAST (Hypertext
     - Parameters: source, highlighted, components
   - stringOrHastToString
     - Parameters: source
+- Types: HAST_DICTIONARY
 
 
 

From b4ae3aab3dd2517e492284183950f5022eb7b41d Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Fri, 3 Apr 2026 11:35:21 -0400
Subject: [PATCH 27/61] Add text content into compression pipeline

---
 docs/app/docs-infra/patterns/page.mdx         |   3 +
 .../patterns/prop-compression/page.mdx        |  55 +++
 .../docs-infra/pipeline/hast-utils/types.md   | 173 +++++++-
 docs/app/docs-infra/pipeline/page.mdx         |  17 +-
 .../DeferredHighlightClient.tsx               |  45 +-
 .../src/pipeline/hastUtils/hastCompress.ts    |  59 +++
 .../hastUtils/hastCompression.test.ts         | 215 ++++++++++
 .../src/pipeline/hastUtils/hastCompression.ts | 381 +----------------
 .../src/pipeline/hastUtils/hastDecompress.ts  |  83 ++++
 .../src/pipeline/hastUtils/hastDictionary.ts  | 404 ++++++++++++++++++
 .../src/pipeline/hastUtils/index.ts           |   1 +
 .../hastUtils}/stripHighlightingSpans.test.ts |  35 ++
 .../hastUtils}/stripHighlightingSpans.ts      |  11 +-
 13 files changed, 1074 insertions(+), 408 deletions(-)
 create mode 100644 packages/docs-infra/src/pipeline/hastUtils/hastCompress.ts
 create mode 100644 packages/docs-infra/src/pipeline/hastUtils/hastDecompress.ts
 create mode 100644 packages/docs-infra/src/pipeline/hastUtils/hastDictionary.ts
 rename packages/docs-infra/src/{abstractCreateTypes => pipeline/hastUtils}/stripHighlightingSpans.test.ts (92%)
 rename packages/docs-infra/src/{abstractCreateTypes => pipeline/hastUtils}/stripHighlightingSpans.ts (85%)

diff --git a/docs/app/docs-infra/patterns/page.mdx b/docs/app/docs-infra/patterns/page.mdx
index 1f186d41c..cf802f832 100644
--- a/docs/app/docs-infra/patterns/page.mdx
+++ b/docs/app/docs-infra/patterns/page.mdx
@@ -119,6 +119,9 @@ HAST is the core data structure used throughout this package for syntax highligh
     - Page Weight (Type Documentation)
   - When Not to Use This Pattern
   - Implementation Details
+    - Content-Aware Dictionary
+      - Checksum Verification
+      - Server → Client Data Flow
     - Decompression & Rendering
     - How Compressed Data Arrives
     - Abstracting Complexity from Users
diff --git a/docs/app/docs-infra/patterns/prop-compression/page.mdx b/docs/app/docs-infra/patterns/prop-compression/page.mdx
index f6ee82d12..93f01a517 100644
--- a/docs/app/docs-infra/patterns/prop-compression/page.mdx
+++ b/docs/app/docs-infra/patterns/prop-compression/page.mdx
@@ -110,6 +110,7 @@ predictable behavior across clients.
 | Plain Text Hydrated  | 87 bytes     | 87 bytes      | 0 bytes         | = 174 bytes  | 200% |
 | HTML String in Props | 193 bytes    | 193 bytes     | 0 bytes         | = 386 bytes  | 444% |
 | Compressed Props     | 87 bytes     | 87 bytes      | 320 bytes       | = 494 bytes  | 568% |
+| Compressed with Dict | 87 bytes     | 87 bytes      | 168 bytes       | = 342 bytes  | 393% |
 
 Using HTML String in Props requires `dangerouslySetInnerHTML` to render, which
 can have security implications when content is untrusted. To add interactivity,
@@ -126,6 +127,7 @@ With [a large code snippet](https://github.com/mui/mui-public/blob/530ec94f97bfe
 | Plain Text Hydrated  | 47 KB        | 47 KB         | 0 bytes         | = 94 KB      | 200% |
 | HTML String in Props | 203 KB       | 203 KB        | 0 bytes         | = 406 KB     | 864% |
 | Compressed Props     | 47 KB        | 47 KB         | 54 KB           | = 149 KB     | 317% |
+| Compressed with Dict | 47 KB        | 47 KB         | 53 KB           | = 148 KB     | 315% |
 
 ---
 
@@ -191,6 +193,59 @@ in the server-rendered markup.
 
 ## 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 — 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 —
+this prevents silently rendering 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 HAST rather than
+passed as a separate prop. On the server, the `hastToJsxDeferred` function
+builds a stripped fallback HAST (highlighting spans removed), extracts its
+text content via `toText()`, and uses it as the DEFLATE dictionary. The
+fallback HAST is then passed as a `fallbackHast` prop to the client
+component. On the client, the same `toText()` call reconstructs the
+identical dictionary for decompression — no duplicate text string crosses
+the boundary.
+
 ### Decompression & Rendering
 
 After the component enters the viewport, we decompress and parse the JSON
diff --git a/docs/app/docs-infra/pipeline/hast-utils/types.md b/docs/app/docs-infra/pipeline/hast-utils/types.md
index 9728e9ddf..5abe7d202 100644
--- a/docs/app/docs-infra/pipeline/hast-utils/types.md
+++ b/docs/app/docs-infra/pipeline/hast-utils/types.md
@@ -4,16 +4,51 @@
 
 ## API Reference
 
+### buildDictionary
+
+Build a DEFLATE dictionary by combining optional text content with the
+static `HAST_DICTIONARY`.
+
+Layout: `[textContent bytes (truncated)][HAST_DICTIONARY]`
+
+- `HAST_DICTIONARY` is always at the **end** so it falls within DEFLATE's
+  32 KiB window regardless of text length.
+- `textContent` is truncated from the **end** (keeps the start, which
+  corresponds to the first-rendered / most important content).
+
+When `textContent` is omitted or empty, returns `HAST_DICTIONARY` as-is.
+
+**Parameters:**
+
+| Parameter    | Type     | Default | Description |
+| :----------- | :------- | :------ | :---------- |
+| textContent? | `string` | -       | -           |
+
+**Return Value:**
+
+```tsx
+type ReturnValue = Uint8Array;
+```
+
 ### compressHast
 
 Compress a JSON string using DEFLATE with the shared HAST dictionary.
 Returns a base64-encoded string suitable for embedding in serialized props.
 
+When `textContent` is provided, the text is prepended to the static
+dictionary for better compression of payloads that repeat their own text.
+A 4-byte checksum is embedded so `decompressHast` can verify the same
+`textContent` was supplied.
+
+When `textContent` is omitted, only the static dictionary is used and no
+checksum is embedded (opt-out / backward-compatible path).
+
 **Parameters:**
 
-| Parameter | Type     | Default | Description |
-| :-------- | :------- | :------ | :---------- |
-| json      | `string` | -       | -           |
+| Parameter    | Type     | Default | Description |
+| :----------- | :------- | :------ | :---------- |
+| json         | `string` | -       | -           |
+| textContent? | `string` | -       | -           |
 
 **Return Value:**
 
@@ -23,14 +58,17 @@ type ReturnValue = string;
 
 ### compressHastAsync
 
-Compress a string asynchronously using DEFLATE with the shared HAST dictionary.
-Returns a base64-encoded string.
+Compress a string asynchronously using DEFLATE with the shared HAST
+dictionary. Returns a base64-encoded string.
+
+See `compressHast` for `textContent` semantics.
 
 **Parameters:**
 
-| Parameter | Type     | Default | Description |
-| :-------- | :------- | :------ | :---------- |
-| input     | `string` | -       | -           |
+| Parameter    | Type     | Default | Description |
+| :----------- | :------- | :------ | :---------- |
+| input        | `string` | -       | -           |
+| textContent? | `string` | -       | -           |
 
 **Return Value:**
 
@@ -38,18 +76,47 @@ Returns a base64-encoded string.
 type ReturnValue = Promise;
 ```
 
+### computeDictionaryChecksum
+
+FNV-1a 32-bit hash of a Uint8Array.
+
+Used to detect dictionary mismatches between compression and decompression.
+This is NOT cryptographic — it catches programming errors (wrong
+`textContent` passed), not adversarial tampering.
+
+Returns 4 bytes in big-endian order.
+
+**Parameters:**
+
+| Parameter | Type         | Default | Description |
+| :-------- | :----------- | :------ | :---------- |
+| dict      | `Uint8Array` | -       | -           |
+
+**Return Value:**
+
+```tsx
+type ReturnValue = Uint8Array;
+```
+
 ### decompressHast
 
 Decompress a base64-encoded DEFLATE payload that was compressed with
 `compressHast`. Returns the original JSON string.
 
-Throws if the payload was not compressed with the matching dictionary.
+When `textContent` is provided, the first 4 bytes of the decoded payload
+are treated as a dictionary checksum. If the checksum does not match the
+dictionary built from `textContent`, a `HastDictionaryMismatchError` is
+thrown — this prevents silently rendering corrupted data.
+
+When `textContent` is omitted, only the static dictionary is used for
+decompression and no checksum verification is performed.
 
 **Parameters:**
 
-| Parameter | Type     | Default | Description |
-| :-------- | :------- | :------ | :---------- |
-| base64    | `string` | -       | -           |
+| Parameter    | Type     | Default | Description |
+| :----------- | :------- | :------ | :---------- |
+| base64       | `string` | -       | -           |
+| textContent? | `string` | -       | -           |
 
 **Return Value:**
 
@@ -62,11 +129,14 @@ type ReturnValue = string;
 Decompress a base64-encoded DEFLATE payload asynchronously.
 Returns the original JSON string.
 
+See `decompressHast` for `textContent` semantics.
+
 **Parameters:**
 
-| Parameter | Type     | Default | Description |
-| :-------- | :------- | :------ | :---------- |
-| base64    | `string` | -       | -           |
+| Parameter    | Type     | Default | Description |
+| :----------- | :------- | :------ | :---------- |
+| base64       | `string` | -       | -           |
+| textContent? | `string` | -       | -           |
 
 **Return Value:**
 
@@ -74,6 +144,28 @@ Returns the original JSON string.
 type ReturnValue = Promise;
 ```
 
+### HastDictionaryMismatchError
+
+Error thrown when the dictionary checksum in a compressed payload does not
+match the dictionary built from the provided `textContent`.
+
+**Static Methods:**
+
+```typescript
+function isError(error: unknown): boolean;
+```
+
+Indicates whether the argument provided is a built-in Error instance or not.
+
+**Properties:**
+
+| Property | Type      | Modifiers | Description |
+| :------- | :-------- | :-------- | :---------- |
+| name     | `string`  | -         | -           |
+| message  | `string`  | -         | -           |
+| stack?   | `string`  | -         | -           |
+| cause?   | `unknown` | -         | -           |
+
 ### hastOrJsonToJsx
 
 **Parameters:**
@@ -134,8 +226,44 @@ type ReturnValue = React.ReactNode;
 type ReturnValue = string;
 ```
 
+### stripHighlightingSpans
+
+Strip syntax-highlighting `` elements from a HAST tree while preserving
+semantic structure and text content. Produces a "links-only" version of the
+tree suitable as a lightweight server-rendered fallback for deferred highlighting.
+
+- Highlighting `` elements (e.g. `pl-k`, `pl-smi`, `line`): removed, children promoted
+- Frame `` elements (`frame`): preserved with their data attributes
+- `` elements: preserved, children recursively processed
+- text nodes: preserved, adjacent text nodes merged
+- other elements (pre, code, etc.): preserved, children recursively processed
+
+Does not mutate the input tree.
+
+**Parameters:**
+
+| Parameter | Type   | Default | Description |
+| :-------- | :----- | :------ | :---------- |
+| root      | `Root` | -       | -           |
+
+**Return Value:**
+
+```tsx
+type ReturnValue = Root;
+```
+
 ## Additional Types
 
+### CHECKSUM_BYTES
+
+Checksum byte length embedded in compressed payloads that use a text
+dictionary. The checksum lets `decompressHast` verify that the caller
+supplied the same `textContent` that was used during compression.
+
+```typescript
+type CHECKSUM_BYTES = 4;
+```
+
 ### HAST_DICTIONARY
 
 Shared dictionary for DEFLATE compression of HAST JSON.
@@ -145,10 +273,17 @@ Contains byte sequences that frequently appear in JSON-serialized HAST trees
 embedded in both the server build and the client bundle, so it must stay
 small — currently \~3 KB uncompressed.
 
-IMPORTANT: Once compressed payloads are shipped or cached, removing or
-reordering entries in this dictionary becomes a **breaking change**. Any
-such change requires regenerating all persisted `hastCompressed` strings.
-
 ```typescript
 type HAST_DICTIONARY = Uint8Array;
 ```
+
+### MAX_DICTIONARY_SIZE
+
+Maximum size of the DEFLATE dictionary in bytes.
+
+DEFLATE uses only the last 32 KiB of the dictionary buffer.
+Any content beyond this limit is ignored by the compressor.
+
+```typescript
+type MAX_DICTIONARY_SIZE = number;
+```
diff --git a/docs/app/docs-infra/pipeline/page.mdx b/docs/app/docs-infra/pipeline/page.mdx
index 57ff95d6e..4e0f2d903 100644
--- a/docs/app/docs-infra/pipeline/page.mdx
+++ b/docs/app/docs-infra/pipeline/page.mdx
@@ -291,14 +291,19 @@ The `hastUtils` module provides utilities for converting between HAST (Hypertext
   - Additional Types
   - When to Use
 - Exports:
+  - buildDictionary
+    - Parameters: textContent
   - compressHast
-    - Parameters: json
+    - Parameters: json, textContent
   - compressHastAsync
-    - Parameters: input
+    - Parameters: input, textContent
+  - computeDictionaryChecksum
+    - Parameters: dict
   - decompressHast
-    - Parameters: base64
+    - Parameters: base64, textContent
   - decompressHastAsync
-    - Parameters: base64
+    - Parameters: base64, textContent
+  - HastDictionaryMismatchError
   - hastOrJsonToJsx
     - Parameters: hastOrJson, components
   - hastToJsx
@@ -307,7 +312,9 @@ The `hastUtils` module provides utilities for converting between HAST (Hypertext
     - Parameters: source, highlighted, components
   - stringOrHastToString
     - Parameters: source
-- Types: HAST_DICTIONARY
+  - stripHighlightingSpans
+    - Parameters: root
+- Types: CHECKSUM_BYTES, HAST_DICTIONARY, MAX_DICTIONARY_SIZE
 
 
 
diff --git a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
index 30b86669f..cff4fa0a3 100644
--- a/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
+++ b/packages/docs-infra/src/abstractCreateTypes/DeferredHighlightClient.tsx
@@ -3,6 +3,8 @@ import * as React from 'react';
 import type { Root as HastRoot, Element as HastElement } from 'hast';
 import { decompressHast, hastToJsx } from '../pipeline/hastUtils';
 import { useCodeComponents } from '../useCode/CodeComponentsContext';
+import type { FallbackNode } from '../CodeHighlighter/fallbackFormat';
+import { fallbackToHast, fallbackToText } from '../CodeHighlighter/fallbackFormat';
 
 type HighlightAt = 'hydration' | 'idle';
 
@@ -13,8 +15,15 @@ interface DeferredHighlightClientProps {
   hastCompressed?: string;
   /** When to replace the fallback with the fully-highlighted version. */
   highlightAt: HighlightAt;
-  /** Server-rendered links-only fallback. */
-  children?: React.ReactNode;
+  /**
+   * Links-only fallback (code children with highlighting spans stripped),
+   * in compact `FallbackNode[]` format.
+   * Serves two purposes:
+   * 1. Rendered as the initial display until the full highlight is ready.
+   * 2. Its text content is used as a DEFLATE dictionary for decompression
+   *    when `hastCompressed` was compressed with that same text dictionary.
+   */
+  fallback?: FallbackNode[];
 }
 
 /**
@@ -39,22 +48,40 @@ function findCodeChildren(node: HastRoot | HastElement): HastRoot['children'] |
  * Renders a links-only fallback on the server and replaces it with the
  * fully syntax-highlighted version on the client at the configured time.
  *
- * Receives the full HAST tree (root > pre > code > children) and extracts
- * the code element's children for rendering. The outer `
` / `TypePre`
- * wrapper stays server-rendered.
+ * When `fallback` is provided, it is converted to HAST and rendered for the
+ * initial display. Its text content is derived (via `fallbackToText`) to serve
+ * as the DEFLATE dictionary for decompressing `hastCompressed`.
  */
 export function DeferredHighlightClient({
   hastJson,
   hastCompressed,
   highlightAt,
-  children,
+  fallback,
 }: DeferredHighlightClientProps) {
   const components = useCodeComponents();
   const [hast, setHast] = React.useState(null);
 
+  // Convert compact fallback to HAST for rendering.
+  const fallbackHastRoot = React.useMemo(
+    () => (fallback ? fallbackToHast(fallback) : undefined),
+    [fallback],
+  );
+
+  // Derive text dictionary from fallback for decompression.
+  const textDictionary = React.useMemo(
+    () => (fallback ? fallbackToText(fallback) : undefined),
+    [fallback],
+  );
+
+  // Render fallback HAST as JSX for initial display.
+  const fallbackJsx = React.useMemo(
+    () => (fallbackHastRoot ? hastToJsx(fallbackHastRoot, components) : null),
+    [fallbackHastRoot, components],
+  );
+
   React.useEffect(() => {
     const parse = () => {
-      const raw = hastCompressed ? decompressHast(hastCompressed) : hastJson!;
+      const raw = hastCompressed ? decompressHast(hastCompressed, textDictionary) : hastJson!;
       const parsed = JSON.parse(raw);
 
       // Extract code element's children from the full tree.
@@ -79,7 +106,7 @@ export function DeferredHighlightClient({
     }
     const id = setTimeout(parse, 0);
     return () => clearTimeout(id);
-  }, [hastJson, hastCompressed, highlightAt, components]);
+  }, [hastJson, hastCompressed, highlightAt, components, textDictionary]);
 
   const highlighted = React.useMemo(
     () => (hast !== null ? hastToJsx(hast, components) : null),
@@ -90,5 +117,5 @@ export function DeferredHighlightClient({
     return highlighted;
   }
 
-  return children;
+  return fallbackJsx;
 }
diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastCompress.ts b/packages/docs-infra/src/pipeline/hastUtils/hastCompress.ts
new file mode 100644
index 000000000..ccd93da7c
--- /dev/null
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastCompress.ts
@@ -0,0 +1,59 @@
+import { deflateSync, deflate, strToU8 } from 'fflate';
+import { encode } from 'uint8-to-base64';
+import { buildDictionary, computeDictionaryChecksum, CHECKSUM_BYTES } from './hastDictionary';
+
+/**
+ * Compress a JSON string using DEFLATE with the shared HAST dictionary.
+ * Returns a base64-encoded string suitable for embedding in serialized props.
+ *
+ * When `textContent` is provided, the text is prepended to the static
+ * dictionary for better compression of payloads that repeat their own text.
+ * A 4-byte checksum is embedded so `decompressHast` can verify the same
+ * `textContent` was supplied.
+ *
+ * When `textContent` is omitted, only the static dictionary is used and no
+ * checksum is embedded (opt-out / backward-compatible path).
+ */
+export function compressHast(json: string, textContent?: string): string {
+  const dictionary = buildDictionary(textContent);
+  const deflated = deflateSync(strToU8(json), { level: 9, dictionary });
+
+  if (textContent != null) {
+    const checksum = computeDictionaryChecksum(dictionary);
+    const payload = new Uint8Array(CHECKSUM_BYTES + deflated.byteLength);
+    payload.set(checksum, 0);
+    payload.set(deflated, CHECKSUM_BYTES);
+    return encode(payload);
+  }
+
+  return encode(deflated);
+}
+
+/**
+ * Compress a string asynchronously using DEFLATE with the shared HAST
+ * dictionary. Returns a base64-encoded string.
+ *
+ * See `compressHast` for `textContent` semantics.
+ */
+export function compressHastAsync(input: string, textContent?: string): Promise {
+  const dictionary = buildDictionary(textContent);
+  const checksumBytes = textContent != null ? computeDictionaryChecksum(dictionary) : null;
+
+  return new Promise((resolve, reject) => {
+    deflate(strToU8(input), { consume: true, level: 9, dictionary }, (err, output) => {
+      if (err) {
+        reject(err);
+        return;
+      }
+
+      if (checksumBytes) {
+        const payload = new Uint8Array(CHECKSUM_BYTES + output.byteLength);
+        payload.set(checksumBytes, 0);
+        payload.set(output, CHECKSUM_BYTES);
+        resolve(encode(payload));
+      } else {
+        resolve(encode(output));
+      }
+    });
+  });
+}
diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.test.ts b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.test.ts
index c4356be33..c95334a75 100644
--- a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.test.ts
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.test.ts
@@ -9,6 +9,11 @@ import {
   compressHastAsync,
   decompressHastAsync,
   HAST_DICTIONARY,
+  buildDictionary,
+  computeDictionaryChecksum,
+  HastDictionaryMismatchError,
+  MAX_DICTIONARY_SIZE,
+  CHECKSUM_BYTES,
 } from './hastCompression';
 
 describe('hastCompression', () => {
@@ -384,4 +389,214 @@ describe('hastCompression', () => {
       expect(output).not.toBe(SAMPLE_HAST_JSON);
     });
   });
+
+  describe('textContent dictionary', () => {
+    const TEXT_CONTENT =
+      'interface ButtonProps {\n  children: React.ReactNode;\n  disabled?: boolean;\n}';
+
+    describe('sync roundtrip with textContent', () => {
+      it('compresses and decompresses with matching textContent', () => {
+        const compressed = compressHast(SAMPLE_HAST_JSON, TEXT_CONTENT);
+        const decompressed = decompressHast(compressed, TEXT_CONTENT);
+        expect(decompressed).toBe(SAMPLE_HAST_JSON);
+      });
+
+      it('compresses and decompresses simple JSON with textContent', () => {
+        const compressed = compressHast(SIMPLE_JSON, TEXT_CONTENT);
+        const decompressed = decompressHast(compressed, TEXT_CONTENT);
+        expect(decompressed).toBe(SIMPLE_JSON);
+      });
+
+      it('compresses and decompresses an empty string with textContent', () => {
+        const compressed = compressHast('', TEXT_CONTENT);
+        const decompressed = decompressHast(compressed, TEXT_CONTENT);
+        expect(decompressed).toBe('');
+      });
+    });
+
+    describe('async roundtrip with textContent', () => {
+      it('compresses and decompresses with matching textContent', async () => {
+        const compressed = await compressHastAsync(SAMPLE_HAST_JSON, TEXT_CONTENT);
+        const decompressed = await decompressHastAsync(compressed, TEXT_CONTENT);
+        expect(decompressed).toBe(SAMPLE_HAST_JSON);
+      });
+
+      it('sync compressed with textContent can be decompressed async', async () => {
+        const compressed = compressHast(SAMPLE_HAST_JSON, TEXT_CONTENT);
+        const decompressed = await decompressHastAsync(compressed, TEXT_CONTENT);
+        expect(decompressed).toBe(SAMPLE_HAST_JSON);
+      });
+
+      it('async compressed with textContent can be decompressed sync', async () => {
+        const compressed = await compressHastAsync(SAMPLE_HAST_JSON, TEXT_CONTENT);
+        const decompressed = decompressHast(compressed, TEXT_CONTENT);
+        expect(decompressed).toBe(SAMPLE_HAST_JSON);
+      });
+    });
+
+    describe('mismatch detection', () => {
+      it('throws HastDictionaryMismatchError when textContent differs', () => {
+        const compressed = compressHast(SAMPLE_HAST_JSON, TEXT_CONTENT);
+        expect(() => decompressHast(compressed, 'completely different text')).toThrow(
+          HastDictionaryMismatchError,
+        );
+      });
+
+      it('throws when decompressing with textContent but compressed without', () => {
+        const compressed = compressHast(SAMPLE_HAST_JSON);
+        // The payload has no checksum prefix, so the first 4 bytes are deflate
+        // data — the checksum comparison will almost certainly fail.
+        expect(() => decompressHast(compressed, TEXT_CONTENT)).toThrow(HastDictionaryMismatchError);
+      });
+
+      it('throws or produces wrong output when decompressing without textContent but compressed with', () => {
+        const compressed = compressHast(SAMPLE_HAST_JSON, TEXT_CONTENT);
+        // Without textContent, the 4-byte checksum prefix is fed into inflate
+        // as part of the deflate stream, which should either throw or produce
+        // corrupted output.
+        let result: string;
+        try {
+          result = decompressHast(compressed);
+        } catch {
+          return; // Throwing is the expected path
+        }
+        expect(result).not.toBe(SAMPLE_HAST_JSON);
+      });
+
+      it('rejects when textContent differs', async () => {
+        const compressed = compressHast(SAMPLE_HAST_JSON, TEXT_CONTENT);
+        await expect(decompressHastAsync(compressed, 'wrong text')).rejects.toThrow(
+          HastDictionaryMismatchError,
+        );
+      });
+    });
+
+    describe('textContent dictionary effectiveness', () => {
+      it('produces smaller output than static-only dictionary for matching HAST data', () => {
+        const withText = compressHast(SAMPLE_HAST_JSON, TEXT_CONTENT);
+        const withoutText = compressHast(SAMPLE_HAST_JSON);
+
+        // The textContent dictionary should help because the HAST JSON
+        // literally contains the text content as node values.
+        // Account for 4-byte checksum overhead in the textContent version.
+        expect(withText.length).toBeLessThan(withoutText.length + 10);
+      });
+
+      it('produces smaller output for type-prop HAST with matching text', () => {
+        const textContent =
+          'The class name to apply to the root element.\n' +
+          'className?: NumberField.Root.State | undefined\n' +
+          '(state: NavigationMenu.Root.State) => React.CSSProperties | undefined\n' +
+          '(value: TValue | null, eventDetails: NavigationMenu.Root.ChangeEventDetails) => void\n' +
+          '(props: HTMLProps, state: NavigationMenu.Root.State) => ReactElement';
+
+        const withText = compressHast(TYPE_PROP_HAST_JSON, textContent);
+        const withoutText = compressHast(TYPE_PROP_HAST_JSON);
+
+        expect(withText.length).toBeLessThan(withoutText.length);
+      });
+
+      it('produces smaller output for real extracted type-prop HAST with text dictionary', async () => {
+        const typeSource = `{
+  /** Whether the button is interactive */
+  disabled?: boolean;
+  /** Optional inline style override */
+  style?: React.CSSProperties;
+  /** Render props for a navigation menu item */
+  render?: (props: HTMLProps, state: NavigationMenu.Root.State) => ReactElement;
+}`;
+
+        const highlighted = await formatDetailedTypeAsHast(typeSource);
+        const extracted = extractTypeProps(highlighted);
+        const rawJson = JSON.stringify(extracted.hast);
+
+        // Extract the text that would be visible to the user
+        const textContent =
+          'Whether the button is interactive\n' +
+          'disabled?: boolean\n' +
+          'Optional inline style override\n' +
+          'style?: React.CSSProperties\n' +
+          'Render props for a navigation menu item\n' +
+          'render?: (props: HTMLProps, state: NavigationMenu.Root.State) => ReactElement';
+
+        const withText = compressHast(rawJson, textContent);
+        const withoutText = compressHast(rawJson);
+
+        expect(withText.length).toBeLessThan(withoutText.length);
+      });
+    });
+  });
+
+  describe('buildDictionary', () => {
+    it('returns HAST_DICTIONARY when textContent is omitted', () => {
+      const dict = buildDictionary();
+      expect(dict).toBe(HAST_DICTIONARY);
+    });
+
+    it('returns HAST_DICTIONARY when textContent is empty', () => {
+      const dict = buildDictionary('');
+      expect(dict).toBe(HAST_DICTIONARY);
+    });
+
+    it('prepends text bytes before HAST_DICTIONARY', () => {
+      const dict = buildDictionary('hello');
+      const textPart = strFromU8(dict.slice(0, 5));
+      expect(textPart).toBe('hello');
+      // The tail should be the static dictionary
+      const tailPart = dict.slice(5);
+      expect(tailPart.byteLength).toBe(HAST_DICTIONARY.byteLength);
+      for (let i = 0; i < HAST_DICTIONARY.byteLength; i += 1) {
+        expect(tailPart[i]).toBe(HAST_DICTIONARY[i]);
+      }
+    });
+
+    it('truncates text from the end to fit within MAX_DICTIONARY_SIZE', () => {
+      const longText = 'x'.repeat(MAX_DICTIONARY_SIZE);
+      const dict = buildDictionary(longText);
+      expect(dict.byteLength).toBe(MAX_DICTIONARY_SIZE);
+      // The tail should still be HAST_DICTIONARY
+      const tail = dict.slice(dict.byteLength - HAST_DICTIONARY.byteLength);
+      for (let i = 0; i < HAST_DICTIONARY.byteLength; i += 1) {
+        expect(tail[i]).toBe(HAST_DICTIONARY[i]);
+      }
+    });
+
+    it('never exceeds MAX_DICTIONARY_SIZE', () => {
+      const hugeText = 'a'.repeat(MAX_DICTIONARY_SIZE * 2);
+      const dict = buildDictionary(hugeText);
+      expect(dict.byteLength).toBeLessThanOrEqual(MAX_DICTIONARY_SIZE);
+    });
+
+    it('uses the full budget when text fits exactly', () => {
+      const exactSize = MAX_DICTIONARY_SIZE - HAST_DICTIONARY.byteLength;
+      const text = 'b'.repeat(exactSize);
+      const dict = buildDictionary(text);
+      expect(dict.byteLength).toBe(MAX_DICTIONARY_SIZE);
+    });
+  });
+
+  describe('computeDictionaryChecksum', () => {
+    it('returns CHECKSUM_BYTES bytes', () => {
+      const checksum = computeDictionaryChecksum(HAST_DICTIONARY);
+      expect(checksum.byteLength).toBe(CHECKSUM_BYTES);
+    });
+
+    it('is deterministic', () => {
+      const a = computeDictionaryChecksum(HAST_DICTIONARY);
+      const b = computeDictionaryChecksum(HAST_DICTIONARY);
+      expect(a).toEqual(b);
+    });
+
+    it('differs for different inputs', () => {
+      const a = computeDictionaryChecksum(strToU8('hello'));
+      const b = computeDictionaryChecksum(strToU8('world'));
+      const aHex = Array.from(a)
+        .map((byte) => byte.toString(16))
+        .join('');
+      const bHex = Array.from(b)
+        .map((byte) => byte.toString(16))
+        .join('');
+      expect(aHex).not.toBe(bHex);
+    });
+  });
 });
diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
index 1817685c5..6a38b3b86 100644
--- a/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastCompression.ts
@@ -1,371 +1,10 @@
-import { deflateSync, deflate, inflateSync, inflate, strToU8, strFromU8 } from 'fflate';
-import { encode, decode } from 'uint8-to-base64';
-
-/**
- * Shared dictionary for DEFLATE compression of HAST JSON.
- *
- * Contains byte sequences that frequently appear in JSON-serialized HAST trees
- * (syntax-highlighted TypeScript type documentation). The dictionary is
- * embedded in both the server build and the client bundle, so it must stay
- * small — currently ~3 KB uncompressed.
- *
- * IMPORTANT: Once compressed payloads are shipped or cached, removing or
- * reordering entries in this dictionary becomes a **breaking change**. Any
- * such change requires regenerating all persisted `hastCompressed` strings.
- */
-export const HAST_DICTIONARY = strToU8(
-  [
-    // JSON structural patterns (most frequent first)
-    '{"type":"element","tagName":"span","properties":{"className":["frame"],"dataFrameType":"',
-    '{"type":"element","tagName":"span","properties":{"className":["line"],"dataLn":',
-    '{"type":"element","tagName":"span","properties":{"className":["',
-    '{"type":"element","tagName":"a","properties":{"href":"',
-    '{"type":"text","value":"',
-    '"children":[',
-    '"properties":{}',
-    '"tagName":"code"',
-    '"tagName":"pre"',
-    '"type":"root"',
-    '"type":"element"',
-    '"type":"text"',
-    // Starry Night / Pretty Lights class names (full set)
-    'pl-c1',
-    'pl-c2',
-    'pl-c',
-    'pl-corl',
-    'pl-cce',
-    'pl-en',
-    'pl-ent',
-    'pl-e',
-    'pl-k',
-    'pl-smi',
-    'pl-smw',
-    'pl-s1',
-    'pl-sre',
-    'pl-sra',
-    'pl-sr',
-    'pl-s',
-    'pl-pds',
-    'pl-pse',
-    'pl-v',
-    'pl-bu',
-    'pl-ii',
-    'pl-ml',
-    'pl-mh',
-    'pl-ms',
-    'pl-mi1',
-    'pl-mi2',
-    'pl-mi',
-    'pl-mb',
-    'pl-md',
-    'pl-mc',
-    'pl-mdr',
-    'pl-ba',
-    'pl-sg',
-    // Frame & line structure
-    '"className":["frame"]',
-    '"className":["line"]',
-    '"dataFrameType":"highlighted"',
-    '"dataFrameType":"comment"',
-    '"dataFrameType":"property"',
-    '"dataFrameIndent":',
-    '"dataLn":',
-    '"dataType":"TypeRef"',
-    // Common TypeScript keywords (appear as highlighted spans)
-    'interface',
-    'export',
-    'import',
-    'function',
-    'return',
-    'extends',
-    'implements',
-    'class',
-    'typeof',
-    'keyof',
-    'readonly',
-    'abstract',
-    'public',
-    'private',
-    'protected',
-    'static',
-    'async',
-    'await',
-    'const',
-    'true',
-    'false',
-    'never',
-    'any',
-    'unknown',
-    'type',
-    // Common TypeScript primitive and utility types
-    'string',
-    'number',
-    'boolean',
-    'undefined',
-    'null',
-    'object',
-    'void',
-    'Array',
-    'Record',
-    'Partial',
-    'Required',
-    'Readonly',
-    'Pick',
-    'Omit',
-    'Exclude',
-    'Extract',
-    'NonNullable',
-    'ReturnType',
-    'Parameters',
-    'Promise',
-    // Common React types
-    'React.ReactNode',
-    'React.ReactElement',
-    'React.HTMLAttributes',
-    'React.AriaAttributes',
-    'React.CSSProperties',
-    'React.ComponentPropsWithRef',
-    'React.Ref',
-    'React.RefObject',
-    'React.ElementRef',
-    'React.ComponentProps',
-    'React.FC',
-    'React.Dispatch',
-    'React.SetStateAction',
-    'React.useState',
-    'React.useEffect',
-    'React.useMemo',
-    'React.useCallback',
-    'React.useRef',
-    'React.useContext',
-    'React.useReducer',
-    'React.JSX.Element',
-    'JSX.Element',
-    // Common DOM types
-    'HTMLElement',
-    'HTMLDivElement',
-    'HTMLButtonElement',
-    'HTMLInputElement',
-    'HTMLSelectElement',
-    'HTMLTextAreaElement',
-    'HTMLAnchorElement',
-    'HTMLFormElement',
-    'HTMLSpanElement',
-    'HTMLLabelElement',
-    'ShadowRoot',
-    'Element',
-    'EventTarget',
-    // Common event types
-    'MouseEvent',
-    'ChangeEvent',
-    'KeyboardEvent',
-    'FocusEvent',
-    'FormEvent',
-    'PointerEvent',
-    'TouchEvent',
-    // Common punctuation patterns in type signatures
-    ' | ',
-    ' & ',
-    ' => ',
-    '{ ',
-    ' }',
-    '(): ',
-    '(event: ',
-    '(state: ',
-    '(props: HTMLProps, state: ',
-    ', eventDetails: ',
-    ': string',
-    ': number',
-    ': boolean',
-    ': void',
-    ') => string | undefined',
-    ') => React.CSSProperties | undefined',
-    ') => ReactElement',
-    '(open: boolean) => void',
-    '(): JSX.Element',
-    '(): React.JSX.Element',
-    'useMemo<',
-    'useCallback<',
-    'useReducer<',
-    'React.useMemo<',
-    'React.useCallback<',
-    'React.useReducer<',
-    'Dispatch',
-    '',
-    '/>',
-    '=> <',
-    '{children}',
-    'className={',
-    // Common prop names (from typeOrder)
-    'className',
-    'children',
-    'disabled',
-    'style',
-    'render',
-    'defaultValue',
-    'value',
-    'onClick',
-    'onChange',
-    'onSubmit',
-    'onValueChange',
-    'defaultOpen',
-    'open',
-    'onOpenChange',
-    'defaultChecked',
-    'checked',
-    'onCheckedChange',
-    'orientation',
-    'keepMounted',
-    'required',
-    'readOnly',
-    'name',
-    'label',
-    'container',
-    'anchor',
-    'align',
-    'side',
-    'sideOffset',
-    'alignOffset',
-    // Common data attributes
-    'data-disabled',
-    'data-open',
-    'data-closed',
-    'data-checked',
-    'data-unchecked',
-    'data-pressed',
-    'data-selected',
-    'data-highlighted',
-    'data-orientation',
-    'data-valid',
-    'data-invalid',
-    'data-required',
-    'data-readonly',
-    'aria-label',
-    'aria-describedby',
-    'aria-expanded',
-    // Common component part names
-    'Root',
-    'Trigger',
-    'Popup',
-    'Positioner',
-    'Portal',
-    'Arrow',
-    'Content',
-    'Item',
-    'Indicator',
-    'Group',
-    'Track',
-    'Thumb',
-    'AlertDialog.',
-    'Autocomplete.',
-    'NumberField.',
-    'NavigationMenu.',
-    'Accordion.',
-    'Checkbox.',
-    'Combobox.',
-    'ContextMenu.',
-    'Dialog.',
-    'Popover.',
-    'Radio.',
-    'Select.',
-    'Slider.',
-    'Switch.',
-    'Tabs.',
-    'Toggle.',
-    'Tooltip.',
-    // Common React hook names
-    'useState',
-    'useEffect',
-    'useMemo',
-    'useCallback',
-    'useRef',
-    'useContext',
-    'useReducer',
-    // Common type suffixes
-    'Props',
-    '.State',
-    '.ChangeEventDetails',
-    'DataAttributes',
-    'CssVars',
-    // Common Base UI event and state tokens
-    'BaseUIEvent',
-    'TransitionStatus',
-    'SeparatorState',
-    'reason',
-    'allowPropagation',
-    'isCanceled',
-    'isPropagationAllowed',
-    'itemValue',
-    'inline-start',
-    'inline-end',
-    'trigger-press',
-    'outside-press',
-    'focus-out',
-    'list-navigation',
-    'escape-key',
-    'item-press',
-    'close-press',
-  ].join(''),
-);
-
-/**
- * Compress a JSON string using DEFLATE with the shared HAST dictionary.
- * Returns a base64-encoded string suitable for embedding in serialized props.
- */
-export function compressHast(json: string): string {
-  return encode(deflateSync(strToU8(json), { level: 9, dictionary: HAST_DICTIONARY }));
-}
-
-/**
- * Decompress a base64-encoded DEFLATE payload that was compressed with
- * `compressHast`. Returns the original JSON string.
- *
- * Throws if the payload was not compressed with the matching dictionary.
- */
-export function decompressHast(base64: string): string {
-  return strFromU8(inflateSync(decode(base64), { dictionary: HAST_DICTIONARY }));
-}
-
-/**
- * Compress a string asynchronously using DEFLATE with the shared HAST dictionary.
- * Returns a base64-encoded string.
- */
-export function compressHastAsync(input: string): Promise {
-  return new Promise((resolve, reject) => {
-    deflate(
-      strToU8(input),
-      { consume: true, level: 9, dictionary: HAST_DICTIONARY },
-      (err, output) => {
-        if (err) {
-          reject(err);
-        } else {
-          resolve(encode(output));
-        }
-      },
-    );
-  });
-}
-
-/**
- * Decompress a base64-encoded DEFLATE payload asynchronously.
- * Returns the original JSON string.
- */
-export function decompressHastAsync(base64: string): Promise {
-  return new Promise((resolve, reject) => {
-    inflate(decode(base64), { consume: true, dictionary: HAST_DICTIONARY }, (err, output) => {
-      if (err) {
-        reject(err);
-      } else {
-        resolve(strFromU8(output));
-      }
-    });
-  });
-}
+export {
+  HAST_DICTIONARY,
+  MAX_DICTIONARY_SIZE,
+  CHECKSUM_BYTES,
+  buildDictionary,
+  computeDictionaryChecksum,
+  HastDictionaryMismatchError,
+} from './hastDictionary';
+export { compressHast, compressHastAsync } from './hastCompress';
+export { decompressHast, decompressHastAsync } from './hastDecompress';
diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastDecompress.ts b/packages/docs-infra/src/pipeline/hastUtils/hastDecompress.ts
new file mode 100644
index 000000000..c5d1b43c1
--- /dev/null
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastDecompress.ts
@@ -0,0 +1,83 @@
+import { inflateSync, inflate, strFromU8 } from 'fflate';
+import { decode } from 'uint8-to-base64';
+import {
+  buildDictionary,
+  computeDictionaryChecksum,
+  CHECKSUM_BYTES,
+  HastDictionaryMismatchError,
+} from './hastDictionary';
+
+/**
+ * Decompress a base64-encoded DEFLATE payload that was compressed with
+ * `compressHast`. Returns the original JSON string.
+ *
+ * When `textContent` is provided, the first 4 bytes of the decoded payload
+ * are treated as a dictionary checksum. If the checksum does not match the
+ * dictionary built from `textContent`, a `HastDictionaryMismatchError` is
+ * thrown — this prevents silently rendering corrupted data.
+ *
+ * When `textContent` is omitted, only the static dictionary is used for
+ * decompression and no checksum verification is performed.
+ */
+export function decompressHast(base64: string, textContent?: string): string {
+  const raw = decode(base64);
+  const dictionary = buildDictionary(textContent);
+
+  if (textContent != null) {
+    verifyChecksum(raw, dictionary);
+    return strFromU8(inflateSync(raw.subarray(CHECKSUM_BYTES), { dictionary }));
+  }
+
+  return strFromU8(inflateSync(raw, { dictionary }));
+}
+
+/**
+ * Decompress a base64-encoded DEFLATE payload asynchronously.
+ * Returns the original JSON string.
+ *
+ * See `decompressHast` for `textContent` semantics.
+ */
+export function decompressHastAsync(base64: string, textContent?: string): Promise {
+  const raw = decode(base64);
+  const dictionary = buildDictionary(textContent);
+
+  if (textContent != null) {
+    try {
+      verifyChecksum(raw, dictionary);
+    } catch (checksumError) {
+      return Promise.reject(checksumError);
+    }
+    return new Promise((resolve, reject) => {
+      inflate(raw.slice(CHECKSUM_BYTES), { consume: true, dictionary }, (err, output) => {
+        if (err) {
+          reject(err);
+        } else {
+          resolve(strFromU8(output));
+        }
+      });
+    });
+  }
+
+  return new Promise((resolve, reject) => {
+    inflate(raw, { consume: true, dictionary }, (err, output) => {
+      if (err) {
+        reject(err);
+      } else {
+        resolve(strFromU8(output));
+      }
+    });
+  });
+}
+
+function verifyChecksum(raw: Uint8Array, dictionary: Uint8Array): void {
+  if (raw.byteLength < CHECKSUM_BYTES) {
+    throw new HastDictionaryMismatchError();
+  }
+
+  const expected = computeDictionaryChecksum(dictionary);
+  for (let i = 0; i < CHECKSUM_BYTES; i += 1) {
+    if (raw[i] !== expected[i]) {
+      throw new HastDictionaryMismatchError();
+    }
+  }
+}
diff --git a/packages/docs-infra/src/pipeline/hastUtils/hastDictionary.ts b/packages/docs-infra/src/pipeline/hastUtils/hastDictionary.ts
new file mode 100644
index 000000000..412abbf48
--- /dev/null
+++ b/packages/docs-infra/src/pipeline/hastUtils/hastDictionary.ts
@@ -0,0 +1,404 @@
+import { strToU8 } from 'fflate';
+
+/**
+ * Maximum size of the DEFLATE dictionary in bytes.
+ *
+ * DEFLATE uses only the last 32 KiB of the dictionary buffer.
+ * Any content beyond this limit is ignored by the compressor.
+ */
+export const MAX_DICTIONARY_SIZE = 32 * 1024;
+
+/**
+ * Checksum byte length embedded in compressed payloads that use a text
+ * dictionary. The checksum lets `decompressHast` verify that the caller
+ * supplied the same `textContent` that was used during compression.
+ */
+export const CHECKSUM_BYTES = 4;
+
+/**
+ * Shared dictionary for DEFLATE compression of HAST JSON.
+ *
+ * Contains byte sequences that frequently appear in JSON-serialized HAST trees
+ * (syntax-highlighted TypeScript type documentation). The dictionary is
+ * embedded in both the server build and the client bundle, so it must stay
+ * small — currently ~3 KB uncompressed.
+ */
+export const HAST_DICTIONARY = strToU8(
+  [
+    // JSON structural patterns (most frequent first)
+    '{"type":"element","tagName":"span","properties":{"className":["frame"],"dataFrameType":"',
+    '{"type":"element","tagName":"span","properties":{"className":["line"],"dataLn":',
+    '{"type":"element","tagName":"span","properties":{"className":["',
+    '{"type":"element","tagName":"a","properties":{"href":"',
+    '{"type":"text","value":"',
+    '"children":[',
+    '"properties":{}',
+    '"tagName":"code"',
+    '"tagName":"pre"',
+    '"type":"root"',
+    '"type":"element"',
+    '"type":"text"',
+    // Starry Night / Pretty Lights class names (full set)
+    'pl-c1',
+    'pl-c2',
+    'pl-c',
+    'pl-corl',
+    'pl-cce',
+    'pl-en',
+    'pl-ent',
+    'pl-e',
+    'pl-k',
+    'pl-smi',
+    'pl-smw',
+    'pl-s1',
+    'pl-sre',
+    'pl-sra',
+    'pl-sr',
+    'pl-s',
+    'pl-pds',
+    'pl-pse',
+    'pl-v',
+    'pl-bu',
+    'pl-ii',
+    'pl-ml',
+    'pl-mh',
+    'pl-ms',
+    'pl-mi1',
+    'pl-mi2',
+    'pl-mi',
+    'pl-mb',
+    'pl-md',
+    'pl-mc',
+    'pl-mdr',
+    'pl-ba',
+    'pl-sg',
+    // Frame & line structure
+    '"className":["frame"]',
+    '"className":["line"]',
+    '"dataFrameType":"highlighted"',
+    '"dataFrameType":"comment"',
+    '"dataFrameType":"property"',
+    '"dataFrameIndent":',
+    '"dataLn":',
+    '"dataType":"TypeRef"',
+    // Common TypeScript keywords (appear as highlighted spans)
+    'interface',
+    'export',
+    'import',
+    'function',
+    'return',
+    'extends',
+    'implements',
+    'class',
+    'typeof',
+    'keyof',
+    'readonly',
+    'abstract',
+    'public',
+    'private',
+    'protected',
+    'static',
+    'async',
+    'await',
+    'const',
+    'true',
+    'false',
+    'never',
+    'any',
+    'unknown',
+    'type',
+    // Common TypeScript primitive and utility types
+    'string',
+    'number',
+    'boolean',
+    'undefined',
+    'null',
+    'object',
+    'void',
+    'Array',
+    'Record',
+    'Partial',
+    'Required',
+    'Readonly',
+    'Pick',
+    'Omit',
+    'Exclude',
+    'Extract',
+    'NonNullable',
+    'ReturnType',
+    'Parameters',
+    'Promise',
+    // Common React types
+    'React.ReactNode',
+    'React.ReactElement',
+    'React.HTMLAttributes',
+    'React.AriaAttributes',
+    'React.CSSProperties',
+    'React.ComponentPropsWithRef',
+    'React.Ref',
+    'React.RefObject',
+    'React.ElementRef',
+    'React.ComponentProps',
+    'React.FC',
+    'React.Dispatch',
+    'React.SetStateAction',
+    'React.useState',
+    'React.useEffect',
+    'React.useMemo',
+    'React.useCallback',
+    'React.useRef',
+    'React.useContext',
+    'React.useReducer',
+    'React.JSX.Element',
+    'JSX.Element',
+    // Common DOM types
+    'HTMLElement',
+    'HTMLDivElement',
+    'HTMLButtonElement',
+    'HTMLInputElement',
+    'HTMLSelectElement',
+    'HTMLTextAreaElement',
+    'HTMLAnchorElement',
+    'HTMLFormElement',
+    'HTMLSpanElement',
+    'HTMLLabelElement',
+    'ShadowRoot',
+    'Element',
+    'EventTarget',
+    // Common event types
+    'MouseEvent',
+    'ChangeEvent',
+    'KeyboardEvent',
+    'FocusEvent',
+    'FormEvent',
+    'PointerEvent',
+    'TouchEvent',
+    // Common punctuation patterns in type signatures
+    ' | ',
+    ' & ',
+    ' => ',
+    '{ ',
+    ' }',
+    '(): ',
+    '(event: ',
+    '(state: ',
+    '(props: HTMLProps, state: ',
+    ', eventDetails: ',
+    ': string',
+    ': number',
+    ': boolean',
+    ': void',
+    ') => string | undefined',
+    ') => React.CSSProperties | undefined',
+    ') => ReactElement',
+    '(open: boolean) => void',
+    '(): JSX.Element',
+    '(): React.JSX.Element',
+    'useMemo<',
+    'useCallback<',
+    'useReducer<',
+    'React.useMemo<',
+    'React.useCallback<',
+    'React.useReducer<',
+    'Dispatch',
+    '',
+    '/>',
+    '=> <',
+    '{children}',
+    'className={',
+    // Common prop names (from typeOrder)
+    'className',
+    'children',
+    'disabled',
+    'style',
+    'render',
+    'defaultValue',
+    'value',
+    'onClick',
+    'onChange',
+    'onSubmit',
+    'onValueChange',
+    'defaultOpen',
+    'open',
+    'onOpenChange',
+    'defaultChecked',
+    'checked',
+    'onCheckedChange',
+    'orientation',
+    'keepMounted',
+    'required',
+    'readOnly',
+    'name',
+    'label',
+    'container',
+    'anchor',
+    'align',
+    'side',
+    'sideOffset',
+    'alignOffset',
+    // Common data attributes
+    'data-disabled',
+    'data-open',
+    'data-closed',
+    'data-checked',
+    'data-unchecked',
+    'data-pressed',
+    'data-selected',
+    'data-highlighted',
+    'data-orientation',
+    'data-valid',
+    'data-invalid',
+    'data-required',
+    'data-readonly',
+    'aria-label',
+    'aria-describedby',
+    'aria-expanded',
+    // Common component part names
+    'Root',
+    'Trigger',
+    'Popup',
+    'Positioner',
+    'Portal',
+    'Arrow',
+    'Content',
+    'Item',
+    'Indicator',
+    'Group',
+    'Track',
+    'Thumb',
+    'AlertDialog.',
+    'Autocomplete.',
+    'NumberField.',
+    'NavigationMenu.',
+    'Accordion.',
+    'Checkbox.',
+    'Combobox.',
+    'ContextMenu.',
+    'Dialog.',
+    'Popover.',
+    'Radio.',
+    'Select.',
+    'Slider.',
+    'Switch.',
+    'Tabs.',
+    'Toggle.',
+    'Tooltip.',
+    // Common React hook names
+    'useState',
+    'useEffect',
+    'useMemo',
+    'useCallback',
+    'useRef',
+    'useContext',
+    'useReducer',
+    // Common type suffixes
+    'Props',
+    '.State',
+    '.ChangeEventDetails',
+    'DataAttributes',
+    'CssVars',
+    // Common Base UI event and state tokens
+    'BaseUIEvent',
+    'TransitionStatus',
+    'SeparatorState',
+    'reason',
+    'allowPropagation',
+    'isCanceled',
+    'isPropagationAllowed',
+    'itemValue',
+    'inline-start',
+    'inline-end',
+    'trigger-press',
+    'outside-press',
+    'focus-out',
+    'list-navigation',
+    'escape-key',
+    'item-press',
+    'close-press',
+  ].join(''),
+);
+
+/**
+ * FNV-1a 32-bit hash of a Uint8Array.
+ *
+ * Used to detect dictionary mismatches between compression and decompression.
+ * This is NOT cryptographic — it catches programming errors (wrong
+ * `textContent` passed), not adversarial tampering.
+ *
+ * Returns 4 bytes in big-endian order.
+ */
+export function computeDictionaryChecksum(dict: Uint8Array): Uint8Array {
+  let hash = 0x811c9dc5; // FNV offset basis
+  for (let i = 0; i < dict.length; i += 1) {
+    // eslint-disable-next-line no-bitwise
+    hash ^= dict[i];
+    hash = Math.imul(hash, 0x01000193); // FNV prime
+  }
+
+  const out = new Uint8Array(CHECKSUM_BYTES);
+  /* eslint-disable no-bitwise */
+  out[0] = (hash >>> 24) & 0xff;
+  out[1] = (hash >>> 16) & 0xff;
+  out[2] = (hash >>> 8) & 0xff;
+  out[3] = hash & 0xff;
+  /* eslint-enable no-bitwise */
+  return out;
+}
+
+/**
+ * Build a DEFLATE dictionary by combining optional text content with the
+ * static `HAST_DICTIONARY`.
+ *
+ * Layout: `[textContent bytes (truncated)][HAST_DICTIONARY]`
+ *
+ * - `HAST_DICTIONARY` is always at the **end** so it falls within DEFLATE's
+ *   32 KiB window regardless of text length.
+ * - `textContent` is truncated from the **end** (keeps the start, which
+ *   corresponds to the first-rendered / most important content).
+ *
+ * When `textContent` is omitted or empty, returns `HAST_DICTIONARY` as-is.
+ */
+export function buildDictionary(textContent?: string): Uint8Array {
+  if (!textContent) {
+    return HAST_DICTIONARY;
+  }
+
+  const textBytes = strToU8(textContent);
+  const maxTextBytes = MAX_DICTIONARY_SIZE - HAST_DICTIONARY.byteLength;
+
+  if (maxTextBytes <= 0) {
+    return HAST_DICTIONARY;
+  }
+
+  // Truncate text from the end — keep the start (first-rendered content)
+  const usableText =
+    textBytes.byteLength > maxTextBytes ? textBytes.slice(0, maxTextBytes) : textBytes;
+
+  const combined = new Uint8Array(usableText.byteLength + HAST_DICTIONARY.byteLength);
+  combined.set(usableText, 0);
+  combined.set(HAST_DICTIONARY, usableText.byteLength);
+  return combined;
+}
+
+/**
+ * Error thrown when the dictionary checksum in a compressed payload does not
+ * match the dictionary built from the provided `textContent`.
+ */
+export class HastDictionaryMismatchError extends Error {
+  override name = 'HastDictionaryMismatchError';
+
+  constructor() {
+    super(
+      'HAST dictionary mismatch: the textContent used for compression does not match ' +
+        'the textContent provided for decompression. Ensure the same text is provided to both.',
+    );
+  }
+}
diff --git a/packages/docs-infra/src/pipeline/hastUtils/index.ts b/packages/docs-infra/src/pipeline/hastUtils/index.ts
index 030efe52a..fc5019f41 100644
--- a/packages/docs-infra/src/pipeline/hastUtils/index.ts
+++ b/packages/docs-infra/src/pipeline/hastUtils/index.ts
@@ -1,2 +1,3 @@
 export * from './hastCompression';
 export * from './hastUtils';
+export { stripHighlightingSpans } from './stripHighlightingSpans';
diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts b/packages/docs-infra/src/pipeline/hastUtils/stripHighlightingSpans.test.ts
similarity index 92%
rename from packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
rename to packages/docs-infra/src/pipeline/hastUtils/stripHighlightingSpans.test.ts
index 2ab3ae798..e04310b52 100644
--- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.test.ts
+++ b/packages/docs-infra/src/pipeline/hastUtils/stripHighlightingSpans.test.ts
@@ -425,6 +425,41 @@ describe('stripHighlightingSpans', () => {
     expect(frame.children).toEqual([{ type: 'text', value: '// hello' }]);
   });
 
+  it('should remove dataAsString from frame spans', () => {
+    const root: HastRoot = {
+      type: 'root',
+      children: [
+        {
+          type: 'element',
+          tagName: 'span',
+          properties: {
+            className: ['frame'],
+            dataAsString: 'const x = 1;\nconst y = 2;',
+          },
+          children: [
+            {
+              type: 'element',
+              tagName: 'span',
+              properties: { className: ['line'], dataLn: 1 },
+              children: [{ type: 'text', value: 'const x = 1;' }],
+            },
+            { type: 'text', value: '\n' },
+            {
+              type: 'element',
+              tagName: 'span',
+              properties: { className: ['line'], dataLn: 2 },
+              children: [{ type: 'text', value: 'const y = 2;' }],
+            },
+          ],
+        },
+      ],
+    };
+    const result = stripHighlightingSpans(root);
+    const frame = result.children[0] as HastElement;
+    expect(frame.properties).toEqual({ className: ['frame'] });
+    expect(frame.children).toEqual([{ type: 'text', value: 'const x = 1;\nconst y = 2;' }]);
+  });
+
   it('should strip line spans but preserve their content', () => {
     const root: HastRoot = {
       type: 'root',
diff --git a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts b/packages/docs-infra/src/pipeline/hastUtils/stripHighlightingSpans.ts
similarity index 85%
rename from packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
rename to packages/docs-infra/src/pipeline/hastUtils/stripHighlightingSpans.ts
index c97af7621..72856f883 100644
--- a/packages/docs-infra/src/abstractCreateTypes/stripHighlightingSpans.ts
+++ b/packages/docs-infra/src/pipeline/hastUtils/stripHighlightingSpans.ts
@@ -40,10 +40,13 @@ function processChildren(children: RootContent[]): RootContent[] {
       ...element,
       children: processChildren(element.children as RootContent[]) as ElementContent[],
     };
-    // Strip data-lined from frame spans since line spans are removed
-    if (isFrameSpan(element) && processed.properties?.dataLined !== undefined) {
-      const { dataLined: omittedDataLined, ...rest } = processed.properties;
-      processed.properties = rest;
+    // Strip data-lined and data-as-string from frame spans since line spans
+    // are removed and the raw source text is redundant in the fallback HAST.
+    if (isFrameSpan(element) && processed.properties) {
+      const { dataLined, dataAsString, ...rest } = processed.properties;
+      if (dataLined !== undefined || dataAsString !== undefined) {
+        processed.properties = rest;
+      }
     }
     return [processed as RootContent];
   });

From af630e74f1431db811d642626e21bb958b1182d2 Mon Sep 17 00:00:00 2001
From: dav-is 
Date: Fri, 3 Apr 2026 11:47:32 -0400
Subject: [PATCH 28/61] First step in restructuring fallback compression

---
 .../demos/CodeContentLoading.tsx              |   4 +-
 .../DemoContentLoading.tsx                    |   9 +-
 .../DemoContentLoading.tsx                    |  22 +-
 .../demo-fallback/DemoContentLoading.tsx      |   5 +-
 .../components/code-highlighter/types.md      | 113 ++++++-
 docs/app/docs-infra/components/page.mdx       |   3 +
 .../DemoPerformanceContentLoading.tsx         |   5 +-
 .../src/CodeHighlighter/CodeHighlighter.tsx   |  17 +-
 .../CodeHighlighter/CodeHighlighterClient.tsx |  82 ++++-
 .../CodeHighlighterContext.tsx                |   8 +-
 .../CodeHighlighterFallbackContext.tsx        |  18 +-
 .../codeToFallbackProps.test.ts               | 268 +++++++++++------
 .../CodeHighlighter/codeToFallbackProps.ts    | 240 +++++++++++----
 .../docs-infra/src/CodeHighlighter/errors.ts  |   9 +
 .../CodeHighlighter/fallbackFormat.test.ts    | 283 ++++++++++++++++++
 .../src/CodeHighlighter/fallbackFormat.ts     | 160 ++++++++++
 .../docs-infra/src/CodeHighlighter/index.ts   |   2 +
 .../docs-infra/src/CodeHighlighter/types.ts   |  41 ++-
 .../src/CodeHighlighter/useCodeFallback.ts    | 156 ++++++++++
 .../src/abstractCreateTypes/typesToJsx.ts     |  38 ++-
 .../loadCodeVariant/loadCodeFallback.ts       |   2 +
 .../loadCodeVariant/loadCodeVariant.test.ts   |   4 +-
 .../loadCodeVariant/loadCodeVariant.ts        |  40 ++-
 .../pipeline/loadServerTypes/hastTypeUtils.ts |   7 +-
 packages/docs-infra/src/useCode/Pre.tsx       |  14 +-
 packages/docs-infra/src/useCode/useCode.ts    |   1 +
 .../src/useCode/useFileNavigation.tsx         |  20 +-
 .../src/useCode/useSourceEnhancing.ts         |  17 +-
 28 files changed, 1376 insertions(+), 212 deletions(-)
 create mode 100644 packages/docs-infra/src/CodeHighlighter/fallbackFormat.test.ts
 create mode 100644 packages/docs-infra/src/CodeHighlighter/fallbackFormat.ts
 create mode 100644 packages/docs-infra/src/CodeHighlighter/useCodeFallback.ts

diff --git a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx
index 8d15dfde8..00f26222c 100644
--- a/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx
+++ b/docs/app/bench/docs-infra/components/code-highlighter/demos/CodeContentLoading.tsx
@@ -6,11 +6,11 @@ import styles from './CodeContent.module.css';
 
 import '@wooorm/starry-night/style/light';
 
-export function CodeContentLoading(props: ContentLoadingProps<{}>) {
+export function CodeContentLoading(_props: ContentLoadingProps<{}>) {
   return (
     
-
{props.source}
+
       
); diff --git a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/DemoContentLoading.tsx b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/DemoContentLoading.tsx index 936980ce8..be455442f 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/DemoContentLoading.tsx +++ b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-files/DemoContentLoading.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import type { ContentLoadingProps } from '@mui/internal-docs-infra/CodeHighlighter/types'; +import { useCodeFallback } from '@mui/internal-docs-infra/CodeHighlighter'; +import { hastToJsx } from '@mui/internal-docs-infra/pipeline/hastUtils'; import { Tabs } from '@/components/Tabs'; import styles from '../DemoContent.module.css'; import loadingStyles from './DemoContentLoading.module.css'; @@ -9,6 +11,7 @@ import loadingStyles from './DemoContentLoading.module.css'; import '@wooorm/starry-night/style/light'; export function DemoContentLoading(props: ContentLoadingProps) { + const { source, extraSource } = useCodeFallback(props); const tabs = React.useMemo( () => props.fileNames?.map((name) => ({ @@ -46,11 +49,11 @@ export function DemoContentLoading(props: ContentLoadingProps) {
-
{props.source}
+
{source ? hastToJsx(source) : null}
- {Object.keys(props.extraSource || {}).map((slug) => ( -
{props.extraSource?.[slug]}
+ {Object.keys(extraSource || {}).map((slug) => ( +
{extraSource?.[slug] ? hastToJsx(extraSource[slug]) : null}
))}
diff --git a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading.tsx b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading.tsx index 069c8d4aa..0d3ad932f 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading.tsx +++ b/docs/app/docs-infra/components/code-highlighter/demos/demo-fallback-all-variants/DemoContentLoading.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import type { ContentLoadingProps } from '@mui/internal-docs-infra/CodeHighlighter/types'; +import { useCodeFallback } from '@mui/internal-docs-infra/CodeHighlighter'; +import { hastToJsx } from '@mui/internal-docs-infra/pipeline/hastUtils'; import { Tabs } from '@/components/Tabs'; import { Select } from '@/components/Select'; import styles from '../DemoContent.module.css'; @@ -14,6 +16,7 @@ const variantNames: Record = { }; export function DemoContentLoading(props: ContentLoadingProps) { + const { source, extraSource, extraVariants } = useCodeFallback(props); const tabs = React.useMemo( () => props.fileNames?.map((name) => ({ @@ -38,7 +41,7 @@ export function DemoContentLoading(props: ContentLoadingProps) { return (
- {Object.keys(props.extraSource || {}).map((slug) => ( + {Object.keys(extraSource || {}).map((slug) => ( ))}
@@ -57,28 +60,31 @@ export function DemoContentLoading(props: ContentLoadingProps) { )}
- {Object.keys(props.extraVariants || {}).length >= 1 && ( + {Object.keys(extraVariants || {}).length >= 1 && (