Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 32 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div ref={parentRef} style={{overflow: 'auto', height: '100vh'}}>
<div style={{height: spaceBefore, overflowAnchor: 'none'}} />
{items.map(({index, key, row}) => (
<div key={key} {...rowAttributes(index, key)}>
{row ? row.title : 'Loading...'}
</div>
))}
<div style={{height: spaceAfter, overflowAnchor: 'none'}} />
<div style={{paddingTop: spaceBefore, paddingBottom: spaceAfter}}>
{items.map(({index, key, row}) => (
<div key={key} {...rowAttributes(index, key)}>
{row ? row.title : 'Loading...'}
</div>
))}
</div>
</div>
);
}
Expand Down Expand Up @@ -221,23 +222,18 @@ export function ItemList() {
<div ref={parentRef} style={{overflow: 'auto', height: '100vh'}}>
<div
style={{
'height': `${snapshot().spaceBefore}px`,
'overflow-anchor': 'none',
}}
/>
<For each={snapshot().items}>
{item => (
<div {...rowAttributes(item.index, item.key)}>
{item.row ? item.row.title : 'Loading...'}
</div>
)}
</For>
<div
style={{
'height': `${snapshot().spaceAfter}px`,
'overflow-anchor': 'none',
'padding-top': `${snapshot().spaceBefore}px`,
'padding-bottom': `${snapshot().spaceAfter}px`,
}}
/>
>
<For each={snapshot().items}>
{item => (
<div {...rowAttributes(item.index, item.key)}>
{item.row ? item.row.title : 'Loading...'}
</div>
)}
</For>
</div>
</div>
);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 16 additions & 14 deletions demo/react/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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). */}
<div ref={parentRef} className={styles.viewport}>
<Spacer height={spaceBefore} />
{items.map(item => (
<ItemRow
key={item.key}
item={item}
heightMode={heightMode}
sortField={sortField}
permalinkID={permalinkID}
/>
))}
<Spacer height={spaceAfter} />
<div style={{paddingTop: spaceBefore, paddingBottom: spaceAfter}}>
{items.map(item => (
<ItemRow
key={item.key}
item={item}
heightMode={heightMode}
sortField={sortField}
permalinkID={permalinkID}
/>
))}
</div>
</div>
</div>
{permalinkID && (
Expand Down
9 changes: 0 additions & 9 deletions demo/react/ItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div className={styles.spacer} style={{height}} />;
}

/**
* One list row — or its loading placeholder — shared by the element and window
* scroller demos. `rowAttributes` stamps the attributes the virtualizer
Expand Down
12 changes: 8 additions & 4 deletions demo/react/WindowList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -86,8 +86,13 @@ export function WindowList(): React.ReactNode {
/>
</div>

<div ref={rowsRef}>
<Spacer height={spaceBefore} />
{/* 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). */}
<div
ref={rowsRef}
style={{paddingTop: spaceBefore, paddingBottom: spaceAfter}}
>
{items.map(item => (
<ItemRow
key={item.key}
Expand All @@ -97,7 +102,6 @@ export function WindowList(): React.ReactNode {
permalinkID={permalinkID}
/>
))}
<Spacer height={spaceAfter} />
</div>
<DevPanel
getScrollElement={getScrollElement}
Expand Down
2 changes: 1 addition & 1 deletion demo/react/e2e/tests/scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ test.describe('Back / forward and scroll restore', () => {

// 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,
});
Expand Down
81 changes: 81 additions & 0 deletions demo/react/e2e/tests/stick.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<void> {
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});
});
});
9 changes: 5 additions & 4 deletions demo/react/list-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
Expand Down
8 changes: 0 additions & 8 deletions demo/shared/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
42 changes: 25 additions & 17 deletions demo/solid/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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,
Expand Down Expand Up @@ -43,7 +43,7 @@
setFollow,
} = createDemoControls();

let parentRef: HTMLDivElement | undefined;

Check warning on line 46 in demo/solid/App.tsx

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unassigned-vars)

'parentRef' is always 'undefined' because it's never assigned.
const getScrollElement = () => parentRef ?? null;

const estimateSize = createEstimateSize(heightMode);
Expand Down Expand Up @@ -85,22 +85,30 @@
onToggleSortDirection={toggleSortDirection}
/>
<div ref={parentRef} class={styles.viewport}>
<Spacer height={virtualizer().spaceBefore} />
{/* Plain <For> 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. */}
<For each={virtualizer().items}>
{item => (
<ItemRow
item={item}
heightMode={heightMode()}
sortField={sortField() as 'created' | 'modified'}
permalinkID={permalinkID()}
/>
)}
</For>
<Spacer height={virtualizer().spaceAfter} />
{/* Content wrapper: its padding stands in for the unloaded rows
above and below (padding, not margin, so it always contributes
to the scrollable extent). */}
<div
style={{
'padding-top': `${virtualizer().spaceBefore}px`,
'padding-bottom': `${virtualizer().spaceAfter}px`,
}}
>
{/* Plain <For> 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. */}
<For each={virtualizer().items}>
{item => (
<ItemRow
item={item}
heightMode={heightMode()}
sortField={sortField() as 'created' | 'modified'}
permalinkID={permalinkID()}
/>
)}
</For>
</div>
</div>
</div>

Expand Down
4 changes: 0 additions & 4 deletions demo/solid/ItemRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ function rowPresentation(
}
}

export function Spacer(props: {height: number}) {
return <div class={styles.spacer} style={`height: ${props.height}px;`} />;
}

// 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.
Expand Down
Loading
Loading