You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Adds an imperative scrollToItem(id, options?) to the result of useZeroVirtualizer / createZeroVirtualizer, for bringing a specific row into view — a "jump to item" button, a search hit — without going through the declarative permalinkID option, which is edge-triggered, skips the scroll when the row is already visible, and is top-aligned only.
constvirtualizer=useZeroVirtualizer({/* … */});virtualizer.scrollToItem('item-123');// scrolls the least amount neededvirtualizer.scrollToItem('item-123',{align: 'center'});
id is the same identifier permalinkID takes (what getSingleQuery resolves), which need not equal getRowKey(row). align follows TanStack Virtual's scrollToIndex: 'auto' (the default), 'start', 'center', 'end'. There is no behavior: 'smooth' — the scroll is re-applied on every commit while the target's page streams in, which a smooth animation would fight.
An id that doesn't exist does nothing
Loading a target's page means re-anchoring paging on it, which throws the loaded window away. If the id resolves to nothing, that window never comes back — the list just empties. So over a list that is already on screen the row is looked up first, and paging only re-anchors once the row is known to exist.
That lookup rides the existing single-row query slot (a new probeID input selects it), which is idle whenever the anchor isn't a permalink. The two can't collide — the core never probes while a permalink anchor is live — and the handover is free: a probe that finds its row re-anchors on that same id, so the query doesn't change and nothing is unsubscribed in between.
permalinkID takes the same route, so a permalink to a mistyped or deleted id leaves the list alone too. On a cold load there is no list to protect, so the anchor goes straight to the permalink as before — and if that target turns out to be missing, the list now falls back to the top instead of sitting empty forever.
scrollState is an instruction, not an echo
The core persists scroll state on a debounce; the host stores it and hands it back. Handing it back is the problem: by the time a write completes that round trip the viewport has often moved on — a scrollToItem landing, a permalink resolving — and re-applying it undoes whatever moved it.
The Navigation API already draws the line. updateCurrentEntry changes the current entry's state without navigating and leaves the entry's id alone, while a load, reload, push/replace or traverse produces a new one. useHistoryScrollState / createHistoryScrollState now key on that id, so they only move when the browser actually navigated and never feed their own writes back. Verified against a real browser on all five cases, not just inferred from the spec.
The core keeps a bounded defence against echo for hosts that persist some other way, and the scrollState option now documents the contract: change it when the user navigated, not when onScrollStateChange fired.
The virtualizer compares paging anchors constantly — to recognise a state it wrote itself, to decide whether a persist is worth scheduling, to skip a no-op re-anchor — and it was doing that three different ways (reference equality, JSON, a stringified key). Those are now one path, which takes an optional comparator in the shape Zero's own comparators use, so an app can hand over the one it already sorts by. Only the zero is read.
Without it, start rows are compared with JSON.stringify and so have to be JSON-serializable. With it, a bigint from an int64 column survives the whole round trip on React. Solid is the exception and the README says so: its helper round-trips through JSON to turn Zero's store proxies into plain data, because structured clone refuses proxies.
Alignment matches the platform
scroll-padding on the container. A window-scrolled list usually has a sticky header over the top of the scrollport, so a top-aligned jump landed the row behind it and the list read as scrolled a row too far. Alignment now insets the scrollport by the container's scroll-padding-top/-bottom, which is what scrollIntoView reads. The window demo declares it in CSS, in sync with the header height.
scroll-margin on the target row. The per-row half of the same contract — "keep this much space around me" — is honoured too.
Tests
12 Playwright specs for the new behaviour (48 e2e in total): loaded and unloaded targets, every alignment, repeat jumps, superseding jumps, ids that don't exist, permalinks that don't exist, and the window-scrolled sticky-header case.
94 unit tests, most of the new ones in the core's DOM harness. Each behaviour fix has a test that fails without it — checked by reverting the fix, not by assuming.
The e2e seed's descriptions vary in length, so dynamic height mode actually produces different row heights.
Also fixes a pre-existing flaky spec: item-detail asserted on a "Loading…" frame that lasts one round trip to zero-cache, which fails every time the suite runs serially.
Manual-test checklist in HACKING.md, driven by a scrollToItem field in the dev panel.
Reviewer notes
Breaking changes to the ./core entry point, which is already marked experimental:
virtualizerResult() takes a 4th parameter.
RowsQueryInputs gains probeID; RowsSnapshot gains probeID, probeRow, probeComplete and permalinkID.
VirtualizerOptions gains compareStartRows; ScrollAlignment and ScrollToItemOptions are exported from all three entry points.
getHistoryNavigationSnapshot and readHistoryState are new; getHistoryStateSnapshot is unchanged but is no longer what the bundled helpers read.
Also worth a look:
The demo's useHash had two components sharing one module-level cache, where whichever listener ran first consumed the change and notified only itself. The echo above was masking it; closing the detail panel left it on screen once the echo was gone. It now notifies every subscriber and reads on currententrychange rather than mid-navigate, so permalinkID and scrollState reach the virtualizer in the same commit.
Known rough edge, pre-existing and not introduced here: a jump that lands while its window is still streaming in leaves the viewport at the window's edge, and paging then tops the window up from above — on a cold cache that can walk the window far enough to unload the row you jumped to. Documented in HACKING.md; worth watching when touching #evaluatePaging.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
It changes core virtualizer scroll/anchoring behavior and adds a new query-staging path (probeID), which warrants final human review despite strong test coverage.
Pull request overview
This PR adds an imperative scrollToItem(id, options?) API to Zero Virtual’s virtualizer results (React + Solid) to support “jump to row” interactions, including loading the target row’s page when needed. Along the way it strengthens permalink / missing-id behavior via a new probeID query slot, and improves scroll alignment by honoring CSS scroll-padding (notably for sticky headers in window-scrolled layouts).
Changes:
Add scrollToItem (with align: 'auto' | 'start' | 'center' | 'end') to VirtualizerResult, wiring it through React/Solid entry points.
Add probeID staging and core support so nonexistent ids don’t wipe the current loaded window; add fallback-to-top behavior for cold-load permalinks that resolve to nothing.
Update demos, docs, and add unit + Playwright coverage for jump behavior and alignment semantics.
File summaries
File
Description
src/solid/index.ts
Re-export new ScrollAlignment / ScrollToItemOptions types for Solid consumers.
src/solid/create-zero-virtualizer.ts
Pass core.scrollToItem through virtualizerResult.
src/solid/create-rows.ts
Add stage-4 probe query slot and expose probe results in snapshot assembly.
src/solid/create-rows.test.ts
Update staging/slot expectations to include the new probe slot.
src/react/use-zero-virtualizer.ts
Thread probeID into useRows and include scrollToItem in the memoized result.
src/react/use-zero-virtualizer.test.ts
Add a test asserting scrollToItem identity stability across content changes.
src/react/use-rows.ts
Add probeID support + probe query staging and include probe results in assembled snapshot.
src/react/index.ts
Re-export new ScrollAlignment / ScrollToItemOptions types for React consumers.
src/core/virtualizer.ts
Core implementation of scroll-to-row, probe handling, restore echo suppression, and scroll-padding-aware alignment.
src/core/virtualizer.dom.test.ts
Expand DOM harness and add unit coverage for scrollToItem, probe behavior, and restore echo handling.
src/core/types.ts
Define ScrollAlignment and ScrollToItemOptions types.
src/core/rows.ts
Add probeID to query inputs, stage-4 buildProbeQuery, and include probe fields in RowsSnapshot.
src/core/index.ts
Re-export new ScrollAlignment / ScrollToItemOptions types from core entry point.
README.md
Document scrollToItem behavior, semantics, and scroll-padding alignment expectations.
HACKING.md
Add manual testing checklist for scrollToItem behavior in the demo.
demo/shared/DevPanel.module.css
Add styles for new jump UI controls and refactor hover/disabled rules.
demo/react/WindowList.tsx
Keep scroll-padding-top synced with sticky header height so jumps land below it.
demo/react/JumpControls.tsx
New dev panel control to invoke scrollToItem by id + alignment.
demo/react/e2e/tests/scroll-to-item.spec.ts
New Playwright coverage for jump behavior, alignment variants, and missing-id behavior.
demo/react/e2e/tests/item-detail.spec.ts
Remove flaky assertion on transient “Loading…” frame; assert stable end state instead.
demo/react/e2e/seed-test.ts
Vary item description lengths to exercise dynamic row measurement more realistically.
demo/react/DevPanel.tsx
Wire new JumpControls into the demo panel and plumb scrollToItem through props.
demo/react/App.tsx
Pass scrollToItem from the virtualizer into the DevPanel.
Review details
Suppressed comments (1)
demo/react/DevPanel.tsx:37
The scrollToItem prop type is duplicated as {align?: ScrollAlignment}. Using the exported ScrollToItemOptions keeps demo typings aligned with the public API if options expand.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
There are two confirmed core edge-case bugs (empty-id probe stalling and unsafe JSON.stringify in scroll-state equality) that should be fixed before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/core/virtualizer.ts:326
sameScrollState uses JSON.stringify on anchor and listContextParams without a try/catch. If either contains a cycle or a bigint, JSON.stringify will throw and crash restores/persist handling during #isOwnScrollState. Consider making the comparison fail-safe (return false) on stringify errors, similar to the defensive stringify used in #warnOnListContextIdentityChurn.
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
There are confirmed correctness issues in the probe-to-scroll path for id !== getRowKey(row) and likely-build-breaking CSS nesting syntax in .module.css without an evident nesting transform.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
Previously missed (2) — in code that hasn't changed since the last review.
src/core/virtualizer.ts:808
In #resolveProbe, the “row rendered while probing” check only looks for a DOM row keyed by the id (findRow(el, probe.id)). If an app deep-links by an id that differs from getRowKey(row), the row can already be rendered (keyed by getRowKey) but this check will miss it and unnecessarily re-anchor (clearing the list briefly). Use the probed row’s getRowKey as an additional lookup key before deciding to re-anchor. src/solid/create-rows.test.ts:71
This test comment says the probe slot “can be the slot carrying the lookup that 2 and 3 depend on”, but the main/after page queries depend on the permalink single-row lookup (stage 1), not the probe (stage 4). This makes the staging explanation misleading.
demo/shared/DevPanel.module.css:105
This file now uses nested selectors (&:last-child) inside a .module.css file, which is likely unsupported without a nesting transform. Use a plain .section:last-child selector instead.
&:last-child {
border-bottom: none;
}
demo/shared/DevPanel.module.css:168
This file now uses nested selectors (&:hover, &:disabled) inside a .module.css file, which may not be supported by the current CSS toolchain. Rewrite these as plain .actionButton:hover / .actionButton:disabled selectors to avoid build/parser issues.
&:hover {
background: #33333a;
}
&:disabled {
demo/shared/DevPanel.module.css:223
This file now uses nested &::placeholder inside a .module.css file. If CSS nesting isn’t enabled, this won’t parse. Prefer a plain .input::placeholder selector.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
#jumpEchoUntil may not be refreshed on the landing commit when #pendingScroll is cleared inside #retryPendingScroll, allowing a delayed echoed scrollState to be treated as a restore and undo a long-running jump.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/core/virtualizer.ts:1802
When the scroll request lands because the target is already aligned (|delta| <= 1), #pendingScroll is cleared before the #afterDOMUpdate echo-window refresh runs. If the jump took > JUMP_ECHO_WINDOW_MS to load (common on cold cache) and lands in this commit, #jumpEchoUntil may have already expired and a delayed echo of the pre-jump scrollState can be treated as a restore and undo the jump. Refresh the echo window when the request is retired here so a landing commit always extends the window.
This issue also appears on line 1806 of the same file.
src/core/virtualizer.ts:1808
Similarly, when #rows.complete is true the request is cleared immediately after applying the scroll. That can clear #pendingScroll in the same commit the target first appears, so the #afterDOMUpdate echo-window refresh may not run and a delayed echo of a pre-jump scroll state can be applied as a restore. Update #jumpEchoUntil when retiring the request here as well.
if (this.#rows.complete) {
this.#pendingScroll = null;
}
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
#resolveProbe clears the probe without bumping the observable version in some paths, which can prevent wrappers from unsubscribing the probe query and can leave a stale probe active.
Get a fresh assessment by requesting another Copilot review.
Clearing `#probe` directly left `#version` untouched, so `#withNotify`
had nothing to report: the wrapper never re-rendered, `probeID` stayed
in the query inputs, and the single-row lookup stayed subscribed to an
id nobody was waiting on. The not-found path is the one that bites —
after a jump to a missing id, no notify fires at all — but the same
holds wherever the probe is dropped without a re-anchor behind it.
Reported by Copilot on #79.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
getHistoryNavigationSnapshot currently uses JSON.stringify directly, which can throw for structured-cloneable Navigation state (notably bigint), breaking navigation-based scroll-state restore in supported scenarios.
Get a fresh assessment by requesting another Copilot review.
The Navigation API structured-clones, so an int64 column read as a
bigint stores and restores through history state perfectly well — and
then a JSON comparison in the middle threw over it, on the next
navigation, taking the store down rather than losing one field.
virtualizer.ts already had a helper for this; it now lives in
value-key.ts and spells bigints out instead of falling back over them,
which also makes the echo check there precise for the apps it was
hardened for rather than degraded. Both history-state snapshots and the
two scroll-state memos (React and Solid) go through it.
Reported by Copilot on #79, which named getHistoryNavigationSnapshot;
the same JSON.stringify was in getHistoryStateSnapshot and in both
bindings' memo keys.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
getHistoryNavigationSnapshot still uses raw JSON.stringify for caching, which can throw on valid history.state values (e.g. bigint/cycles) and break navigation-driven scroll-state restore.
Get a fresh assessment by requesting another Copilot review.
getHistoryNavigationSnapshot compared the whole `history.state` to
preserve object identity across a navigation — but `history.state` is
shared, and most of it belongs to whoever else writes there. Comparing
it meant a sibling key the app never handed us, holding something
structured clone takes and JSON doesn't, threw out of the store and took
the list with it.
The entry id already answers the only question that function asks. The
state itself is now never inspected, and both bindings' memos already
compare their own key, so nothing downstream changes. The setters read
live rather than through the cached snapshot, which is what a
read-modify-write wanted anyway.
Reported by Copilot on #79. Its other half — coalescing undefined and
catching the throw — is deliberately not done: that is the tolerance
removed in 86391be, and our own scroll state is still required to be
JSON-serializable. The fix is to stringify less, not to forgive more.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
useHistoryScrollState still memoizes via JSON.stringify(state[key]), which is unnecessary with the new navigation snapshot semantics and can throw for non-JSON (but structured-cloneable) scroll state.
Get a fresh assessment by requesting another Copilot review.
Avoid promising immediate scrolling for non-key row IDs
README.md:335
The README says an “already loaded” row is “scrolled to immediately”, but when id !== getRowKey(row) the virtualizer may need to resolve the row key via the single-row lookup before it can locate the rendered element. Consider rephrasing to avoid promising immediate scrolling, while still noting it won’t re-anchor/prefetch when the row is already in the rendered window.
Clarify immediate scrolling behavior when row ID differs from key
src/core/virtualizer.ts:672
The scrollToItem doc comment says “A row that is already rendered is scrolled to immediately”, but #startOrScroll only treats the row as immediately scrollable when it can be found in the DOM by id (via findRow(el, id)). When id !== getRowKey(row), even an already-rendered row typically requires the lookup/probe to resolve the DOM key first. Tightening this wording avoids overstating the “immediate” behavior in the id≠key case.
Both memos keyed on JSON.stringify of the scroll state, which is the
last thing standing between compareStartRows and actually working: the
core will happily compare a bigint start row with the app's comparator,
and then the binding stringifies it on the way through and throws.
The snapshot's identity is already the signal — it only moves when the
browser navigated, and holds still for every write in between — so
there is nothing to gain by looking inside. A navigation between two
entries holding the same position now restores instead of
short-circuiting, which is the right way round: the list may have
scrolled away from what it last persisted, and then the restore is
exactly what was wanted.
Solid still round-trips through JSON on write, because Zero's store
proxies are not structured-cloneable and that is what turns them back
into plain data. So bigint start rows work end to end on React and not
on Solid, which the README now says instead of claiming both ways at
once in two adjacent sections.
Reported by Copilot on #79 (both halves of the same thing).
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
It introduces substantial new scroll/anchoring/state-restore logic in the core (plus a noted same-tick race), which warrants final human review despite strong test coverage.
The helpers hold one reference per history entry and hand back that
same one until the browser navigates — they stopped comparing content
two commits ago. The requirement on a custom persistence layer is
steadiness, not serialization, and saying otherwise implied a JSON
constraint that is no longer there.
Reported by Copilot on #79. It is the opening paragraph of the block
reattached in edcbd6e, which I moved without re-reading.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
It substantially changes core scrolling/anchoring/restore behavior and adds new query-staging pathways, which warrants final human verification despite strong test coverage.
In a lazy loaded list we do not use indexes since we do not know how many items there are. We can use indexes for the current viewport plus the overscan but outside that it is not reliable. Also indexes changes as the set of items change.
In practice for keyboard navigation what is usually supported is
go to next item. This is always available due to overscan
go to previous item. Same.
next page (page down). This would only work if overscan includes enough items to draw the next page.
previous page. Same
start (home). Anchor at zero
end. Anchor at infinity.
For zbugs we only need to support scroll into view for things in the physical dom so this is really overkill.
I'm still tempted to land this since it has extensive testing and is generally useful.
But before I do that I'll let Claude refactor zbugs selection to use this to make sure it covers its use cases.
Adds an imperative `scrollToItem(id, options?)` to the result of
`useZeroVirtualizer` / `createZeroVirtualizer`, for bringing a row into
view without going through `permalinkID` — which is edge-triggered,
skips the scroll when the row is already visible, and is top-aligned
only. `align` follows TanStack Virtual's `scrollToIndex`: `auto` (the
default), `start`, `center`, `end`.
An id that resolves to nothing does nothing at all. Loading a target's
page means re-anchoring on it, which throws the loaded window away, so
over a list that is already on screen the row is looked up first and
paging only re-anchors once it is known to exist. That lookup rides the
single-row query slot, idle whenever the anchor isn't a permalink.
`permalinkID` takes the same route, and a cold deep link to a missing id
now falls back to the top of the list instead of sitting empty.
Alignment reads both halves of the platform's scroll-into-view contract:
the container's `scroll-padding` (how a sticky header is declared) and
the target row's own `scroll-margin`.
`scrollState` is an instruction, not an echo. The core persists on a
debounce and the host hands the state back, and by the time a write
completes that round trip the viewport has often moved on — so
re-applying it undid whatever moved it. The history helpers now key on
the Navigation API's entry id, which `updateCurrentEntry` leaves alone
and every real navigation changes, so they never feed their own writes
back. The core keeps a bounded defence for hosts that persist some other
way, and the option documents the contract.
`compareStartRows` makes anchor comparison pluggable, in the shape
Zero's own comparators use, so an app can hand over the one it already
sorts by rather than meeting a serializability requirement it never
asked for. It replaces three different comparison styles with one path.
Also fixes, in the demo, a `useHash` store where two components shared
one module-level cache and whichever listener ran first consumed the
change and notified only itself — masked until now by the echo above.
94 unit tests and 48 Playwright specs; each behaviour fix has a test
that fails without it. Manual-test checklist in HACKING.md.
Breaking changes to the experimental `./core` entry point:
`virtualizerResult()` takes a 4th parameter, `RowsQueryInputs` gains
`probeID`, `RowsSnapshot` gains `probeID`/`probeRow`/`probeComplete`/
`permalinkID`, `VirtualizerOptions` gains `compareStartRows`, and
`getHistoryNavigationSnapshot`/`readHistoryState` are new.
The last place this file assumed a fixed row height: with nothing
focused, j/k picked a starting row by dividing scrollTop by ITEM_SIZE.
The virtualizer now reports which loaded row is actually in view, so
ask it. ITEM_SIZE is back to meaning one thing — the size estimate.
Uses firstVisibleItem from rocicorp/zero-virtual#79, added for this.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
The PR introduces broad, behavior-sensitive changes across the core scrolling/persistence logic and multiple bindings/demos, which warrants final human validation despite strong test coverage.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an imperative
scrollToItem(id, options?)to the result ofuseZeroVirtualizer/createZeroVirtualizer, for bringing a specific row into view — a "jump to item" button, a search hit — without going through the declarativepermalinkIDoption, which is edge-triggered, skips the scroll when the row is already visible, and is top-aligned only.idis the same identifierpermalinkIDtakes (whatgetSingleQueryresolves), which need not equalgetRowKey(row).alignfollows TanStack Virtual'sscrollToIndex:'auto'(the default),'start','center','end'. There is nobehavior: 'smooth'— the scroll is re-applied on every commit while the target's page streams in, which a smooth animation would fight.An id that doesn't exist does nothing
Loading a target's page means re-anchoring paging on it, which throws the loaded window away. If the id resolves to nothing, that window never comes back — the list just empties. So over a list that is already on screen the row is looked up first, and paging only re-anchors once the row is known to exist.
That lookup rides the existing single-row query slot (a new
probeIDinput selects it), which is idle whenever the anchor isn't a permalink. The two can't collide — the core never probes while a permalink anchor is live — and the handover is free: a probe that finds its row re-anchors on that same id, so the query doesn't change and nothing is unsubscribed in between.permalinkIDtakes the same route, so a permalink to a mistyped or deleted id leaves the list alone too. On a cold load there is no list to protect, so the anchor goes straight to the permalink as before — and if that target turns out to be missing, the list now falls back to the top instead of sitting empty forever.scrollStateis an instruction, not an echoThe core persists scroll state on a debounce; the host stores it and hands it back. Handing it back is the problem: by the time a write completes that round trip the viewport has often moved on — a
scrollToItemlanding, a permalink resolving — and re-applying it undoes whatever moved it.The Navigation API already draws the line.
updateCurrentEntrychanges the current entry's state without navigating and leaves the entry'sidalone, while a load, reload, push/replace or traverse produces a new one.useHistoryScrollState/createHistoryScrollStatenow key on that id, so they only move when the browser actually navigated and never feed their own writes back. Verified against a real browser on all five cases, not just inferred from the spec.The core keeps a bounded defence against echo for hosts that persist some other way, and the
scrollStateoption now documents the contract: change it when the user navigated, not whenonScrollStateChangefired.compareStartRowsThe virtualizer compares paging anchors constantly — to recognise a state it wrote itself, to decide whether a persist is worth scheduling, to skip a no-op re-anchor — and it was doing that three different ways (reference equality, JSON, a stringified key). Those are now one path, which takes an optional comparator in the shape Zero's own comparators use, so an app can hand over the one it already sorts by. Only the zero is read.
Without it, start rows are compared with
JSON.stringifyand so have to be JSON-serializable. With it, abigintfrom an int64 column survives the whole round trip on React. Solid is the exception and the README says so: its helper round-trips through JSON to turn Zero's store proxies into plain data, because structured clone refuses proxies.Alignment matches the platform
scroll-paddingon the container. A window-scrolled list usually has a sticky header over the top of the scrollport, so a top-aligned jump landed the row behind it and the list read as scrolled a row too far. Alignment now insets the scrollport by the container'sscroll-padding-top/-bottom, which is whatscrollIntoViewreads. The window demo declares it in CSS, in sync with the header height.scroll-marginon the target row. The per-row half of the same contract — "keep this much space around me" — is honoured too.Tests
dynamicheight mode actually produces different row heights.item-detailasserted on a "Loading…" frame that lasts one round trip to zero-cache, which fails every time the suite runs serially.HACKING.md, driven by ascrollToItemfield in the dev panel.Reviewer notes
Breaking changes to the
./coreentry point, which is already marked experimental:virtualizerResult()takes a 4th parameter.RowsQueryInputsgainsprobeID;RowsSnapshotgainsprobeID,probeRow,probeCompleteandpermalinkID.VirtualizerOptionsgainscompareStartRows;ScrollAlignmentandScrollToItemOptionsare exported from all three entry points.getHistoryNavigationSnapshotandreadHistoryStateare new;getHistoryStateSnapshotis unchanged but is no longer what the bundled helpers read.Also worth a look:
useHashhad two components sharing one module-level cache, where whichever listener ran first consumed the change and notified only itself. The echo above was masking it; closing the detail panel left it on screen once the echo was gone. It now notifies every subscriber and reads oncurrententrychangerather than mid-navigate, sopermalinkIDandscrollStatereach the virtualizer in the same commit.HACKING.md; worth watching when touching#evaluatePaging.