From 2a0d551640bead65c7e64412bff031070f2e3da3 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Mon, 6 Jul 2026 16:46:06 +0200 Subject: [PATCH 1/2] refactor: replace spacers with padding in content wrappers for improved scrolling behavior --- README.md | 56 +++++++------- demo/react/App.tsx | 30 ++++---- demo/react/ItemRow.tsx | 9 --- demo/react/WindowList.tsx | 12 ++- demo/react/e2e/tests/scroll.spec.ts | 2 +- demo/react/list-shared.ts | 9 ++- demo/shared/App.module.css | 8 -- demo/solid/App.tsx | 42 ++++++---- demo/solid/ItemRow.tsx | 4 - src/core/dom.ts | 2 +- src/core/stick-to-bottom.ts | 2 +- src/core/types.ts | 4 +- src/core/virtualizer.test.ts | 11 +-- src/core/virtualizer.ts | 94 +++++++++++++++-------- src/react/use-stick-to-edge.ts | 6 +- src/react/use-zero-virtualizer.test.ts | 9 ++- src/react/use-zero-virtualizer.ts | 3 +- src/solid/create-stick-to-bottom.ts | 7 +- src/solid/create-zero-virtualizer.test.ts | 2 +- 19 files changed, 168 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index 9b3e3b7..0f2b7d5 100644 --- a/README.md +++ b/README.md @@ -152,19 +152,20 @@ export function ItemList() { onScrollStateChange, }); - // Rows render in normal document flow between two spacers that stand in for - // the not-yet-loaded rows above and below. The hook manages `overflow-anchor` - // on the scroll container per the anchoring mode; put `overflow-anchor: none` - // on the spacers so anchoring always targets a real row, never a spacer. + // Rows render in normal document flow inside a content wrapper whose + // padding stands in for the not-yet-loaded rows above and below (padding + // rather than margin so it always contributes to the scrollable extent). + // The hook manages `overflow-anchor` on the scroll container per the + // anchoring mode. return (
-
- {items.map(({index, key, row}) => ( -
- {row ? row.title : 'Loading...'} -
- ))} -
+
+ {items.map(({index, key, row}) => ( +
+ {row ? row.title : 'Loading...'} +
+ ))} +
); } @@ -221,23 +222,18 @@ export function ItemList() {
- - {item => ( -
- {item.row ? item.row.title : 'Loading...'} -
- )} -
-
+ > + + {item => ( +
+ {item.row ? item.row.title : 'Loading...'} +
+ )} +
+
); } @@ -287,7 +283,7 @@ relabeling): - **`'manual'`** — the virtualizer pins a reference row itself and folds above-viewport size changes back into the scroll position. Writing `scrollTop` mid-momentum cancels the fling on iOS, so corrections during a - touch gesture are instead held as a margin on the first rendered row and + touch gesture are instead held as a margin on the content wrapper and reconciled into `scrollTop` when the gesture ends. Manual mode matches native semantics, including suppression at scroll offset 0 @@ -326,8 +322,8 @@ hook reuses the virtualizer's scroll wiring (via `virtualizer.options` / The full signature is `useStickToBottom(virtualizer, options?, deps?)`, with `enabled` and `slack` in the options. Re-pinning is driven by the loaded -window and the spacers; pass `deps` for content that grows at the bottom -without changing them — e.g. the last row streaming in taller: +window and the space estimates; pass `deps` for content that grows at the +bottom without changing them — e.g. the last row streaming in taller: ```ts useStickToBottom(virtualizer, {}, [lastMessage?.text]); diff --git a/demo/react/App.tsx b/demo/react/App.tsx index 99c1adb..879b5f2 100644 --- a/demo/react/App.tsx +++ b/demo/react/App.tsx @@ -7,7 +7,7 @@ import React, {useCallback, useRef} from 'react'; import styles from '../shared/App.module.css'; import {DevPanel} from './DevPanel.tsx'; import {ItemDetail} from './ItemDetail.tsx'; -import {ItemRow, Spacer} from './ItemRow.tsx'; +import {ItemRow} from './ItemRow.tsx'; import {ListHeader} from './ListHeader.tsx'; import { getRowKey, @@ -81,20 +81,22 @@ export function App(): React.ReactNode { onToggleSortField={toggleSortField} onToggleSortDirection={toggleSortDirection} /> - {/* Scrollable viewport. Rows render in normal flow between two spacers - that stand in for the unloaded rows above and below. */} + {/* Scrollable viewport. Rows render in normal flow inside a content + wrapper whose padding stands in for the unloaded rows above and + below (padding, not margin, so it always contributes to the + scrollable extent). */}
- - {items.map(item => ( - - ))} - +
+ {items.map(item => ( + + ))} +
{permalinkID && ( diff --git a/demo/react/ItemRow.tsx b/demo/react/ItemRow.tsx index 5b8c675..c5aadd8 100644 --- a/demo/react/ItemRow.tsx +++ b/demo/react/ItemRow.tsx @@ -56,15 +56,6 @@ function rowPresentation( } } -/** - * A spacer standing in for the estimated unloaded rows above/below the loaded - * window. The `.spacer` class carries `overflow-anchor: none` so native scroll - * anchoring never anchors to a resizing spacer, only to a real row. - */ -export function Spacer({height}: {height: number}): React.ReactNode { - return
; -} - /** * One list row — or its loading placeholder — shared by the element and window * scroller demos. `rowAttributes` stamps the attributes the virtualizer diff --git a/demo/react/WindowList.tsx b/demo/react/WindowList.tsx index 94e42aa..edcd514 100644 --- a/demo/react/WindowList.tsx +++ b/demo/react/WindowList.tsx @@ -5,7 +5,7 @@ import { } from '@rocicorp/zero-virtual/react'; import React, {useCallback, useRef} from 'react'; import {DevPanel} from './DevPanel.tsx'; -import {ItemRow, Spacer} from './ItemRow.tsx'; +import {ItemRow} from './ItemRow.tsx'; import {ListHeader} from './ListHeader.tsx'; import { getRowKey, @@ -86,8 +86,13 @@ export function WindowList(): React.ReactNode { />
-
- + {/* The rows element doubles as the content wrapper: its padding stands + in for the unloaded rows above and below (it isn't the scroll + container — the window is — so padding on it is safe). */} +
{items.map(item => ( ))} -
{ // The same row must be back in the viewport — `toBeInViewport` (not just // `toBeVisible`) catches the regression where the anchor is restored but the - // scroll offset is not, leaving the viewport parked on the blank top spacer. + // scroll offset is not, leaving the viewport parked on the blank top padding. await expect(page.locator(`a[href="${topRowHref}"]`)).toBeInViewport({ timeout: TIMEOUT, }); diff --git a/demo/react/list-shared.ts b/demo/react/list-shared.ts index c2e36db..65032a9 100644 --- a/demo/react/list-shared.ts +++ b/demo/react/list-shared.ts @@ -93,8 +93,9 @@ export function useGetPageQuery(listContextParams: ListContextParams) { /** * A single representative row-height estimate per mode, used only to size the - * spacers that stand in for not-yet-loaded rows (the scrollbar is approximate, - * as with any virtualized list). Real heights come from the DOM. + * content-wrapper padding that stands in for not-yet-loaded rows (the + * scrollbar is approximate, as with any virtualized list). Real heights come + * from the DOM. */ export function useEstimateSize(heightMode: HeightMode): () => number { return useCallback( @@ -116,8 +117,8 @@ export function useDemoControls() { const [anchoringStr, setAnchoring] = useUrlState('anchoring', 'manual'); const anchoring = anchoringStr as AnchoringMode; const [countStr] = useUrlState('count', ''); - // Guard against non-numeric ?count= — NaN would flow into the spacer math - // (NaN heights) with no error. + // Guard against non-numeric ?count= — NaN would flow into the space + // estimates (NaN padding) with no error. const parsedCount = Number(countStr); const count = countStr && Number.isFinite(parsedCount) ? parsedCount : undefined; diff --git a/demo/shared/App.module.css b/demo/shared/App.module.css index beed5c4..e946cb3 100644 --- a/demo/shared/App.module.css +++ b/demo/shared/App.module.css @@ -62,14 +62,6 @@ (none in manual mode, auto in native mode) — don't set it here. */ } -/* Spacers stand in for the estimated unloaded rows above/below the window. - They must NOT be scroll-anchor candidates — the browser should anchor to a - real row, not a resizing spacer. */ -.spacer { - overflow-anchor: none; - width: 100%; -} - /* Rows. The base is the design's two-column row (title + description left, date right); per height-mode variants below adjust the vertical rhythm so the demo's uniform / fixed / dynamic sizing keeps exercising the engine. */ diff --git a/demo/solid/App.tsx b/demo/solid/App.tsx index a3b607d..d5f0adc 100644 --- a/demo/solid/App.tsx +++ b/demo/solid/App.tsx @@ -7,7 +7,7 @@ import {For, Show, createMemo} from 'solid-js'; import styles from '../shared/App.module.css'; import {DevPanel} from './DevPanel.tsx'; import {ItemDetail} from './ItemDetail.tsx'; -import {ItemRow, Spacer} from './ItemRow.tsx'; +import {ItemRow} from './ItemRow.tsx'; import {ListHeader} from './ListHeader.tsx'; import { createDemoControls, @@ -85,22 +85,30 @@ export function App() { onToggleSortDirection={toggleSortDirection} />
- - {/* Plain is safe here: the binding exposes items as a store - reconciled by row key, so a row's VirtualRow instance — and with - it the DOM node scroll anchoring measures against — survives - paging. */} - - {item => ( - - )} - - + {/* Content wrapper: its padding stands in for the unloaded rows + above and below (padding, not margin, so it always contributes + to the scrollable extent). */} +
+ {/* Plain is safe here: the binding exposes items as a store + reconciled by row key, so a row's VirtualRow instance — and + with it the DOM node scroll anchoring measures against — + survives paging. */} + + {item => ( + + )} + +
diff --git a/demo/solid/ItemRow.tsx b/demo/solid/ItemRow.tsx index 05fe57f..88b448b 100644 --- a/demo/solid/ItemRow.tsx +++ b/demo/solid/ItemRow.tsx @@ -49,10 +49,6 @@ function rowPresentation( } } -export function Spacer(props: {height: number}) { - return
; -} - // Reads props via accessors (no destructuring): `item` is a store node the // binding keeps stable per row key, so the same DOM node survives paging // while its index/heightMode/permalink props update in place. diff --git a/src/core/dom.ts b/src/core/dom.ts index 1c94c1f..24f0fc4 100644 --- a/src/core/dom.ts +++ b/src/core/dom.ts @@ -25,7 +25,7 @@ export function queryRows(el: HTMLElement): Iterable { return el.querySelectorAll(`[${VROW_INDEX_ATTR}]`); } -/** The first rendered row element (the held-margin carrier), or null. */ +/** The first rendered row element, or null. */ export function firstRow(el: HTMLElement): HTMLElement | null { return el.querySelector(`[${VROW_INDEX_ATTR}]`); } diff --git a/src/core/stick-to-bottom.ts b/src/core/stick-to-bottom.ts index 9457824..d95b965 100644 --- a/src/core/stick-to-bottom.ts +++ b/src/core/stick-to-bottom.ts @@ -84,7 +84,7 @@ export function createStickToBottom( /** * The values that change whenever content can grow at the bottom: the loaded - * window and the spacers. The React binding spreads them into its effect + * window and the space estimates. The React binding spreads them into its effect * deps; the Solid binding calls this inside its effect so reading the store * fields tracks them. */ diff --git a/src/core/types.ts b/src/core/types.ts index 085c45f..0884ead 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -158,8 +158,8 @@ export type VirtualRow = { * - `native`: rely on the browser's CSS `overflow-anchor` (simplest; used where * it's reliable). * - `manual`: the momentum-safe manual anchoring (reference-row pinning, with - * touch-time corrections held as a first-row margin), for browsers without - * native scroll anchoring. + * touch-time corrections held as a content-wrapper margin), for browsers + * without native scroll anchoring. * - `auto`: feature-detect `overflow-anchor` support (default). */ export type AnchoringMode = 'auto' | 'manual' | 'native'; diff --git a/src/core/virtualizer.test.ts b/src/core/virtualizer.test.ts index 09ace71..c7ef876 100644 --- a/src/core/virtualizer.test.ts +++ b/src/core/virtualizer.test.ts @@ -43,11 +43,12 @@ function makeVirtualizer( return v; } -// Rows are rendered in flow between a top spacer (`spaceBefore`) and a bottom -// spacer (`spaceAfter`); there is no virtual "count". These cases pin down the -// derived outputs — the same table the React hook test used, now exercised -// directly against the framework-free core (which also covers Solid). -describe('ZeroVirtualizer snapshot — items, spacers and total', () => { +// Rows are rendered in flow inside a content wrapper padded by `spaceBefore` +// (top) and `spaceAfter` (bottom); there is no virtual "count". These cases +// pin down the derived outputs — the same table the React hook test used, now +// exercised directly against the framework-free core (which also covers +// Solid). +describe('ZeroVirtualizer snapshot — items, space and total', () => { test.for([ { name: 'empty, loading (no rows yet)', diff --git a/src/core/virtualizer.ts b/src/core/virtualizer.ts index 7d9fe55..33db687 100644 --- a/src/core/virtualizer.ts +++ b/src/core/virtualizer.ts @@ -265,9 +265,13 @@ export class ZeroVirtualizer { #observedItems: ReadonlyArray> | null = null; #programmaticScroll = false; #anchorKey: RowKey | null = null; - // The reference row's top offset from the viewport top, in the settled - // (hold-free) frame — what the anchoring keeps stable across relabels and - // off-screen resizes (matching native `overflow-anchor`). + // The reference row's top position in content (document) coordinates, in + // the settled (hold-free) frame. Content above the row changing size moves + // it off this target; the measured delta is what compensation folds back + // into scrollTop (or the held margin) so the viewport stays visually stable + // (matching native `overflow-anchor`). Content coordinates, not + // viewport-relative: scrolling must not read as content movement (see + // #anchorOffsetOf). #anchorOffset = 0; // True from a list reset (context change / restore / permalink) until the new // data has loaded, so we don't adopt or pin a stale reference row. @@ -285,7 +289,7 @@ export class ZeroVirtualizer { #persistTimer: ReturnType | undefined; // The live anchoring state. readonly #anchorState = {isScrolling: false, pendingJump: 0}; - // The row element currently carrying the held margin. + // The element currently carrying the held margin (see #holdTarget). #holdEl: HTMLElement | null = null; // Set to a permalink id when navigation targets a row that is NOT currently @@ -388,8 +392,9 @@ export class ZeroVirtualizer { // Toggle native scroll anchoring to match the resolved mode: off (so it // can't fight our compensation) in manual mode, on in native mode. Set on - // the scroll element; spacers keep their own `overflow-anchor: none` so - // native mode still anchors to a real row, not a resizing spacer. + // the scroll element; the rows live inside a padded content wrapper, and + // native anchoring picks a real row inside it — wrapper padding changes + // move the row, which is exactly what the browser compensates for. const scroller = this.#resolveScrollElement(el); this.#scrollElement = scroller; this.#prevOverflowAnchor = scroller.style.overflowAnchor; @@ -564,9 +569,9 @@ export class ZeroVirtualizer { const {estimatedTotal, hasReachedStart, hasReachedEnd} = this.#paging; const effectiveEstimatedTotal = this.#effectiveEstimatedTotal(); - // Spacer heights: the estimated pixel extent of the unloaded rows above - // and below the loaded window (the scrollbar is approximate, exactly as - // with any virtualized list). + // Space estimates: the estimated pixel extent of the unloaded rows above + // and below the loaded window, rendered as the content wrapper's padding + // (the scrollbar is approximate, exactly as with any virtualized list). const rowEstimate = this.#rowEstimate(); const rowsBefore = Math.max(0, rows.firstRowIndex); const rowsAfter = rows.atEnd @@ -719,35 +724,59 @@ export class ZeroVirtualizer { // visually stable ourselves: pin one keyed "reference" row; whenever the // loaded rows change size above it, measure how far it moved and put it back. // Idle we fold the correction into scrollTop; during a touch gesture we hold - // it as a margin-top on the first rendered row — writing scrollTop - // mid-momentum is ignored / cancels the fling on iOS, while a layout shift - // is fine — and reconcile margin→scrollTop when the gesture ends. + // it as a margin-top on the content wrapper — writing scrollTop mid-momentum + // is ignored / cancels the fling on iOS, while a layout shift is fine — and + // reconcile margin→scrollTop when the gesture ends. + + // The element that carries the held margin: the rows' content wrapper (it + // survives paging, unlike the first row). Margin, not the wrapper's padding: + // the hold is usually negative (pull content up) and padding clamps at 0 — + // and `padding-top` is the consumer's `spaceBefore` binding, which their + // next render would clobber. Falls back to the first row itself when rows + // are direct children of the scroll container, where a margin on it would + // land outside the scrollable content and shift nothing. + #holdTarget(el: HTMLElement): HTMLElement | null { + const first = firstRow(el); + if (!first) return null; + const parent = first.parentElement; + return parent && parent !== this.#scroller(el) ? parent : first; + } - // Apply the held correction as a margin-top on the first rendered row (px is + // Apply the held correction as a margin-top on the hold target (px is // -pendingJump: negative pulls the content up). #applyHold(px: number): void { const el = this.#el; - const first = el ? firstRow(el) : null; + const target = el ? this.#holdTarget(el) : null; const prev = this.#holdEl; - if (prev && prev !== first) prev.style.marginTop = ''; - this.#holdEl = px !== 0 ? first : null; - if (first) first.style.marginTop = px !== 0 ? `${px}px` : ''; + if (prev && prev !== target) prev.style.marginTop = ''; + this.#holdEl = px !== 0 ? target : null; + if (target) target.style.marginTop = px !== 0 ? `${px}px` : ''; } - // If paging replaced the first row while a hold is applied, move the margin - // to the new first row (pre-paint, so nothing shifts visibly). + // If the hold carrier changed while a hold is applied (only possible in the + // first-row fallback — the wrapper survives paging), move the margin to the + // new carrier (pre-paint, so nothing shifts visibly). #migrateHold(): void { if (this.#holdEl === null) return; const el = this.#el; - const first = el ? firstRow(el) : null; - if (first !== this.#holdEl) { + const target = el ? this.#holdTarget(el) : null; + if (target !== this.#holdEl) { this.#applyHold(-this.#anchorState.pendingJump); } } - // A row rect's top offset from the viewport top, the held margin folded out. + // A row rect's top position in content (document) coordinates — the scroll + // offset and the held margin folded out. Content coordinates make the + // measurement scroll-invariant: a measure landing between a scrollTop write + // and its scroll event (before #onScrollOffset re-bases the anchor) must not + // mistake the scroll itself for content movement and "compensate" it away. #anchorOffsetOf(el: HTMLElement, rect: DOMRect): number { - return rect.top - this.#viewportTop(el) + this.#anchorState.pendingJump; + return ( + rect.top - + this.#viewportTop(el) + + this.#scrollOffset(el) + + this.#anchorState.pendingJump + ); } #refreshAnchor(): void { @@ -770,17 +799,17 @@ export class ZeroVirtualizer { } // The single correction choke point: idle → scrollTop; mid-gesture → hold as - // the first-row margin, owed to the reconcile at gesture end. + // the content-wrapper margin, owed to the reconcile at gesture end. #compensate(delta: number): void { const el = this.#el; if (!el) return; + // The target is a content-space position, which neither correction moves + // (a scrollTop write by definition; the hold's shift is folded back out by + // #anchorOffsetOf via pendingJump). Re-baseline it so the same growth + // isn't re-compensated on the next measure. + this.#anchorOffset += delta; if (this.#anchorState.isScrolling && this.#touchScroll) { this.#anchorState.pendingJump += delta; - // Unlike the scrollTop path (which moves the settled layout back to the - // target), the hold leaves the reference's settled position shifted by - // `delta`. Re-baseline the target so we don't keep re-compensating the - // same growth; the owed jump is flushed at reconcile. - this.#anchorOffset += delta; this.#applyHold(-this.#anchorState.pendingJump); } else { this.#setScrollTop(this.#scrollOffset(el) + delta); @@ -801,7 +830,7 @@ export class ZeroVirtualizer { } const el = this.#el; if (!el) return; - // If paging swapped out the row carrying the held margin, re-pin it before + // If the hold carrier changed (first-row fallback only), re-pin it before // measuring so the hold isn't double-counted as movement. this.#migrateHold(); // Match native scroll anchoring, which the spec suppresses at scroll @@ -1199,7 +1228,8 @@ export class ZeroVirtualizer { if (firstVisible === Infinity) { // No loaded row is visible: a far jump (scrollbar drag, instant - // scrollTop write) put the viewport entirely inside a spacer, so the + // scrollTop write) put the viewport entirely inside the wrapper's + // padding, so the // edge-distance logic below has nothing to react to and paging would // stall. Recover: a jump to the very top re-anchors at the start // directly; otherwise cascade a page toward the viewport from the @@ -1275,7 +1305,7 @@ export class ZeroVirtualizer { onScrollStateChange({ anchor, // The logical committed offset: if a gesture is mid-flight with an - // owed jump held in the first-row margin, fold it in so restore lands + // owed jump held in the wrapper margin, fold it in so restore lands // right. scrollTop: el ? this.#scrollOffset(el) + this.#anchorState.pendingJump diff --git a/src/react/use-stick-to-edge.ts b/src/react/use-stick-to-edge.ts index 75151c0..a0ae079 100644 --- a/src/react/use-stick-to-edge.ts +++ b/src/react/use-stick-to-edge.ts @@ -27,10 +27,10 @@ export type {StickOptions} from '../core/stick-to-bottom.ts'; * @param virtualizer The result of `useZeroVirtualizer` / * `useZeroWindowVirtualizer`. It supplies the scroll wiring (via * `virtualizer.options` / `virtualizer.scrollElement`), and its - * items/spacers drive the re-pinning. + * items/space estimates drive the re-pinning. * @param deps Extra values that change when content can grow at the bottom in - * ways the items/spacers don't capture (e.g. the last row streaming in - * taller). Must keep a stable length across renders, like hook deps. + * ways the items/space estimates don't capture (e.g. the last row streaming + * in taller). Must keep a stable length across renders, like hook deps. */ export function useStickToBottom( virtualizer: ZeroVirtualizerResult, diff --git a/src/react/use-zero-virtualizer.test.ts b/src/react/use-zero-virtualizer.test.ts index 9f6dec4..5d0c7ab 100644 --- a/src/react/use-zero-virtualizer.test.ts +++ b/src/react/use-zero-virtualizer.test.ts @@ -56,10 +56,11 @@ afterEach(() => { vi.clearAllMocks(); }); -// Rows are rendered in flow between a top spacer (`spaceBefore`) and a bottom -// spacer (`spaceAfter`); there is no virtual "count". These cases pin down the -// derived outputs: how many rows to render, the spacer sizes, and `total`. -describe('useZeroVirtualizer - items, spacers and total', () => { +// Rows are rendered in flow inside a content wrapper padded by `spaceBefore` +// (top) and `spaceAfter` (bottom); there is no virtual "count". These cases +// pin down the derived outputs: how many rows to render, the space estimates, +// and `total`. +describe('useZeroVirtualizer - items, space and total', () => { test.for([ { name: 'empty, loading (no rows yet)', diff --git a/src/react/use-zero-virtualizer.ts b/src/react/use-zero-virtualizer.ts index d2b1a08..c8fce8b 100644 --- a/src/react/use-zero-virtualizer.ts +++ b/src/react/use-zero-virtualizer.ts @@ -33,7 +33,8 @@ export type UseZeroVirtualizerOptions = /** * Result object returned by the useZeroVirtualizer hook: the loaded rows to - * render between two spacers, plus load/paging status, the resolved scroll + * render inside a content wrapper padded by `spaceBefore` / `spaceAfter`, + * plus load/paging status, the resolved scroll * wiring (`options`), and the current scrolling element (`scrollElement`). * See {@linkcode VirtualizerResult} for field semantics. The object identity * is stable between renders whose content didn't change. diff --git a/src/solid/create-stick-to-bottom.ts b/src/solid/create-stick-to-bottom.ts index 1ac94b7..c330d66 100644 --- a/src/solid/create-stick-to-bottom.ts +++ b/src/solid/create-stick-to-bottom.ts @@ -17,10 +17,11 @@ import type {CreateZeroVirtualizerResult} from './create-zero-virtualizer.ts'; * * @param snapshot The accessor returned by `createZeroVirtualizer` or * `createZeroWindowVirtualizer`. It supplies the scroll wiring (via - * `options` / `scrollElement`), and its items/spacers drive the re-pinning. + * `options` / `scrollElement`), and its items/space estimates drive the + * re-pinning. * @param deps Optional accessor of extra values that change when content can - * grow at the bottom in ways the items/spacers don't capture (e.g. the last - * row streaming in taller). + * grow at the bottom in ways the items/space estimates don't capture (e.g. + * the last row streaming in taller). */ export function createStickToBottom( snapshot: Accessor>, diff --git a/src/solid/create-zero-virtualizer.test.ts b/src/solid/create-zero-virtualizer.test.ts index f49c511..12f164f 100644 --- a/src/solid/create-zero-virtualizer.test.ts +++ b/src/solid/create-zero-virtualizer.test.ts @@ -101,7 +101,7 @@ describe('createZeroVirtualizer (solid wrapper wiring)', () => { expect(snap().items).toHaveLength(4); expect(snap().total).toBeUndefined(); // end no longer reached // The estimate is a high-water mark of the discovered extent — it never - // projects past the loaded rows, so the bottom spacer stays collapsed. + // projects past the loaded rows, so the bottom padding stays collapsed. expect(snap().spaceAfter).toBe(0); dispose(); }); From d6c7aac0751f83176fabf8097433ea6e080ece08 Mon Sep 17 00:00:00 2001 From: Erik Arvidsson Date: Mon, 6 Jul 2026 17:15:50 +0200 Subject: [PATCH 2/2] refactor: enhance stick-to-bottom functionality with ResizeObservers for dynamic content growth --- README.md | 20 ++- demo/react/e2e/tests/stick.spec.ts | 81 +++++++++++++ src/core/dom.ts | 9 ++ src/core/index.ts | 2 +- src/core/stick-to-bottom.ts | 162 +++++++++++++++---------- src/react/use-stick-to-edge.ts | 32 +++-- src/react/use-zero-virtualizer.test.ts | 52 ++++++-- src/solid/create-stick-to-bottom.ts | 19 ++- 8 files changed, 260 insertions(+), 117 deletions(-) create mode 100644 demo/react/e2e/tests/stick.spec.ts diff --git a/README.md b/README.md index 0f2b7d5..533c420 100644 --- a/README.md +++ b/README.md @@ -320,17 +320,15 @@ following stops (read history in peace); scroll back down and it re-arms. The hook reuses the virtualizer's scroll wiring (via `virtualizer.options` / `virtualizer.scrollElement`), so it works unchanged with window scrolling. -The full signature is `useStickToBottom(virtualizer, options?, deps?)`, with -`enabled` and `slack` in the options. Re-pinning is driven by the loaded -window and the space estimates; pass `deps` for content that grows at the -bottom without changing them — e.g. the last row streaming in taller: - -```ts -useStickToBottom(virtualizer, {}, [lastMessage?.text]); -``` - -In Solid, use `createStickToBottom(snapshot, options?, deps?)` with accessors -in the reactive slots. +The full signature is `useStickToBottom(virtualizer, options?)`, with +`enabled` and `slack` in the options. Re-pinning is driven purely by the DOM +— ResizeObservers on the rows' content wrapper and the scroll container — so +_any_ growth at the bottom re-pins, including content the virtualizer doesn't +know about, like the last row streaming in taller. There are no content deps +to declare. + +In Solid, use `createStickToBottom(snapshot, options?)` with accessors in the +reactive slots. A feed parked at the top needs no helper: at scroll offset 0, scroll anchoring (native and manual alike) deliberately stands down, so newly prepended content diff --git a/demo/react/e2e/tests/stick.spec.ts b/demo/react/e2e/tests/stick.spec.ts new file mode 100644 index 0000000..31bbed1 --- /dev/null +++ b/demo/react/e2e/tests/stick.spec.ts @@ -0,0 +1,81 @@ +import {expect, test, type Page} from '@playwright/test'; +import {viewportHandle, waitForRows} from './helpers.ts'; + +const TIMEOUT = 20_000; + +// Distance (px) from the bottom edge; 0-ish when pinned. +function bottomGap(page: Page): Promise { + return page.evaluate(() => { + const vp = document.querySelector('[class*="viewport"]')!; + return vp.scrollHeight - vp.scrollTop - vp.clientHeight; + }); +} + +// Grow the rows' content wrapper by `px`, standing in for content growth the +// virtualizer doesn't announce (e.g. the last row streaming in taller). The +// stick-to-bottom ResizeObserver on the wrapper must pick it up on its own. +function growContent(page: Page, px: number): Promise { + return page.evaluate(px => { + const wrapper = document.querySelector('[data-vrow-index]')! + .parentElement as HTMLElement; + const current = parseFloat(getComputedStyle(wrapper).paddingBottom) || 0; + wrapper.style.paddingBottom = `${current + px}px`; + }, px); +} + +test.describe('Stick to bottom', () => { + test('follows growth at the bottom only while parked there', async ({ + page, + }) => { + // Exact count for a stable scrollbar, so the jump to the bottom lands at + // the true end instead of chasing a growing estimate. + await page.goto('/?follow=bottom&count=200'); + await waitForRows(page, TIMEOUT); + + // Park at the bottom and wait for paging to settle there: the scrollable + // extent stops changing (the last page loaded, the bottom space + // collapsed) across consecutive polls. + const vp = await viewportHandle(page); + let prevHeight = -1; + await expect(async () => { + const height = await vp.evaluate(el => { + el.scrollTop = el.scrollHeight; + return el.scrollHeight; + }); + const stable = height === prevHeight; + prevHeight = height; + expect(stable).toBe(true); + expect(await bottomGap(page)).toBeLessThanOrEqual(1); + }).toPass({timeout: TIMEOUT, intervals: [100, 150, 200]}); + + // Content grows at the bottom → the viewport must follow, purely via the + // wrapper ResizeObserver (no virtualizer content tick involved). + await growContent(page, 200); + await expect(async () => { + expect(await bottomGap(page)).toBeLessThanOrEqual(1); + }).toPass({timeout: TIMEOUT}); + + // Scroll away → unstuck: further growth must not yank the viewport. + const before = await vp.evaluate(el => (el.scrollTop -= 400)); + await growContent(page, 200); + // Give a re-pin (if any, wrongly) time to land. + await page.waitForTimeout(250); + const after = await vp.evaluate(el => el.scrollTop); + expect(Math.abs(after - before)).toBeLessThanOrEqual(1); + + // Scroll back to the bottom → re-arms and follows again. Let the scroll + // event's stuckness measure land (it reads live geometry, so growing in + // the same frame would read as "not at the bottom") before growing. + await vp.evaluate(el => { + el.scrollTop = el.scrollHeight; + }); + await expect(async () => { + expect(await bottomGap(page)).toBeLessThanOrEqual(1); + }).toPass({timeout: TIMEOUT}); + await page.waitForTimeout(100); + await growContent(page, 200); + await expect(async () => { + expect(await bottomGap(page)).toBeLessThanOrEqual(1); + }).toPass({timeout: TIMEOUT}); + }); +}); diff --git a/src/core/dom.ts b/src/core/dom.ts index 24f0fc4..8e530c2 100644 --- a/src/core/dom.ts +++ b/src/core/dom.ts @@ -30,6 +30,15 @@ export function firstRow(el: HTMLElement): HTMLElement | null { return el.querySelector(`[${VROW_INDEX_ATTR}]`); } +/** + * The rows' content wrapper: the parent of the first rendered row — the + * element carrying the `spaceBefore`/`spaceAfter` padding, whose border-box + * grows and shrinks with the content. Null until rows render. + */ +export function contentWrapper(el: HTMLElement): HTMLElement | null { + return firstRow(el)?.parentElement ?? null; +} + /** Find a rendered row element by its stable key. */ export function findRow(el: HTMLElement, key: RowKey): HTMLElement | null { return el.querySelector( diff --git a/src/core/index.ts b/src/core/index.ts index 72f352e..89af1a8 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,6 +7,7 @@ * `./react` and `./solid` entry points are the stable surfaces. */ export { + contentWrapper, findRow, firstRow, queryRows, @@ -31,7 +32,6 @@ export { type RowsSnapshot, } from './rows.ts'; export { - contentGrowthDeps, createStickToBottom, createStickToBottomCache, DEFAULT_STICK_SLACK, diff --git a/src/core/stick-to-bottom.ts b/src/core/stick-to-bottom.ts index d95b965..8d99dcc 100644 --- a/src/core/stick-to-bottom.ts +++ b/src/core/stick-to-bottom.ts @@ -1,4 +1,5 @@ -import type {ObserveElementOffset, ResolvedScrollOptions} from './scroll.ts'; +import {contentWrapper} from './dom.ts'; +import type {ResolvedScrollOptions} from './scroll.ts'; /** * Slack (px) around the bottom edge so sub-pixel rounding or a stray @@ -19,95 +20,119 @@ export type StickOptions = { export type StickToBottomController = { /** - * Call after content may have grown at the bottom (post-DOM-update, - * pre-paint). Re-pins to the bottom if the user was parked there; attaches - * the scroll listener lazily once the element exists. + * Idempotent (re)wiring: resolves the elements and attaches the observers + * once both exist, re-attaching if either was replaced. Call whenever the + * elements may have (dis)appeared — e.g. per framework commit. The actual + * re-pinning is driven by the observers, not by this call. */ - contentChanged(): void; - /** Remove listeners. Safe to call repeatedly. */ + ensure(): void; + /** Remove listeners and observers. Safe to call repeatedly. */ detach(): void; }; /** - * The framework-free heart of stick-to-bottom: track "is the user parked at - * the bottom?" on every scroll (i.e. *before* content grows, which is the - * whole trick), and snap back to the bottom on content growth only while - * stuck. Starts unstuck; the first measurement decides (a freshly mounted - * short list measures as at-bottom, so a chat still starts stuck, while a - * restored mid-list position is never yanked). + * The framework-free heart of stick-to-bottom, driven purely by the DOM: + * track "is the user parked at the bottom?" on every scroll (i.e. *before* + * content grows, which is the whole trick), and snap back to the bottom on + * growth only while stuck. Growth is detected with two ResizeObservers — one + * on the rows' content wrapper (anything that changes the scrollable extent: + * rows added, the space estimates re-rendered as padding, a row streaming in + * taller) and one on the scroll container (viewport resizes; the window + * `resize` event when the document itself scrolls). Both fire post-layout, + * pre-paint, so the re-pin never flickers. No content change notifications + * are needed from the framework or the virtualizer. + * + * Starts unstuck; the first measurement decides (a freshly mounted short list + * measures as at-bottom, so a chat still starts stuck, while a restored + * mid-list position is never yanked). * * @param getScrollElement Returns the resolved scrolling element (the * virtualizer's `scrollElement`); may be null until mounted. - * @param observeElementOffset The same scroll-offset observer the virtualizer - * uses (stuckness is tracked on every scroll). + * @param getContentElement Returns the rows' content wrapper — the element + * whose border-box grows with the content (the bindings derive it as the + * parent of the first rendered row); may be null until rows render. */ export function createStickToBottom( getScrollElement: () => HTMLElement | null, - observeElementOffset: ObserveElementOffset, + getContentElement: () => HTMLElement | null, slack: number = DEFAULT_STICK_SLACK, ): StickToBottomController { let stuck = false; - let attachedEl: HTMLElement | null = null; - let unsubscribe: (() => void) | null = null; + let attached: { + readonly scroller: HTMLElement; + readonly content: HTMLElement; + } | null = null; + let cleanup: (() => void) | null = null; - const ensureAttached = (): HTMLElement | null => { - const scroller = getScrollElement(); - if (scroller === attachedEl) return scroller; - unsubscribe?.(); - unsubscribe = null; - attachedEl = scroller; - if (!scroller) return null; - const measure = () => { - stuck = - scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= - slack; - }; - measure(); - unsubscribe = - observeElementOffset({scrollElement: scroller}, measure) ?? null; - return scroller; + const detach = () => { + cleanup?.(); + cleanup = null; + attached = null; }; return { - contentChanged() { - const scroller = ensureAttached(); - if (!scroller || !stuck) return; - scroller.scrollTop = scroller.scrollHeight; - }, - detach() { - unsubscribe?.(); - unsubscribe = null; - attachedEl = null; + ensure() { + const scroller = getScrollElement(); + const content = scroller && getContentElement(); + if ( + attached && + attached.scroller === scroller && + attached.content === content + ) { + return; + } + detach(); + if (!scroller || !content) return; + attached = {scroller, content}; + + const measure = () => { + stuck = + scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= + slack; + }; + // Re-pin using the stuckness measured before the growth. Snap to the + // exact bottom rather than preserving the (sub-slack) gap: the slack is + // rounding tolerance, not an intentional offset, and snapping is what + // guarantees the newest content is on screen. Writing past the maximum + // and letting the browser clamp lands on the exact (possibly + // fractional) end; the resulting scroll event re-measures at the + // bottom, so stuck stays latched. + const repin = () => { + if (stuck) scroller.scrollTop = scroller.scrollHeight; + }; + measure(); + + // Scroll events fire on the window when the document itself scrolls. + const scrollTarget: HTMLElement | Window = + scroller === document.scrollingElement ? window : scroller; + scrollTarget.addEventListener('scroll', measure, {passive: true}); + const observer = new ResizeObserver(repin); + observer.observe(content, {box: 'border-box'}); + let removeWindowResize: (() => void) | null = null; + if (scroller === document.scrollingElement) { + // A ResizeObserver on the scrolling element tracks the document (its + // content), not the viewport — use the window resize event instead. + window.addEventListener('resize', repin); + removeWindowResize = () => window.removeEventListener('resize', repin); + } else { + observer.observe(scroller, {box: 'border-box'}); + } + cleanup = () => { + scrollTarget.removeEventListener('scroll', measure); + observer.disconnect(); + removeWindowResize?.(); + }; }, + detach, }; } -/** - * The values that change whenever content can grow at the bottom: the loaded - * window and the space estimates. The React binding spreads them into its effect - * deps; the Solid binding calls this inside its effect so reading the store - * fields tracks them. - */ -export function contentGrowthDeps(snapshot: { - readonly items: ReadonlyArray<{readonly key: unknown}>; - readonly spaceBefore: number; - readonly spaceAfter: number; -}): unknown[] { - const {items, spaceBefore, spaceAfter} = snapshot; - return [ - items.length, - items[0]?.key ?? '', - items[items.length - 1]?.key ?? '', - spaceBefore, - spaceAfter, - ]; -} - export type StickToBottomCache = { /** - * Call per content tick while enabled. Recreates the controller when the + * Call per framework tick while enabled. Recreates the controller when the * resolved scroll wiring or `slack` changes (they're baked into the core - * controller), then re-pins via {@linkcode StickToBottomController.contentChanged}. + * controller), then lets it lazily (re)attach via + * {@linkcode StickToBottomController.ensure}. */ ensure( virtualizer: { @@ -141,11 +166,14 @@ export function createStickToBottomCache(): StickToBottomCache { // controller's whole lifetime. controller ??= createStickToBottom( () => virtualizer.scrollElement, - options.observeElementOffset, + () => { + const scroller = virtualizer.scrollElement; + return scroller && contentWrapper(scroller); + }, slack, ); key = [options, slack]; - controller.contentChanged(); + controller.ensure(); }, detach() { controller?.detach(); diff --git a/src/react/use-stick-to-edge.ts b/src/react/use-stick-to-edge.ts index a0ae079..c650d6c 100644 --- a/src/react/use-stick-to-edge.ts +++ b/src/react/use-stick-to-edge.ts @@ -1,6 +1,5 @@ import {useLayoutEffect, useRef} from 'react'; import { - contentGrowthDeps, createStickToBottomCache, DEFAULT_STICK_SLACK, type StickOptions, @@ -16,6 +15,13 @@ export type {StickOptions} from '../core/stick-to-bottom.ts'; * chat / log UI. Thin React binding over the core * {@linkcode createStickToBottomCache} state machine. * + * The behavior is driven purely by the DOM: ResizeObservers on the rows' + * content wrapper and the scroll container detect every change to the + * scrollable extent — including content the virtualizer doesn't know about, + * like the last row streaming in taller — so there is nothing to declare in + * effect deps. This hook only wires the observers up (lazily, until the + * elements exist). + * * This is a thin layer on top of scroll anchoring, not a replacement for it. * Anchoring keeps the view stable when off-screen content above changes; the * one thing it never does is *follow* new content arriving at the bottom @@ -26,36 +32,26 @@ export type {StickOptions} from '../core/stick-to-bottom.ts'; * * @param virtualizer The result of `useZeroVirtualizer` / * `useZeroWindowVirtualizer`. It supplies the scroll wiring (via - * `virtualizer.options` / `virtualizer.scrollElement`), and its - * items/space estimates drive the re-pinning. - * @param deps Extra values that change when content can grow at the bottom in - * ways the items/space estimates don't capture (e.g. the last row streaming - * in taller). Must keep a stable length across renders, like hook deps. + * `virtualizer.options` / `virtualizer.scrollElement`); the rows' content + * wrapper is found in the DOM. */ export function useStickToBottom( virtualizer: ZeroVirtualizerResult, {enabled = true, slack = DEFAULT_STICK_SLACK}: StickOptions = {}, - deps: ReadonlyArray = [], ): void { const ref = useRef(null); - // Runs per content tick, pre-paint. The content deps are deliberately in - // the deps: when the scroll container renders conditionally, - // `scrollElement` can be null at first and nothing else would re-run — the - // controller attaches lazily on each tick until the element exists. + // Runs per commit, pre-paint, with no deps on purpose: when the scroll + // container renders conditionally (or before the first rows render) the + // elements can be null and nothing else would re-run — ensure() retries + // each tick until they exist, and is an identity-check no-op after that. useLayoutEffect(() => { if (!enabled) { ref.current?.detach(); return; } (ref.current ??= createStickToBottomCache()).ensure(virtualizer, slack); - }, [ - ...contentGrowthDeps(virtualizer), - ...deps, - enabled, - virtualizer.options, - slack, - ]); + }); useLayoutEffect( () => () => { diff --git a/src/react/use-zero-virtualizer.test.ts b/src/react/use-zero-virtualizer.test.ts index 5d0c7ab..85b0920 100644 --- a/src/react/use-zero-virtualizer.test.ts +++ b/src/react/use-zero-virtualizer.test.ts @@ -54,6 +54,7 @@ function makeOptions() { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); // Rows are rendered in flow inside a content wrapper padded by `spaceBefore` @@ -304,8 +305,42 @@ describe('useStickToBottom over the virtualizer result', () => { test('re-pins to the bottom on content growth only while stuck', () => { mockUseRows.mockReturnValue(makeUseRowsResult({})); + // The re-pinning is driven by ResizeObservers on the content wrapper and + // the scroll container; capture their callbacks to fire growth manually + // (happy-dom never fires them itself). + const observers: {cb: ResizeObserverCallback; targets: Element[]}[] = []; + vi.stubGlobal( + 'ResizeObserver', + class { + readonly #entry: {cb: ResizeObserverCallback; targets: Element[]}; + constructor(cb: ResizeObserverCallback) { + this.#entry = {cb, targets: []}; + observers.push(this.#entry); + } + observe(target: Element) { + this.#entry.targets.push(target); + } + disconnect() { + this.#entry.targets.length = 0; + } + }, + ); + const fireResize = () => { + for (const {cb, targets} of observers) { + if (targets.length > 0) { + cb([], undefined as unknown as ResizeObserver); + } + } + }; + const options = makeOptions(); const scrollEl = options.getScrollElement(); + // The rows' content wrapper (found in the DOM as the first row's parent). + const wrapper = document.createElement('div'); + const row = document.createElement('div'); + row.setAttribute('data-vrow-index', '0'); + wrapper.appendChild(row); + scrollEl.appendChild(wrapper); let scrollTop = 0; let scrollHeight = 100; Object.defineProperties(scrollEl, { @@ -319,25 +354,22 @@ describe('useStickToBottom over the virtualizer result', () => { clientHeight: {get: () => 100}, }); - const {rerender} = renderHook( - ({dep}: {dep: number}) => { - const virtualizer = useZeroVirtualizer(options); - useStickToBottom(virtualizer, {}, [dep]); - }, - {initialProps: {dep: 0}}, - ); + renderHook(() => { + const virtualizer = useZeroVirtualizer(options); + useStickToBottom(virtualizer); + }); // Mounted parked at the bottom (scrollHeight - scrollTop - clientHeight - // = 0) → stuck. Content growth re-pins. + // = 0) → stuck. Content growth (the wrapper resizes) re-pins. scrollHeight = 250; - rerender({dep: 1}); + fireResize(); expect(scrollTop).toBe(250); // Scroll away → unstuck; further growth must not yank the viewport. scrollTop = 50; scrollEl.dispatchEvent(new Event('scroll')); scrollHeight = 300; - rerender({dep: 2}); + fireResize(); expect(scrollTop).toBe(50); }); }); diff --git a/src/solid/create-stick-to-bottom.ts b/src/solid/create-stick-to-bottom.ts index c330d66..4e99384 100644 --- a/src/solid/create-stick-to-bottom.ts +++ b/src/solid/create-stick-to-bottom.ts @@ -1,6 +1,5 @@ import {createEffect, onCleanup, type Accessor} from 'solid-js'; import { - contentGrowthDeps, createStickToBottomCache, DEFAULT_STICK_SLACK, type StickOptions, @@ -13,29 +12,29 @@ import type {CreateZeroVirtualizerResult} from './create-zero-virtualizer.ts'; * React `useStickToBottom`, over the same core * {@linkcode createStickToBottomCache} state machine. * + * The behavior is driven purely by the DOM (ResizeObservers on the rows' + * content wrapper and the scroll container), so there are no content deps to + * declare — the effect only wires the observers up, lazily until the + * elements exist. + * * Call during component setup (uses `onCleanup`). * * @param snapshot The accessor returned by `createZeroVirtualizer` or * `createZeroWindowVirtualizer`. It supplies the scroll wiring (via - * `options` / `scrollElement`), and its items/space estimates drive the - * re-pinning. - * @param deps Optional accessor of extra values that change when content can - * grow at the bottom in ways the items/space estimates don't capture (e.g. - * the last row streaming in taller). + * `options` / `scrollElement`); the rows' content wrapper is found in the + * DOM. */ export function createStickToBottom( snapshot: Accessor>, options: Accessor = () => ({}), - deps: Accessor> = () => [], ): void { const cache = createStickToBottomCache(); onCleanup(() => cache.detach()); createEffect(() => { + // Tracking the snapshot retries the lazy attach until the scroll + // container and rows exist; after that ensure() is an identity no-op. const snapshotValue = snapshot(); - // Reading the store fields tracks the content-growth signals. - contentGrowthDeps(snapshotValue); - deps(); const {enabled = true, slack = DEFAULT_STICK_SLACK} = options(); if (!enabled) { cache.detach();