[virtualizer] Add inverse-sticky layouts - #23053
Conversation
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>
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
…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>
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>
| * <div {...windowProps}> // sticky rendered window | ||
| * <div {...positionerProps}> // horizontal translate | ||
| * {rows} | ||
| * </div> | ||
| * </div> |
There was a problem hiding this comment.
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.
…pdate render context after half of the buffer is reached
…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>
| /** | ||
| * Anchor of the paint-stable window content. | ||
| * | ||
| * The rows render inside a `windowContent` box whose `paddingTop` places them at |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 👍🏻
| * 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 |
There was a problem hiding this comment.
Good to have this mentioned, because I felt like will-change is missing here 😅
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: stickywith negativetop/bottomoffsets 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:
uncontrolledcontrolledThe 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
uncontrolledcode 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: noneon 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):position: sticky; left/rightinside each row — sticky constraints resolve against the scrollport, so they compose with the vertically-sticky window,-(renderedHeight - (viewportInner + topContainerHeight))/-(renderedHeight - (viewportInner + bottomContainerHeight))— so it clamps against the inner viewport edges (below the header, above the pinned-bottom section),packages/x-virtualizer/docs/(demoListSticky,demoGridSticky) with a main-thread-jank toggle to compare against the classic layout.Next steps
layoutMode: 'sticky'behindexperimentalFeatures.virtualizerLayoutMode.flushSyncon scroll: with blanking impossible, render context updates can be deferred off the critical path.🤖 Generated with Claude Code