[docs-infra] Type Prop Compression#1269
Conversation
Bundle size report
Check out the code infra dashboard for more information about this PR. |
…-public into docs-infra/type-prop-compression
Bundle size
Deploy preview
PerformanceTotal duration: 17.18 ms ▼-5.31 ms(-23.6%) | Renders: 4 (+0) | Paint: 71.70 ms ▼-27.30 ms(-27.6%)
Check out the code infra dashboard for more information about this PR. |
This is a strawman argument that probably has come up at every discussion so far. It's really not something I ever proposed. What I used to propose was to break out of React and use |
Then we would not be able to dogfood Base UI components. The complicated state and positioning logic of these components are likely already imported to use elsewhere on the page. We can also use next/link for cross page type linking. Also, the highlighted code is generated at build time (e.g. rehype plugin), the enhancement API runs on server render which is how we can add links based on data from the sitemap (if a rehype plugin reads an external file, it breaks caching). To do that we need to traverse the HTML AST. It's also an extension point for users, they can create their own enhancers that are basically rehype plugins. Otherwise, we are left with a black box that can't be easily extended within a user's repo. There are tradeoffs with the approach, but the main point is that the compression library is only 4KB + 3KB dictionary and results in a large savings in html size, even compared against html string rendering: https://deploy-preview-1269--mui-internal.netlify.app/docs-infra/patterns/prop-compression#medium-snippet |
dogfooding has two main goals:
In this case, the missing feature is an API to trigger popovers from DOM that is not react managed. I guess one could get somwhere with detached trigger handles but it's suboptimal a11y-wise. In general I'm not keen on having the dogfooding use-case dictate the architecture of something as foundational to docs infra as code highlighting.
I don't understand this point, if your toolchain does code > hast > html, regardless of whether which step happens server side or client side, there is a point of extension. |
|
|
|
I agree, I'm just reacting because you bring it up
|
|
I only mentioned it as a comparison, for numbers I could find, from the perspective of HAST vs HTML as a transfer data structure from server to client. For rendering, you could still transfer HAST from server to client, then render it with Making code interactive with So far, the docs-infra package renders real react components in codeblocks with minimal performance impact. The |
Signed-off-by: Connor Davis <mail@connordav.is>
| - **`'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'`** — same fallback, but defer full highlighting until the browser is idle (`requestIdleCallback`). | ||
| - **`'visible'`** (default) — same fallback, but defer full highlighting until the content enters the viewport, then parse during idle time. |
There was a problem hiding this comment.
What is "full highlighting"? transforming a string containing code all the way to DOM?
idle and hydration feel largely redundant, are we really using them?
There was a problem hiding this comment.
What I'm asking is: where is the parsing happening? serverside+transferred or browserside?
There was a problem hiding this comment.
starry-night runs on the server at build time
| 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. |
There was a problem hiding this comment.
then replace with fully-highlighted content on client mount.
Highlighting is fully done browser-side? from a string containing code to generating spans in the DOM?
There was a problem hiding this comment.
Highlighting is precomputed during build and passed to the client. All the client does is swap the fallback text with a rendered HAST. No highlighting lib on the client
There was a problem hiding this comment.
But won't we need a client-side highlighting mode for editable code?
There was a problem hiding this comment.
But won't we need a client-side highlighting mode for editable code?
Yes, for code blocks that is supported, but optional. For the actual type definitions, live editing isn't supported.
| /** | ||
| * Compact serialization format for fallback HAST trees. | ||
| * | ||
| * A `FallbackNode` is either: |
There was a problem hiding this comment.
Why is it called "fallback"? Is that the same "fallback" as in "links-only fallback"? Or are there multiple ways of transferring the HAST from server to client?
There was a problem hiding this comment.
This is intended to only be used to pass very simple HAST for .frame elements that wrap the plain text fallback code. For very large codeblocks, the fallback might need to be broken into mulitple frames, so that they can be enhanced in smaller chunks.
[['span', 'frame', "| boolean\n| React.RefObject<HTMLElement | null>\n| ((\n openType: 'mouse' | 'touch' | 'pen' | 'keyboard' | '',\n ) => boolean | void | HTMLElement | null)\n| undefined"]]Renders as:
<pre class="CodeBlockPreInline"><code class="language-ts"><span class="frame">| boolean
| React.RefObject<HTMLElement | null>
| ((
openType: 'mouse' | 'touch' | 'pen' | 'keyboard' | '',
) => boolean | void | HTMLElement | null)
| undefined</span></code></pre>The fallback term mirrors that of suspense boundaries:
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>

Downstream PR: mui/base-ui#4497
Adds new pattern document explaining the methodology: Prop Compression Pattern
Adds lazy loading to type highlighting and compresses the HAST to optimize uncompressed HTML parse and CSS paint time.
Results
30% (-789 KB) reduction of Base UI's Combobox uncompressed HTML, bringing it under the 2 MB limit (1886 KB). As a tradeoff, compressed props can't be further compressed, causing a 5% (+14 KB) increase in the compressed HTML size. The plain text is used as a shared dictionary when decompressing to minimize losses in compression effeciency. Improves first contentful paint and hydration time by 42 ms. Achieved by reducing HTML parse time by 34% (-19ms), scripting time (to parse the RSC props) by 31% (-15ms), and reducing render time by 20% (-8ms).
23% (-283 KB) reduction of docs-infra docs
CodeHighlighterpage which has many types. Compressed HTML increased by 5% (+6 KB).Breaking Change
TypeRefandTypePropRefmust now be provided via a<CodeComponentProvider>so that they are aviable on the client. These are already client components, so there shouldn't be any meaningful difference in bundle sizetypeTextandshortTypeTextas they can be derived fromtypeandshortTypehast on the client (see diff)hastGziptohastCompressed(mostly an internal change)See migration