Skip to content

[virtualizer] Add inverse-sticky layouts - #23053

Merged
arminmeh merged 41 commits into
mui:masterfrom
romgrk:inverse-sticky
Aug 12, 2026
Merged

[virtualizer] Add inverse-sticky layouts#23053
arminmeh merged 41 commits into
mui:masterfrom
romgrk:inverse-sticky

Conversation

@romgrk

@romgrk romgrk commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Implements the "inverse sticky" rendering technique from On rendering diffs at scale as new virtualizer layouts, scoped to @mui/x-virtualizer (no Data Grid changes yet).

The technique

The rendered window is placed in the normal flow at its content offset and given position: sticky with negative top/bottom offsets of -(renderedHeight - viewportHeight). Within that range the browser moves the window natively with the scroll (no JS per frame); when the scroll position moves past the rendered window before the render context updates, the window clamps to the viewport edges, showing stale content instead of a blank area.

It combines the strengths of the two existing layout modes:

native scrolling blank areas
uncontrolled possible
controlled ❌ (JS transform per scroll event) impossible
inverse sticky ✅ (within the render buffers) impossible (clamps to stale content)

The existing directional render buffers (bigger buffer in the scroll direction) act as the native-scroll headroom of the window, so the sticky layouts reuse the uncontrolled code paths unchanged — zero modifications to the virtualization core.

What's included

  • LayoutListSticky: list-shaped layout. Structure: scroller → in-flow full-height content (the sticky containing block) → spacer → sticky window → rows. overflow-anchor: none on the scroller since the spacer height changes on every render context update.
  • LayoutGridSticky: grid-shaped layout with pinned sections, designed to preserve the a11y-critical DOM structure (rows own all their cells, pinned sections are CSS-only overlays):
    • top/bottom containers (headers, pinned rows) are classic sticky elements,
    • pinned column cells keep position: sticky; left/right inside each row — sticky constraints resolve against the scrollport, so they compose with the vertically-sticky window,
    • the window's sticky offsets are asymmetric — -(renderedHeight - (viewportInner + topContainerHeight)) / -(renderedHeight - (viewportInner + bottomContainerHeight)) — so it clamps against the inner viewport edges (below the header, above the pinned-bottom section),
    • the window has an explicit height to keep the clamping math immune to out-of-flow children (detail panels, virtual focus rows).
  • Browser tests (9) asserting the core invariant synchronously: after a large programmatic scroll jump, before the virtualizer receives the scroll event, the stale window already covers the (inner) viewport through sticky clamping alone. Plus pixel-exact positioning after catch-up, pinned rows/columns staying in place, and the directional buffer extending the window in the scroll direction.
  • Demos in packages/x-virtualizer/docs/ (demoListSticky, demoGridSticky) with a main-thread-jank toggle to compare against the classic layout.

Next steps

  • Port to the Data Grid as a third layoutMode: 'sticky' behind experimentalFeatures.virtualizerLayoutMode.
  • Manual scroll anchoring for dynamic row heights.
  • Explore relaxing the flushSync on scroll: with blanking impossible, render context updates can be deferred off the critical path.

🤖 Generated with Claude Code

romgrk and others added 2 commits July 2, 2026 18:24
Prototype of the inverse-sticky rendering technique
(https://pierre.computer/writing/on-rendering-diffs): the rendered
window is a position:sticky element with negative top/bottom offsets,
giving native scrolling within the render buffer and clamping to the
viewport edges (stale content instead of blank) when the render
context update falls behind the scroll position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the inverse-sticky technique to the grid shape (headers, pinned
rows, pinned columns):
- top/bottom containers are classic sticky elements,
- pinned column cells keep position:sticky inside rows, composing with
  the vertically-sticky window since sticky constraints resolve against
  the scrollport,
- the window's sticky offsets are asymmetric so it clamps against the
  inner viewport edges (below the top container, above the bottom
  container),
- the directional render buffers (bigger buffer in the scroll
  direction) act as the native-scroll headroom of the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@romgrk
romgrk marked this pull request as draft July 3, 2026 16:15
@code-infra-dashboard

code-infra-dashboard Bot commented Jul 3, 2026

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-23053--material-ui-x.netlify.app/
QR code for https://deploy-preview-23053--material-ui-x.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 🔺+7.71KB(+1.88%) 🔺+1.63KB(+1.35%)
@mui/x-data-grid-pro 🔺+7.71KB(+1.45%) 🔺+1.69KB(+1.08%)
@mui/x-data-grid-premium 🔺+7.72KB(+1.07%) 🔺+1.61KB(+0.77%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 🔺+7.65KB(+6.03%) 🔺+1.59KB(+4.14%)
@mui/x-scheduler 0B(0.00%) 0B(0.00%)
@mui/x-scheduler-premium 🔺+7.68KB(+1.49%) 🔺+1.62KB(+1.13%)
@mui/x-chat 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


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

romgrk and others added 2 commits July 3, 2026 12:23
…yout

The grid layout now uses a per-axis strategy: vertical stays inverse
sticky (native scrolling within the render buffers), horizontal becomes
controlled — the window is pinned to the scrollport (sticky left: 0,
sized to the viewport, overflow: hidden) and an inner positioner
translates rows by -scrollLeft on each scroll event. Stale columns can
never leave the viewport, so horizontal render gaps are impossible.

As in the controlled layout mode, pinned column cells switch from
sticky to absolute positioning with JS offsets, since sticky
constraints ignore the positioner's transform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Introduces layoutMode: 'sticky' in the virtualization core so the
inverse-sticky layouts get correct behavior on both axes:
- rows: uncontrolled path — directional buffers (the native-scroll
  headroom of the sticky window) and the post-scroll context refresh,
- columns: controlled path — exact viewport window with no buffers
  (buffered columns would be clipped by the window and never visible),
  pinned-right-aware bounds, and offsetLeft without the pinned-left
  subtraction (pinned cells are absolutely positioned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/x-virtualizer/docs/demoGridSticky.tsx
romgrk and others added 3 commits July 13, 2026 19:59
The scroller hides its native scrollbars and standalone scrollbar
widgets (mirroring the DataGrid's GridVirtualScrollbar) are kept in
sync with it through the existing two-way sync helper. The layout owns
the widget geometry: the content reserves the scrollbar lanes, the
bottom container and the window's upward clamp are lifted above the
horizontal lane, and the widgets collapse (rather than display: none)
when their axis doesn't scroll so the scrollbar size probe can measure
inside them.

Also guards scrollbar size measurement against direct-measuring a
collapsed element: a 0-width/0-height scroll container can't render its
scrollbar, and its clamped measurement would poison the cache as
authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…id layout

The top/bottom containers were only pinned vertically: on horizontal
scroll they moved with the content (native) in addition to their
positioner's -scrollLeft translation, so their content scrolled at
twice the rate. They are now horizontally pinned to the scrollport
like the window (sticky left: 0, viewport-sized, overflow: hidden).

Since every horizontally-pinned section is now viewport-sized, no
in-flow child spans the columns width anymore: the content element
sets the scrollable width explicitly instead of relying on
max-content sizing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@romgrk romgrk 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: virtualizer labels Jul 14, 2026
Comment thread packages/x-virtualizer/src/features/virtualization/layout.ts
Comment on lines +434 to +438
* <div {...windowProps}> // sticky rendered window
* <div {...positionerProps}> // horizontal translate
* {rows}
* </div>
* </div>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The "window" isn't quite equivalent to the "viewport" from controlled mode, so it's named differently even though it fullfills a very similar role. Again, not satisfied with naming.

@romgrk
romgrk marked this pull request as ready for review July 14, 2026 04:10
Comment thread packages/x-virtualizer/docs/demoGridSticky.tsx
Comment thread packages/x-virtualizer/src/features/dimensions.ts
arminmeh and others added 4 commits August 7, 2026 09:49
…ents

Reword the fast-scroll deferral comments to state the mechanism (defer the
mid-fling render-context advance; the inverse-sticky clamp shows stale content
meanwhile) instead of the ambiguous "give entering rows time to rasterize".
Condense the paint-stable window content docs above `padTopFor`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/x-virtualizer/src/features/virtualization/virtualization.ts Outdated
/**
* Anchor of the paint-stable window content.
*
* The rows render inside a `windowContent` box whose `paddingTop` places them at

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.

I get that this keeps rows' positions stable relative to the parent (windowContent), but I'm a little bit suspicious to the padding + translateY part, because changing padding-top invalidates its layout and results in layout recalculation, including layout of its children.
Is the assumption that the browser optimizes children layout, sees that their relative position to the parent hasn't changed, and use the cached layout?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Layout updates are not affected by the change.

This affects the painting phase. Before the update, all rows change the position relative to the parent container, since we were replacing the content of the visible area. This invalidates what was painted before, and all rows have to be rerastered.
With the update, this only happens to the new row added to the edge in the direction of scrolling.

This was confirmed by Claude by comparing the performance traces before and after the change.

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.

Hmm, interesting. My assumption was that layout recalculations translate into painting anyway.
But I did a quick test without stable row positions – and the painting time is indeed reduced a bit with stable row positions. So I guess it works 👍🏻

Comment thread packages/x-virtualizer/src/features/virtualization/layout.ts Outdated

@cherniavskii cherniavskii left a comment

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.

I didn't understand each and every detail of deferred frames, but generally the approach makes sense to me, and it's empirically proven to be working as expected.
Good job @romgrk and @arminmeh!

* sticky offsets resolve before transforms, so a horizontal translate would displace
* their clamped position.
*
* Avoid `will-change: transform` or a background on this box: neither reduces

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.

Good to have this mentioned, because I felt like will-change is missing here 😅

@arminmeh
arminmeh merged commit 8a4a775 into mui:master Aug 12, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance scope: virtualizer 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.

5 participants