diff --git a/README.md b/README.md
index 9b3e3b7..533c420 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
@@ -324,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 spacers; 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]);
-```
+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?, deps?)` with accessors
-in the reactive slots.
+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/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/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/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..8e530c2 100644
--- a/src/core/dom.ts
+++ b/src/core/dom.ts
@@ -25,11 +25,20 @@ 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}]`);
}
+/**
+ * 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 9457824..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 spacers. 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/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..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/spacers 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.
+ * `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 9f6dec4..85b0920 100644
--- a/src/react/use-zero-virtualizer.test.ts
+++ b/src/react/use-zero-virtualizer.test.ts
@@ -54,12 +54,14 @@ function makeOptions() {
afterEach(() => {
vi.clearAllMocks();
+ vi.unstubAllGlobals();
});
-// 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)',
@@ -303,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, {
@@ -318,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/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..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,28 +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/spacers 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).
+ * `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();
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();
});