Skip to content

[docs-infra] Type Prop Compression#1269

Merged
dav-is merged 72 commits into
masterfrom
docs-infra/type-prop-compression
Apr 16, 2026
Merged

[docs-infra] Type Prop Compression#1269
dav-is merged 72 commits into
masterfrom
docs-infra/type-prop-compression

Conversation

@dav-is
Copy link
Copy Markdown
Member

@dav-is dav-is commented Mar 30, 2026

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 CodeHighlighter page which has many types. Compressed HTML increased by 5% (+6 KB).

Breaking Change

  • TypeRef and TypePropRef must 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 size
  • Removed typeText and shortTypeText as they can be derived from type and shortType hast on the client (see diff)
  • Renamed hastGzip to hastCompressed (mostly an internal change)

See migration

@dav-is dav-is added type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. scope: docs-infra Involves the docs-infra product (https://www.notion.so/mui-org/b9f676062eb94747b6768209f7751305). labels Mar 30, 2026
@mui-bot
Copy link
Copy Markdown

mui-bot commented Mar 30, 2026

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@dav-is dav-is added the breaking change Introduces changes that are not backward compatible. label Apr 9, 2026
@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented Apr 9, 2026

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)

Details of bundle changes

Deploy preview

Performance

Total duration: 17.18 ms ▼-5.31 ms(-23.6%) | Renders: 4 (+0) | Paint: 71.70 ms ▼-27.30 ms(-27.6%)

Test Duration Renders
HeavyList mount 10.48 ms ▼-4.90 ms(-31.9%) 1 (+0)
DataGrid mount with paint timing 2.51 ms ▼-0.40 ms(-13.7%) 1 (+0)
Counter click 4.19 ms ▼-0.01 ms(-0.3%) 2 (+0)

Details of benchmark changes


Check out the code infra dashboard for more information about this PR.

@Janpot
Copy link
Copy Markdown
Member

Janpot commented Apr 10, 2026

parsing HTML strings back into something React can work with for interactivity

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 dangerouslySetInnerHtml (or show plain text and do it an effect) with event delegation. i.e. you put a click event handler on the parent element and read data- attributes and coordinates on the event target to show a popup. This goes at 0Kb extra bundle size.

@dav-is
Copy link
Copy Markdown
Member Author

dav-is commented Apr 10, 2026

you put a click event handler on the parent element and read data- attributes and coordinates on the event target to show a popup

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

@Janpot
Copy link
Copy Markdown
Member

Janpot commented Apr 13, 2026

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.

dogfooding has two main goals:

  1. Finding regression early
  2. Finding missing features early (or missing documentation)

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.

We can also use next/link for cross page type linking.

next/link isn't much more than a component that renders an anchor tag and hands of its click event to the Next.js router, where the real complexity lies. If we're rendering anchors outside of react, it's trivial to delegate their clicks to the Next.js router ourselves. next/link isn't hiding complexity, it's just a convenience API.

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.

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.

@dav-is
Copy link
Copy Markdown
Member Author

dav-is commented Apr 13, 2026

dangerouslySetInnerHtml is a viable method with its own set of tradeoffs, but this PR has nothing to do with it other than comparing the html weight from that solution. This PR is about deferring highlighting and the method for which we pass props across server client boundaries.

@Janpot
Copy link
Copy Markdown
Member

Janpot commented Apr 13, 2026

I agree, I'm just reacting because you bring it up

...The alternative (parsing HTML strings back into something React can work with for interactivity)...

@dav-is
Copy link
Copy Markdown
Member Author

dav-is commented Apr 13, 2026

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 dangerouslySetInnerHtml. Sending a raw html string to the client makes having a code enhancer function on the client side (pages router can't have server side enhancers) not viable because it would increase the bundle by 60 KB.

Making code interactive with dangerouslySetInnerHtml and event bubbling has an unknown bundle size, certainly not zero. I would guess probably around the same 4 KB mark if it reused Base UI components somehow. The Base UI popover is 38 KB and might already be included in the page.

So far, the docs-infra package renders real react components in codeblocks with minimal performance impact. The TypeRef component even uses React Context to load type data. The API feels very similar to how MDX works.

@Janpot Janpot requested a review from brijeshb42 April 16, 2026 13:34
Comment thread docs/app/docs-infra/factories/abstract-create-types/page.mdx Outdated
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.
Copy link
Copy Markdown
Member

@Janpot Janpot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

@dav-is dav-is Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback:

image

Full Highlighting:

Screenshot From 2026-04-16 09-55-16

On the first render, the detailed type will likely be hidden within the collapsed accordion. Also note that the shortType (Union) is still highlighted on the first render before hydration.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I'm asking is: where is the parsing happening? serverside+transferred or browserside?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But won't we need a client-side highlighting mode for editable code?

Copy link
Copy Markdown
Member Author

@dav-is dav-is Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Copy Markdown
Member

@Janpot Janpot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

@dav-is dav-is Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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&lt;HTMLElement | null&gt;\n| ((\n    openType: 'mouse' | 'touch' | 'pen' | 'keyboard' | '',\n  ) =&gt; boolean | void | HTMLElement | null)\n| undefined"]]

Renders as:

<pre class="CodeBlockPreInline"><code class="language-ts"><span class="frame">| boolean
| React.RefObject&lt;HTMLElement | null&gt;
| ((
    openType: 'mouse' | 'touch' | 'pen' | 'keyboard' | '',
  ) =&gt; boolean | void | HTMLElement | null)
| undefined</span></code></pre>

The fallback term mirrors that of suspense boundaries:

<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>

Comment thread docs/app/docs-infra/patterns/prop-compression/page.mdx Outdated
@dav-is dav-is merged commit a2817e4 into master Apr 16, 2026
13 of 16 checks passed
@dav-is dav-is deleted the docs-infra/type-prop-compression branch April 16, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Introduces changes that are not backward compatible. scope: docs-infra Involves the docs-infra product (https://www.notion.so/mui-org/b9f676062eb94747b6768209f7751305). type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants