@doync/query-virtualizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vitor Buzinaro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # `@doync/query-virtualizer`
2
+
3
+ React binding for virtualized infinite lists over doync Subscriptions. Stage factories return a Bound query; the hook keeps three live `useQuery` slots (main page, after page, single-row) filled as you scroll, and renders only the visible window. Built on `@rocicorp/zero-virtual` **`/core` only** — consumers do not declare or import zero-virtual themselves. This package's root export is the stable surface; deeper experimental core symbols stay on `@rocicorp/zero-virtual/core` and may move with that pin.
4
+
5
+ Use it under a `@doync/react` `<DoyncProvider>` with registered queries that support keyset (or equivalent) pagination.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @doync/query-virtualizer
11
+ ```
12
+
13
+ | Package | Role |
14
+ | --- | --- |
15
+ | `@doync/query-virtualizer` | This binding |
16
+ | `@doync/react`, `@doync/core` | Direct dependencies (pulled in transitively — import `DoyncProvider` / hooks from `@doync/react` as usual) |
17
+ | `react` | **Peer** — `>=18` |
18
+
19
+ `@rocicorp/zero-virtual` is a direct dependency of this package pinned for you; do not add it to the app unless you intentionally opt into `/core` experimental APIs.
20
+
21
+ ## Quick sketch
22
+
23
+ An overflow-element list. Factories that do not close over props can live at module scope so their identity stays stable across renders (a fresh `estimateSize` / `getRowKey` / factory each render resets paging).
24
+
25
+ ```tsx
26
+ import {
27
+ rowAttributes,
28
+ useQueryVirtualizer,
29
+ type VirtualRow,
30
+ } from '@doync/query-virtualizer'
31
+ import { queries } from './shared/data'
32
+ import { useCallback, useRef } from 'react'
33
+
34
+ type Task = { id: string; title: string; modified: number }
35
+ type TaskStart = { id: string; modified: number }
36
+
37
+ const estimateSize = () => 48
38
+ const getRowKey = (row: Task) => row.id
39
+ const toStartRow = (row: Task): TaskStart => ({
40
+ id: row.id,
41
+ modified: row.modified,
42
+ })
43
+ const getSingleQuery = ({ id }: { id: string; settled: boolean }) => ({
44
+ query: queries.taskById(id),
45
+ })
46
+
47
+ export function TaskList({ ownerId }: { ownerId: string }) {
48
+ const listRef = useRef<HTMLDivElement>(null)
49
+ const getScrollElement = useCallback(() => listRef.current, [])
50
+
51
+ const getPageQuery = useCallback(
52
+ ({
53
+ limit,
54
+ start,
55
+ dir,
56
+ }: {
57
+ limit: number
58
+ start: TaskStart | null
59
+ dir: 'forward' | 'backward'
60
+ settled: boolean
61
+ }) => ({
62
+ // Bound query — registry leaf + args. Optional `options` (ttl, skip, …)
63
+ // is the per-stage UseQueryOptions bag.
64
+ query: queries.taskPage({ ownerId, limit, start, dir }),
65
+ }),
66
+ [ownerId],
67
+ )
68
+
69
+ const { items, spaceBefore, spaceAfter, status } = useQueryVirtualizer<
70
+ string,
71
+ Task,
72
+ TaskStart
73
+ >({
74
+ listContextParams: ownerId, // any stable context; change resets the list
75
+ getScrollElement,
76
+ estimateSize,
77
+ getRowKey,
78
+ getPageQuery,
79
+ getSingleQuery,
80
+ toStartRow,
81
+ })
82
+
83
+ return (
84
+ <div
85
+ ref={listRef}
86
+ style={{ height: 480, overflow: 'auto', position: 'relative' }}
87
+ >
88
+ {/* Spacers are sibling elements — not padding on the scroller —
89
+ so native scroll anchoring can compensate. */}
90
+ <div style={{ height: spaceBefore }} />
91
+ {items.map((item) => (
92
+ <TaskRow key={item.key} item={item} />
93
+ ))}
94
+ <div style={{ height: spaceAfter }} />
95
+ {status.status !== 'complete' ? <div>Loading…</div> : null}
96
+ </div>
97
+ )
98
+ }
99
+
100
+ function TaskRow({ item }: { item: VirtualRow<Task> }) {
101
+ const { index, key, row } = item
102
+ if (row === undefined) {
103
+ return (
104
+ <div {...rowAttributes(index, key)} style={{ height: 48 }}>
105
+
106
+ </div>
107
+ )
108
+ }
109
+ return (
110
+ <div {...rowAttributes(index, key)} style={{ height: 48 }}>
111
+ {row.title}
112
+ </div>
113
+ )
114
+ }
115
+ ```
116
+
117
+ Window-scrolled lists use `useQueryWindowVirtualizer` with the same options: `getScrollElement` then returns the element rows render into (normal page flow); the window is the scroll container.
118
+
119
+ ## Public API
120
+
121
+ ### Hooks
122
+
123
+ #### `useQueryVirtualizer(options) → QueryVirtualizerResult`
124
+
125
+ Virtualized, infinitely-paginated list that scrolls inside an overflow element. `getScrollElement` returns that element (also where rows render).
126
+
127
+ #### `useQueryWindowVirtualizer(options) → QueryVirtualizerResult`
128
+
129
+ Same contract, window as the scroll container.
130
+
131
+ Both take `UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>`:
132
+
133
+ | Option | Required | Notes |
134
+ | --- | --- | --- |
135
+ | `listContextParams` | yes | Opaque list context. A value change (by the core's comparison) resets paging / window. |
136
+ | `getScrollElement` | yes | `() => HTMLElement \| null` — overflow scroller, or the rows host for the window variant. |
137
+ | `estimateSize` | yes | `(index) => number` — estimated row height in px. Keep referentially stable. |
138
+ | `getRowKey` | yes | `(row) => RowKey` — stable id per data row. |
139
+ | `getPageQuery` | yes | `GetPageQuery` — see [Stage factories](#stage-factories). |
140
+ | `getSingleQuery` | yes | `GetSingleQuery` — one-row Bound query for permalink / anchor rows. |
141
+ | `toStartRow` | yes | `(row) => TStartRow` — maps a data row to the keyset cursor `getPageQuery` receives as `start`. |
142
+ | `overscan` | no | Extra rows rendered past the viewport (default from zero-virtual core). |
143
+ | `count` | no | Known total row count when the app already has one; otherwise the composition estimates. |
144
+ | `anchoring` | no | `AnchoringMode` — e.g. `'native'` for comment-style natural-height lists. |
145
+ | `minPageSize` | no | Floor on rows requested per page (default 50; lower for tall cards). |
146
+ | `settleTime` | no | Idle ms before the list is considered settled (default 2000). |
147
+ | `onSettled` | no | Fired when the list has been idle for `settleTime`. |
148
+ | `permalinkID` | no | Row id to scroll into view once present. |
149
+ | `scrollState` / `onScrollStateChange` | no | Persist / restore window (`ScrollHistoryState`). Pair with `useRestoreScrollState` or your router. |
150
+ | `observeElementRect` / `observeElementOffset` | no | Override default observers (element vs window defaults are filled in per entry). |
151
+
152
+ `QueryVirtualizerResult<TRow>` is the zero-virtual binding result without the upstream `complete` boolean, plus aggregated doync `status`:
153
+
154
+ | Field | Notes |
155
+ | --- | --- |
156
+ | `items` | `VirtualRow<TRow>[]` currently on screen (and overscan). `row` may be `undefined` while a slot is still loading. |
157
+ | `spaceBefore` / `spaceAfter` | Spacer heights in px — render as sibling elements, not scroller padding. |
158
+ | `status` | Aggregated `ViewStatus` from live stages (`unknown` → `complete` / `error`). Replaces upstream `complete`. |
159
+ | `rowsEmpty` | True when the composition has no data rows. |
160
+ | `permalinkNotFound` | True when a `permalinkID` could not be resolved. |
161
+ | `estimatedTotal` / `total` | Size hints from the composition. |
162
+ | `settled` | True after the list has been idle for `settleTime`. |
163
+ | `scrollElement` | Live `HTMLElement \| null` — the resolved scroller (`null` until mounted). |
164
+ | `options` | Bound scroll/observer accessors (used by `usePinToBottom`). |
165
+ | `rowAt` | `(index) => TRow \| undefined` — data row at a virtual index. |
166
+
167
+ #### `usePinToBottom(virtualizer, options?)`
168
+
169
+ Chat/log follow: when content grows, keep the viewport pinned to the bottom — only if the user was already parked there. Pass the result of `useQueryVirtualizer` / `useQueryWindowVirtualizer`.
170
+
171
+ ```ts
172
+ import {
173
+ usePinToBottom,
174
+ useQueryWindowVirtualizer,
175
+ } from '@doync/query-virtualizer'
176
+
177
+ const result = useQueryWindowVirtualizer({/* … */})
178
+ usePinToBottom(result)
179
+ // usePinToBottom(result, { enabled: true, slack: 8 })
180
+ ```
181
+
182
+ `PinToBottomOptions`: `{ enabled?: boolean; slack?: number }`.
183
+
184
+ #### `useRestoreScrollState(key?) → [scrollState, setScrollState]`
185
+
186
+ Persist virtualizer scroll under a key in `window.history.state` (Navigation API). Pass the tuple to `scrollState` / `onScrollStateChange`. Default key is `"scrollState"`. Requires the Navigation API (Firefox 147+); older browsers should wire a different persist layer (zbugs uses wouter glue).
187
+
188
+ ```ts
189
+ const [scrollState, setScrollState] = useRestoreScrollState<TaskStart>('tasks')
190
+
191
+ useQueryVirtualizer({
192
+ /* … */
193
+ scrollState,
194
+ onScrollStateChange: setScrollState,
195
+ })
196
+ ```
197
+
198
+ ### Stage factories
199
+
200
+ Each stage returns a `QueryResult` — a Bound query plus optional per-stage `useQuery` options (`ttl`, consumer `skip`, …). Args ride inside the bound value. A null/absent stage from the core is expressed as a falsy query into `useQuery`, not a placeholder swap.
201
+
202
+ ```ts
203
+ import type {
204
+ GetPageQuery,
205
+ GetSingleQuery,
206
+ QueryResult,
207
+ } from '@doync/query-virtualizer'
208
+
209
+ // GetPageQueryOptions: { limit, start, dir, settled }
210
+ const getPageQuery: GetPageQuery<Task, TaskStart> = ({
211
+ limit,
212
+ start,
213
+ dir,
214
+ }) => ({
215
+ query: queries.taskPage({ limit, start, dir }),
216
+ // options: { ttl: 'none' },
217
+ })
218
+
219
+ // GetSingleQueryOptions: { id, settled }
220
+ const getSingleQuery: GetSingleQuery<Task> = ({ id }) => ({
221
+ query: queries.taskById(id),
222
+ })
223
+ ```
224
+
225
+ | Type | Shape |
226
+ | --- | --- |
227
+ | `QueryResult<Row, One>` | `{ query: BoundQuery<Row, One>; options?: UseQueryOptions }` |
228
+ | `GetPageQuery<TRow, TStartRow>` | `(GetPageQueryOptions<TStartRow>) => QueryResult<TRow, false>` |
229
+ | `GetSingleQuery<TRow>` | `(GetSingleQueryOptions) => QueryResult<TRow, true>` |
230
+ | `GetPageQueryOptions` / `GetSingleQueryOptions` | Re-exported from `@rocicorp/zero-virtual/core` |
231
+
232
+ ### Helpers and shared types
233
+
234
+ | Symbol | Role |
235
+ | --- | --- |
236
+ | `rowAttributes(index, key)` | Spread onto each virtual row element so measurement / anchoring can find it. |
237
+ | `VirtualRow<TRow>` | `{ index, key, row?: TRow, … }` — one entry in `items`. |
238
+ | `RowKey` | Stable row identity type (`string \| number`). |
239
+ | `AnchoringMode` | Scroll-anchoring strategy. |
240
+ | `ScrollHistoryState<TStartRow>` | Persisted scroll blob for restore. |
241
+ | `ViewStatus` | Re-export from `@doync/react` — aggregated stage status on the result. |
242
+
243
+ ## Public surface
244
+
245
+ The main entry (`.`) is governed by semver. Documented public symbols:
246
+
247
+ - **Runtime**: `useQueryVirtualizer`, `useQueryWindowVirtualizer`, `usePinToBottom`, `useRestoreScrollState`, `rowAttributes`
248
+ - **Types**: `UseQueryVirtualizerOptions`, `QueryVirtualizerResult`, `QueryResult`, `GetPageQuery`, `GetSingleQuery`, `GetPageQueryOptions`, `GetSingleQueryOptions`, `PinToBottomOptions`, `VirtualRow`, `RowKey`, `AnchoringMode`, `ScrollHistoryState`, `ViewStatus`
249
+
250
+ ## Internal (`@doync/query-virtualizer/internal`)
251
+
252
+ Anything imported from `@doync/query-virtualizer/internal` is **not** part of the semver surface. It may change or disappear in any release, including patches, without notice. Sibling `@doync/*` packages reach internals through that specifier when they must; application code should not. Import the virtualizer hooks from `@doync/query-virtualizer`.
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("@rocicorp/zero-virtual/core"),t=require("react"),n=require("@doync/react");const r=Object.freeze({status:`unknown`});function i(e,t){for(let t of e)if(t.status===`error`)return t;return t?{status:`complete`}:r}function a(e){return e!==null&&e.options?.skip!==!0}function o({pageSize:r,anchor:o,settled:s,getPageQuery:c,getSingleQuery:l,toStartRow:u},d=n.useQuery){let f={pageSize:r,anchor:o,settled:s},p=(0,e.buildSingleQuery)(f,l),[m,h]=d(p?.query??!1,p?.options),g=m??void 0,_=h.status===`complete`,v=(0,e.permalinkMissing)(f,g,_),y=g?u(g):null,b=a(p),x=(0,e.buildMainQuery)(f,c,y,v),[S,C]=d(x?.query??!1,x?.options),w=C.status===`complete`,T=a(x),E=(0,e.buildAfterQuery)(f,c,y,v),[D,O]=d(E?.query??!1,E?.options),k=O.status===`complete`,A=a(E);return(0,t.useMemo)(()=>{let t=(0,e.assembleRows)({pageSize:r,anchor:o,settled:s},{singleRow:g,singleComplete:_,mainRows:S,mainComplete:w,afterRows:D,afterComplete:k}),n=[];return b&&n.push(h),T&&n.push(C),A&&n.push(O),{rows:t,status:i(n,t.complete)}},[r,o,s,g,_,h,b,S,w,C,T,D,k,O,A])}function s(n,r){let[,i]=(0,t.useReducer)(()=>({}),{}),[a]=(0,t.useState)(()=>new e.ZeroVirtualizer(n,r));a.setOptions(n);let{pageSize:s,anchor:c,settled:l}=a.getQueryInputs(),{rows:u,status:d}=o({pageSize:s,anchor:c,settled:l,getPageQuery:n.getPageQuery,getSingleQuery:n.getSingleQuery,toStartRow:n.toStartRow});a.setRows(u),(0,t.useLayoutEffect)(()=>{let e=a.subscribe(i);return()=>{e(),a.detach()}},[a]),(0,t.useLayoutEffect)(()=>{a.attach(n.getScrollElement()),a.afterDOMUpdate()});let{getScrollElement:f,observeElementRect:p,observeElementOffset:m}=n,h=(0,t.useMemo)(()=>({getScrollElement:f,observeElementRect:p,observeElementOffset:m}),[f,p,m]),g=a.getSnapshot(),_=d;return(0,t.useMemo)(()=>{let{complete:t,...n}=(0,e.virtualizerResult)(g,h,r);return{...n,status:_}},[g,h,r,_])}function c(t){return s({...t,observeElementRect:t.observeElementRect??e.observeElementRect,observeElementOffset:t.observeElementOffset??e.observeElementOffset},e.resolveElementScrollElement)}function l(t){return s({...t,observeElementRect:t.observeElementRect??e.observeWindowRect,observeElementOffset:t.observeElementOffset??e.observeWindowOffset},e.resolveWindowScrollElement)}function u(n,{enabled:r=!0,slack:i=e.DEFAULT_STICK_SLACK}={}){let a=(0,t.useRef)(null);(0,t.useLayoutEffect)(()=>{if(!r){a.current?.detach();return}(a.current??=(0,e.createStickToBottomCache)()).ensure(n,i)}),(0,t.useLayoutEffect)(()=>()=>{a.current?.detach(),a.current=null},[])}function d(){return[(0,t.useSyncExternalStore)(e.subscribeHistoryState,e.getHistoryStateSnapshot,e.getHistoryStateServerSnapshot),e.updateHistoryState]}function f(n=`scrollState`){let[r,i]=d();return[(0,t.useMemo)(()=>r?r[n]??null:null,[r&&JSON.stringify(r[n]),n]),(0,t.useCallback)(t=>{let r=(0,e.getHistoryStateSnapshot)();i({...r,[n]:t})},[i,n])]}Object.defineProperty(exports,"rowAttributes",{enumerable:!0,get:function(){return e.rowAttributes}}),exports.usePinToBottom=u,exports.useQueryVirtualizer=c,exports.useQueryWindowVirtualizer=l,exports.useRestoreScrollState=f;
@@ -0,0 +1,92 @@
1
+ import { UseQueryOptions, ViewStatus, ViewStatus as ViewStatus$1 } from "@doync/react";
2
+ import { BoundQuery } from "@doync/core";
3
+ import { AnchoringMode, GetPageQueryOptions, GetSingleQueryOptions, RowKey, ScrollHistoryState, ScrollHistoryState as ScrollHistoryState$1, StickOptions, VirtualRow, VirtualizerBindingOptions, VirtualizerResult, rowAttributes } from "@rocicorp/zero-virtual/core";
4
+
5
+ //#region src/types.d.ts
6
+ /**
7
+ * What a page/single factory returns: a bound query plus optional `useQuery`
8
+ * options (`ttl`, `skip`). Args live on the bound value.
9
+ */
10
+ type QueryResult<Row extends Record<string, unknown> = Record<string, unknown>, One extends boolean = boolean> = {
11
+ readonly query: BoundQuery<Row, One>;
12
+ readonly options?: UseQueryOptions | undefined;
13
+ };
14
+ /**
15
+ * Page-query factory: given `{ limit, start, dir, settled }`, return a
16
+ * multi-row bound query.
17
+ */
18
+ type GetPageQuery<TRow extends Record<string, unknown>, TStartRow> = (options: GetPageQueryOptions<TStartRow>) => QueryResult<TRow, false>;
19
+ /**
20
+ * Single-row factory: given `{ id, settled }`, return a one-row bound query (``
21
+ * sql.one`…` `` / `findFirst`).
22
+ */
23
+ type GetSingleQuery<TRow extends Record<string, unknown>> = (options: GetSingleQueryOptions) => QueryResult<TRow, true>;
24
+ /**
25
+ * Options for {@link useQueryVirtualizer} / {@link useQueryWindowVirtualizer}.
26
+ * Provide `getPageQuery` and `getSingleQuery` factories that return bound
27
+ * queries.
28
+ */
29
+ type UseQueryVirtualizerOptions<TListContextParams, TRow extends Record<string, unknown>, TStartRow> = VirtualizerBindingOptions<TListContextParams, TRow, TStartRow, BoundQuery<TRow, false>, UseQueryOptions, BoundQuery<TRow, true>, UseQueryOptions> & {
30
+ getPageQuery: GetPageQuery<TRow, TStartRow>;
31
+ getSingleQuery: GetSingleQuery<TRow>;
32
+ };
33
+ /**
34
+ * Virtualizer result: virtual items, scroll helpers, and aggregated
35
+ * {@link ViewStatus} from the live page stages (replaces core's `complete`
36
+ * boolean).
37
+ */
38
+ type QueryVirtualizerResult<TRow> = Omit<VirtualizerResult<TRow>, "complete"> & {
39
+ readonly status: ViewStatus$1;
40
+ };
41
+ //#endregion
42
+ //#region src/use-query-virtualizer.d.ts
43
+ /**
44
+ * Virtualized infinite list inside an overflow element. `getScrollElement`
45
+ * returns that element (also where rows render). Provide page/single query
46
+ * factories via {@link UseQueryVirtualizerOptions}.
47
+ *
48
+ * ```ts
49
+ * const v = useQueryVirtualizer({
50
+ * getScrollElement: () => listRef.current,
51
+ * estimateSize: () => 48,
52
+ * getPageQuery: ({ limit, start, dir }) => ({
53
+ * query: queries.items.page({ limit, start, dir }),
54
+ * }),
55
+ * getSingleQuery: ({ id }) => ({
56
+ * query: queries.items.byId({ id }),
57
+ * }),
58
+ * // …
59
+ * })
60
+ * ```
61
+ */
62
+ declare function useQueryVirtualizer<TListContextParams, TRow extends Record<string, unknown>, TStartRow>(options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>): QueryVirtualizerResult<TRow>;
63
+ /**
64
+ * Like {@link useQueryVirtualizer}, but the window is the scroll container.
65
+ * `getScrollElement` returns the element rows render into (normal page flow).
66
+ */
67
+ declare function useQueryWindowVirtualizer<TListContextParams, TRow extends Record<string, unknown>, TStartRow>(options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>): QueryVirtualizerResult<TRow>;
68
+ //#endregion
69
+ //#region src/use-pin-to-bottom.d.ts
70
+ /** Options for {@link usePinToBottom}: `enabled` and bottom-edge `slack`. */
71
+ type PinToBottomOptions = StickOptions;
72
+ /**
73
+ * Keep a chat/log list pinned to the bottom when content grows — only while the
74
+ * user is already at the bottom. Pass the result of `useQueryVirtualizer` /
75
+ * `useQueryWindowVirtualizer`.
76
+ */
77
+ declare function usePinToBottom<TRow>(virtualizer: QueryVirtualizerResult<TRow>, {
78
+ enabled,
79
+ slack
80
+ }?: PinToBottomOptions): void;
81
+ //#endregion
82
+ //#region src/use-restore-scroll-state.d.ts
83
+ /**
84
+ * Persist virtualizer scroll under a key in `history.state` (Navigation API).
85
+ * Pass the returned `[scrollState, setScrollState]` into `useQueryVirtualizer`.
86
+ * Default key is `"scrollState"`; use distinct keys for multiple lists.
87
+ * Requires the Navigation API (Firefox 147+).
88
+ */
89
+ declare function useRestoreScrollState<TStartRow>(key?: string): [ScrollHistoryState$1<TStartRow> | null, (state: ScrollHistoryState$1<TStartRow> | null) => void];
90
+ //#endregion
91
+ export { type AnchoringMode, type GetPageQuery, type GetPageQueryOptions, type GetSingleQuery, type GetSingleQueryOptions, type PinToBottomOptions, type QueryResult, type QueryVirtualizerResult, type RowKey, type ScrollHistoryState, type UseQueryVirtualizerOptions, type ViewStatus, type VirtualRow, rowAttributes, usePinToBottom, useQueryVirtualizer, useQueryWindowVirtualizer, useRestoreScrollState };
92
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/use-query-virtualizer.ts","../src/use-pin-to-bottom.ts","../src/use-restore-scroll-state.ts"],"mappings":";;;;;;;AAiBA;;KAAY,WAAA,aACE,MAAA,oBAA0B,MAAA;EAAA,SAG7B,KAAA,EAAO,UAAA,CAAW,GAAA,EAAK,GAAA;EAAA,SACvB,OAAA,GAAU,eAAA;AAAA;;;;;KAOT,YAAA,cAA0B,MAAA,iCACpC,OAAA,EAAS,mBAAA,CAAoB,SAAA,MAC1B,WAAA,CAAY,IAAA;;;;;KAML,cAAA,cAA4B,MAAA,sBACtC,OAAA,EAAS,qBAAA,KACN,WAAA,CAAY,IAAA;AAjBI;AAOrB;;;;AAPqB,KAwBT,0BAAA,kCAEG,MAAA,gCAEX,yBAAA,CACF,kBAAA,EACA,IAAA,EACA,SAAA,EACA,UAAA,CAAW,IAAA,UACX,eAAA,EACA,UAAA,CAAW,IAAA,SACX,eAAA;EAEA,YAAA,EAAc,YAAA,CAAa,IAAA,EAAM,SAAA;EACjC,cAAA,EAAgB,cAAA,CAAe,IAAA;AAAA;;;;;;KAQrB,sBAAA,SAA+B,IAAA,CACzC,iBAAA,CAAsB,IAAA;EAAA,SAGb,MAAA,EAAQ,YAAA;AAAA;;;;;;AAvDnB;;;;;;;;;;;;;;;;iBC8GgB,mBAAA,kCAED,MAAA,8BAGb,OAAA,EAAS,0BAAA,CAA2B,kBAAA,EAAoB,IAAA,EAAM,SAAA,IAC7D,sBAAA,CAAuB,IAAA;;AD/GL;AAOrB;;iBCwHgB,yBAAA,kCAED,MAAA,8BAGb,OAAA,EAAS,0BAAA,CAA2B,kBAAA,EAAoB,IAAA,EAAM,SAAA,IAC7D,sBAAA,CAAuB,IAAA;;;;KChJd,kBAAA,GAAqB,YAAA;;AFMjC;;;;iBECgB,cAAA,OACd,WAAA,EAAa,sBAAA,CAAuB,IAAA;EAClC,OAAA;EAAgB;AAAA,IAA+B,kBAAA;;;;;;;AFHnD;;iBGiBgB,qBAAA,YACd,GAAA,aAEA,oBAAA,CAAmB,SAAA,WAClB,KAAA,EAAO,oBAAA,CAAmB,SAAA"}
@@ -0,0 +1,92 @@
1
+ import { AnchoringMode, GetPageQueryOptions, GetSingleQueryOptions, RowKey, ScrollHistoryState, ScrollHistoryState as ScrollHistoryState$1, StickOptions, VirtualRow, VirtualizerBindingOptions, VirtualizerResult, rowAttributes } from "@rocicorp/zero-virtual/core";
2
+ import { UseQueryOptions, ViewStatus, ViewStatus as ViewStatus$1 } from "@doync/react";
3
+ import { BoundQuery } from "@doync/core";
4
+
5
+ //#region src/types.d.ts
6
+ /**
7
+ * What a page/single factory returns: a bound query plus optional `useQuery`
8
+ * options (`ttl`, `skip`). Args live on the bound value.
9
+ */
10
+ type QueryResult<Row extends Record<string, unknown> = Record<string, unknown>, One extends boolean = boolean> = {
11
+ readonly query: BoundQuery<Row, One>;
12
+ readonly options?: UseQueryOptions | undefined;
13
+ };
14
+ /**
15
+ * Page-query factory: given `{ limit, start, dir, settled }`, return a
16
+ * multi-row bound query.
17
+ */
18
+ type GetPageQuery<TRow extends Record<string, unknown>, TStartRow> = (options: GetPageQueryOptions<TStartRow>) => QueryResult<TRow, false>;
19
+ /**
20
+ * Single-row factory: given `{ id, settled }`, return a one-row bound query (``
21
+ * sql.one`…` `` / `findFirst`).
22
+ */
23
+ type GetSingleQuery<TRow extends Record<string, unknown>> = (options: GetSingleQueryOptions) => QueryResult<TRow, true>;
24
+ /**
25
+ * Options for {@link useQueryVirtualizer} / {@link useQueryWindowVirtualizer}.
26
+ * Provide `getPageQuery` and `getSingleQuery` factories that return bound
27
+ * queries.
28
+ */
29
+ type UseQueryVirtualizerOptions<TListContextParams, TRow extends Record<string, unknown>, TStartRow> = VirtualizerBindingOptions<TListContextParams, TRow, TStartRow, BoundQuery<TRow, false>, UseQueryOptions, BoundQuery<TRow, true>, UseQueryOptions> & {
30
+ getPageQuery: GetPageQuery<TRow, TStartRow>;
31
+ getSingleQuery: GetSingleQuery<TRow>;
32
+ };
33
+ /**
34
+ * Virtualizer result: virtual items, scroll helpers, and aggregated
35
+ * {@link ViewStatus} from the live page stages (replaces core's `complete`
36
+ * boolean).
37
+ */
38
+ type QueryVirtualizerResult<TRow> = Omit<VirtualizerResult<TRow>, "complete"> & {
39
+ readonly status: ViewStatus$1;
40
+ };
41
+ //#endregion
42
+ //#region src/use-query-virtualizer.d.ts
43
+ /**
44
+ * Virtualized infinite list inside an overflow element. `getScrollElement`
45
+ * returns that element (also where rows render). Provide page/single query
46
+ * factories via {@link UseQueryVirtualizerOptions}.
47
+ *
48
+ * ```ts
49
+ * const v = useQueryVirtualizer({
50
+ * getScrollElement: () => listRef.current,
51
+ * estimateSize: () => 48,
52
+ * getPageQuery: ({ limit, start, dir }) => ({
53
+ * query: queries.items.page({ limit, start, dir }),
54
+ * }),
55
+ * getSingleQuery: ({ id }) => ({
56
+ * query: queries.items.byId({ id }),
57
+ * }),
58
+ * // …
59
+ * })
60
+ * ```
61
+ */
62
+ declare function useQueryVirtualizer<TListContextParams, TRow extends Record<string, unknown>, TStartRow>(options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>): QueryVirtualizerResult<TRow>;
63
+ /**
64
+ * Like {@link useQueryVirtualizer}, but the window is the scroll container.
65
+ * `getScrollElement` returns the element rows render into (normal page flow).
66
+ */
67
+ declare function useQueryWindowVirtualizer<TListContextParams, TRow extends Record<string, unknown>, TStartRow>(options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>): QueryVirtualizerResult<TRow>;
68
+ //#endregion
69
+ //#region src/use-pin-to-bottom.d.ts
70
+ /** Options for {@link usePinToBottom}: `enabled` and bottom-edge `slack`. */
71
+ type PinToBottomOptions = StickOptions;
72
+ /**
73
+ * Keep a chat/log list pinned to the bottom when content grows — only while the
74
+ * user is already at the bottom. Pass the result of `useQueryVirtualizer` /
75
+ * `useQueryWindowVirtualizer`.
76
+ */
77
+ declare function usePinToBottom<TRow>(virtualizer: QueryVirtualizerResult<TRow>, {
78
+ enabled,
79
+ slack
80
+ }?: PinToBottomOptions): void;
81
+ //#endregion
82
+ //#region src/use-restore-scroll-state.d.ts
83
+ /**
84
+ * Persist virtualizer scroll under a key in `history.state` (Navigation API).
85
+ * Pass the returned `[scrollState, setScrollState]` into `useQueryVirtualizer`.
86
+ * Default key is `"scrollState"`; use distinct keys for multiple lists.
87
+ * Requires the Navigation API (Firefox 147+).
88
+ */
89
+ declare function useRestoreScrollState<TStartRow>(key?: string): [ScrollHistoryState$1<TStartRow> | null, (state: ScrollHistoryState$1<TStartRow> | null) => void];
90
+ //#endregion
91
+ export { type AnchoringMode, type GetPageQuery, type GetPageQueryOptions, type GetSingleQuery, type GetSingleQueryOptions, type PinToBottomOptions, type QueryResult, type QueryVirtualizerResult, type RowKey, type ScrollHistoryState, type UseQueryVirtualizerOptions, type ViewStatus, type VirtualRow, rowAttributes, usePinToBottom, useQueryVirtualizer, useQueryWindowVirtualizer, useRestoreScrollState };
92
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/use-query-virtualizer.ts","../src/use-pin-to-bottom.ts","../src/use-restore-scroll-state.ts"],"mappings":";;;;;;;AAiBA;;KAAY,WAAA,aACE,MAAA,oBAA0B,MAAA;EAAA,SAG7B,KAAA,EAAO,UAAA,CAAW,GAAA,EAAK,GAAA;EAAA,SACvB,OAAA,GAAU,eAAA;AAAA;;;;;KAOT,YAAA,cAA0B,MAAA,iCACpC,OAAA,EAAS,mBAAA,CAAoB,SAAA,MAC1B,WAAA,CAAY,IAAA;;;;;KAML,cAAA,cAA4B,MAAA,sBACtC,OAAA,EAAS,qBAAA,KACN,WAAA,CAAY,IAAA;AAjBI;AAOrB;;;;AAPqB,KAwBT,0BAAA,kCAEG,MAAA,gCAEX,yBAAA,CACF,kBAAA,EACA,IAAA,EACA,SAAA,EACA,UAAA,CAAW,IAAA,UACX,eAAA,EACA,UAAA,CAAW,IAAA,SACX,eAAA;EAEA,YAAA,EAAc,YAAA,CAAa,IAAA,EAAM,SAAA;EACjC,cAAA,EAAgB,cAAA,CAAe,IAAA;AAAA;;;;;;KAQrB,sBAAA,SAA+B,IAAA,CACzC,iBAAA,CAAsB,IAAA;EAAA,SAGb,MAAA,EAAQ,YAAA;AAAA;;;;;;AAvDnB;;;;;;;;;;;;;;;;iBC8GgB,mBAAA,kCAED,MAAA,8BAGb,OAAA,EAAS,0BAAA,CAA2B,kBAAA,EAAoB,IAAA,EAAM,SAAA,IAC7D,sBAAA,CAAuB,IAAA;;AD/GL;AAOrB;;iBCwHgB,yBAAA,kCAED,MAAA,8BAGb,OAAA,EAAS,0BAAA,CAA2B,kBAAA,EAAoB,IAAA,EAAM,SAAA,IAC7D,sBAAA,CAAuB,IAAA;;;;KChJd,kBAAA,GAAqB,YAAA;;AFMjC;;;;iBECgB,cAAA,OACd,WAAA,EAAa,sBAAA,CAAuB,IAAA;EAClC,OAAA;EAAgB;AAAA,IAA+B,kBAAA;;;;;;;AFHnD;;iBGiBgB,qBAAA,YACd,GAAA,aAEA,oBAAA,CAAmB,SAAA,WAClB,KAAA,EAAO,oBAAA,CAAmB,SAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import{DEFAULT_STICK_SLACK as e,ZeroVirtualizer as t,assembleRows as n,buildAfterQuery as r,buildMainQuery as i,buildSingleQuery as a,createStickToBottomCache as o,getHistoryStateServerSnapshot as s,getHistoryStateSnapshot as c,observeElementOffset as l,observeElementRect as u,observeWindowOffset as d,observeWindowRect as f,permalinkMissing as p,resolveElementScrollElement as m,resolveWindowScrollElement as h,rowAttributes as g,subscribeHistoryState as _,updateHistoryState as v,virtualizerResult as y}from"@rocicorp/zero-virtual/core";import{useCallback as b,useLayoutEffect as x,useMemo as S,useReducer as C,useRef as w,useState as T,useSyncExternalStore as E}from"react";import{useQuery as D}from"@doync/react";const O=Object.freeze({status:`unknown`});function k(e,t){for(let t of e)if(t.status===`error`)return t;return t?{status:`complete`}:O}function A(e){return e!==null&&e.options?.skip!==!0}function j({pageSize:e,anchor:t,settled:o,getPageQuery:s,getSingleQuery:c,toStartRow:l},u=D){let d={pageSize:e,anchor:t,settled:o},f=a(d,c),[m,h]=u(f?.query??!1,f?.options),g=m??void 0,_=h.status===`complete`,v=p(d,g,_),y=g?l(g):null,b=A(f),x=i(d,s,y,v),[C,w]=u(x?.query??!1,x?.options),T=w.status===`complete`,E=A(x),O=r(d,s,y,v),[j,M]=u(O?.query??!1,O?.options),N=M.status===`complete`,P=A(O);return S(()=>{let r=n({pageSize:e,anchor:t,settled:o},{singleRow:g,singleComplete:_,mainRows:C,mainComplete:T,afterRows:j,afterComplete:N}),i=[];return b&&i.push(h),E&&i.push(w),P&&i.push(M),{rows:r,status:k(i,r.complete)}},[e,t,o,g,_,h,b,C,T,w,E,j,N,M,P])}function M(e,n){let[,r]=C(()=>({}),{}),[i]=T(()=>new t(e,n));i.setOptions(e);let{pageSize:a,anchor:o,settled:s}=i.getQueryInputs(),{rows:c,status:l}=j({pageSize:a,anchor:o,settled:s,getPageQuery:e.getPageQuery,getSingleQuery:e.getSingleQuery,toStartRow:e.toStartRow});i.setRows(c),x(()=>{let e=i.subscribe(r);return()=>{e(),i.detach()}},[i]),x(()=>{i.attach(e.getScrollElement()),i.afterDOMUpdate()});let{getScrollElement:u,observeElementRect:d,observeElementOffset:f}=e,p=S(()=>({getScrollElement:u,observeElementRect:d,observeElementOffset:f}),[u,d,f]),m=i.getSnapshot(),h=l;return S(()=>{let{complete:e,...t}=y(m,p,n);return{...t,status:h}},[m,p,n,h])}function N(e){return M({...e,observeElementRect:e.observeElementRect??u,observeElementOffset:e.observeElementOffset??l},m)}function P(e){return M({...e,observeElementRect:e.observeElementRect??f,observeElementOffset:e.observeElementOffset??d},h)}function F(t,{enabled:n=!0,slack:r=e}={}){let i=w(null);x(()=>{if(!n){i.current?.detach();return}(i.current??=o()).ensure(t,r)}),x(()=>()=>{i.current?.detach(),i.current=null},[])}function I(){return[E(_,c,s),v]}function L(e=`scrollState`){let[t,n]=I();return[S(()=>t?t[e]??null:null,[t&&JSON.stringify(t[e]),e]),b(t=>{let r=c();n({...r,[e]:t})},[n,e])]}export{g as rowAttributes,F as usePinToBottom,N as useQueryVirtualizer,P as useQueryWindowVirtualizer,L as useRestoreScrollState};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["useQueryDefault","useQuery"],"sources":["../src/use-rows.ts","../src/use-query-virtualizer.ts","../src/use-pin-to-bottom.ts","../src/use-restore-scroll-state.ts"],"sourcesContent":["import type { BoundQuery } from '@doync/core'\n\nimport {\n useQuery as useQueryDefault,\n type FalsyQuery,\n type UseQueryOptions,\n type ViewStatus,\n} from '@doync/react'\nimport {\n assembleRows,\n buildAfterQuery,\n buildMainQuery,\n buildSingleQuery,\n permalinkMissing,\n type Anchor,\n type RowsSnapshot,\n} from '@rocicorp/zero-virtual/core'\nimport { useMemo } from 'react'\n\nimport type { GetPageQuery, GetSingleQuery, QueryResult } from './types'\n\n/**\n * Stage payload from core's `build*Query` helpers under doync's `{ query:\n * BoundQuery, options? }` factory contract (closeio/doync#199 / ADR-0027). Core\n * returns `null` for inactive stages; live stages carry a Bound query whose\n * args already ride inside the value.\n */\ntype Stage<\n TRow extends Record<string, unknown>,\n One extends boolean,\n> = QueryResult<TRow, One>\n\nconst UNKNOWN: ViewStatus = Object.freeze({ status: 'unknown' })\n\n/**\n * Aggregate live-stage ViewStatuses into the public virtualizer `status`\n * (closeio/doync#178 §F): first live error wins; assembled complete ⇒ complete;\n * else unknown. Skipped stages (core-null / falsy, or consumer `skip: true`)\n * never contribute.\n */\nexport function aggregateStatus(\n liveStatuses: readonly ViewStatus[],\n assembledComplete: boolean,\n): ViewStatus {\n for (const s of liveStatuses) {\n if (s.status === 'error') return s\n }\n if (assembledComplete) return { status: 'complete' }\n return UNKNOWN\n}\n\n/**\n * Injectable `useQuery` seam (closeio/doync#180 / #199): bound form only —\n * `useQuery(bound | falsy, options?)`. Adapter-first unit tests inject a fake\n * of this shape; the real hook assigns via a bound-form cast (its first public\n * overload rejects falsy).\n */\nexport type UseQueryLike = <\n Row extends Record<string, unknown> = Record<string, unknown>,\n>(\n query: BoundQuery<Row, boolean> | FalsyQuery,\n options?: UseQueryOptions,\n) => [readonly Row[] | Row | undefined, ViewStatus]\n\n/** Core-null or consumer-`skip: true` stages contribute nothing to ViewStatus. */\nfunction isLiveStage(\n stage: Stage<Record<string, unknown>, boolean> | null,\n): boolean {\n return stage !== null && stage.options?.skip !== true\n}\n\n/**\n * Internal three-slot adapter: stages Permalink / main / after via core\n * builders, always calls `useQuery` three times (hook-order stable), and passes\n * each stage's Bound query straight through — falsy when the builder returned\n * null (closeio/doync#199 dissolves the placeholder-under-skip workaround from\n * #180). Assembles a `RowsSnapshot` plus public `ViewStatus`.\n *\n * Not package-root-exported. Injectable `useQuery` for adapter-first unit\n * tests.\n */\nexport function useRows<TRow extends Record<string, unknown>, TStartRow>(\n {\n pageSize,\n anchor,\n settled,\n getPageQuery,\n getSingleQuery,\n toStartRow,\n }: {\n pageSize: number\n anchor: Anchor<TStartRow>\n settled: boolean\n getPageQuery: GetPageQuery<TRow, TStartRow>\n getSingleQuery: GetSingleQuery<TRow>\n toStartRow: (row: TRow) => TStartRow\n },\n // Bound-form cast: real useQuery's non-falsy first overload is not assignable\n // to the maybe-path UseQueryLike; both sides ARE BoundQuery | falsy (#199).\n useQuery: UseQueryLike = useQueryDefault as UseQueryLike,\n): { rows: RowsSnapshot<TRow>; status: ViewStatus } {\n const inputs = { pageSize, anchor, settled }\n\n // Stage 1: single-item lookup (permalink only). Null stage ⇒ falsy.\n const stage1 = buildSingleQuery(inputs, getSingleQuery as never) as Stage<\n TRow,\n true\n > | null\n const [singleRaw, singleStatus] = useQuery(\n stage1?.query ?? false,\n stage1?.options,\n )\n const typedSingleRow = (singleRaw as TRow | undefined) ?? undefined\n const singleComplete = singleStatus.status === 'complete'\n const notFound = permalinkMissing(inputs, typedSingleRow, singleComplete)\n const singleStart = typedSingleRow ? toStartRow(typedSingleRow) : null\n const live1 = isLiveStage(stage1)\n\n // Stage 2: page-before (permalink) or main page. Do not coerce\n // falsy-skipped multi-row `undefined` to `[]` (ADR-0027 a1; assembleRows\n // accepts it).\n const stage2 = buildMainQuery(\n inputs,\n getPageQuery as never,\n singleStart,\n notFound,\n ) as Stage<TRow, false> | null\n const [mainRaw, mainStatus] = useQuery(\n stage2?.query ?? false,\n stage2?.options,\n )\n const mainComplete = mainStatus.status === 'complete'\n const live2 = isLiveStage(stage2)\n\n // Stage 3: page-after (permalink only).\n const stage3 = buildAfterQuery(\n inputs,\n getPageQuery as never,\n singleStart,\n notFound,\n ) as Stage<TRow, false> | null\n const [afterRaw, afterStatus] = useQuery(\n stage3?.query ?? false,\n stage3?.options,\n )\n const afterComplete = afterStatus.status === 'complete'\n const live3 = isLiveStage(stage3)\n\n return useMemo(() => {\n const rows = assembleRows(\n { pageSize, anchor, settled },\n {\n singleRow: typedSingleRow,\n singleComplete,\n mainRows: mainRaw as readonly TRow[] | undefined as TRow[] | undefined,\n mainComplete,\n afterRows: afterRaw as readonly TRow[] | undefined as\n | TRow[]\n | undefined,\n afterComplete,\n },\n )\n const liveStatuses: ViewStatus[] = []\n if (live1) liveStatuses.push(singleStatus)\n if (live2) liveStatuses.push(mainStatus)\n if (live3) liveStatuses.push(afterStatus)\n return {\n rows,\n status: aggregateStatus(liveStatuses, rows.complete),\n }\n }, [\n pageSize,\n anchor,\n settled,\n typedSingleRow,\n singleComplete,\n singleStatus,\n live1,\n mainRaw,\n mainComplete,\n mainStatus,\n live2,\n afterRaw,\n afterComplete,\n afterStatus,\n live3,\n ])\n}\n","import type { ViewStatus } from '@doync/react'\n\nimport {\n observeElementOffset,\n observeElementRect,\n observeWindowOffset,\n observeWindowRect,\n resolveElementScrollElement,\n resolveWindowScrollElement,\n virtualizerResult,\n ZeroVirtualizer,\n type ResolvedScrollOptions,\n type ResolveScrollElement,\n} from '@rocicorp/zero-virtual/core'\nimport { useLayoutEffect, useMemo, useReducer, useState } from 'react'\n\nimport type {\n QueryVirtualizerResult,\n UseQueryVirtualizerOptions,\n} from './types'\n\nimport { useRows } from './use-rows'\n\n/**\n * Thin React binding over framework-agnostic `ZeroVirtualizer`\n * (closeio/doync#180): options and rows push silently every render; DOM work\n * runs from layout effects; re-renders come from the core's subscribe.\n *\n * Public result replaces core's `complete` boolean with aggregated `status:\n * ViewStatus` from live stages.\n */\nfunction useQueryVirtualizerImpl<\n TListContextParams,\n TRow extends Record<string, unknown>,\n TStartRow,\n>(\n options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow> &\n ResolvedScrollOptions,\n resolveScrollElement: ResolveScrollElement,\n): QueryVirtualizerResult<TRow> {\n const [, rerender] = useReducer(() => ({}), {})\n // One core instance per hook lifetime. Constructor is pure (no DOM /\n // listeners / timers), so Strict Mode double-construction is harmless —\n // and initializing paging from persisted scroll state here avoids Strict\n // Mode double-mounting the rows.\n const [core] = useState(\n () =>\n new ZeroVirtualizer<TListContextParams, TRow, TStartRow>(\n options,\n resolveScrollElement,\n ),\n )\n\n // Silent staging — never notifies during render.\n core.setOptions(options)\n const { pageSize, anchor, settled } = core.getQueryInputs()\n const { rows, status } = useRows<TRow, TStartRow>({\n pageSize,\n anchor,\n settled,\n getPageQuery: options.getPageQuery,\n getSingleQuery: options.getSingleQuery,\n toStartRow: options.toStartRow,\n })\n core.setRows(rows)\n\n // Mount/unmount: re-render subscription + listener teardown. Idempotent\n // across Strict Mode mount→unmount→mount (state survives detach; the\n // per-commit effect below re-attaches).\n useLayoutEffect(() => {\n const unsubscribe = core.subscribe(rerender)\n return () => {\n unsubscribe()\n core.detach()\n }\n }, [core])\n\n // Every commit, before paint: (re)wire the scroll element and run the\n // core's post-DOM-update pass (anchoring, restore/permalink, paging,\n // persistence).\n useLayoutEffect(() => {\n core.attach(options.getScrollElement())\n core.afterDOMUpdate()\n })\n\n const { getScrollElement, observeElementRect, observeElementOffset } = options\n const resultOptions = useMemo(\n () => ({ getScrollElement, observeElementRect, observeElementOffset }),\n [getScrollElement, observeElementRect, observeElementOffset],\n )\n const snapshot = core.getSnapshot()\n const statusRef = status\n return useMemo(() => {\n const base = virtualizerResult(\n snapshot,\n resultOptions,\n resolveScrollElement,\n )\n // Drop core's complete boolean; surface ViewStatus for consumers.\n const { complete: _complete, ...rest } = base\n void _complete\n return {\n ...rest,\n status: statusRef,\n }\n }, [snapshot, resultOptions, resolveScrollElement, statusRef])\n}\n\n/**\n * Virtualized infinite list inside an overflow element. `getScrollElement`\n * returns that element (also where rows render). Provide page/single query\n * factories via {@link UseQueryVirtualizerOptions}.\n *\n * ```ts\n * const v = useQueryVirtualizer({\n * getScrollElement: () => listRef.current,\n * estimateSize: () => 48,\n * getPageQuery: ({ limit, start, dir }) => ({\n * query: queries.items.page({ limit, start, dir }),\n * }),\n * getSingleQuery: ({ id }) => ({\n * query: queries.items.byId({ id }),\n * }),\n * // …\n * })\n * ```\n */\nexport function useQueryVirtualizer<\n TListContextParams,\n TRow extends Record<string, unknown>,\n TStartRow,\n>(\n options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>,\n): QueryVirtualizerResult<TRow> {\n return useQueryVirtualizerImpl(\n {\n ...options,\n observeElementRect: options.observeElementRect ?? observeElementRect,\n observeElementOffset:\n options.observeElementOffset ?? observeElementOffset,\n },\n resolveElementScrollElement,\n )\n}\n\n/**\n * Like {@link useQueryVirtualizer}, but the window is the scroll container.\n * `getScrollElement` returns the element rows render into (normal page flow).\n */\nexport function useQueryWindowVirtualizer<\n TListContextParams,\n TRow extends Record<string, unknown>,\n TStartRow,\n>(\n options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>,\n): QueryVirtualizerResult<TRow> {\n return useQueryVirtualizerImpl(\n {\n ...options,\n observeElementRect: options.observeElementRect ?? observeWindowRect,\n observeElementOffset: options.observeElementOffset ?? observeWindowOffset,\n },\n resolveWindowScrollElement,\n )\n}\n\n// Re-export for helper modules that type-check against the public result.\nexport type { QueryVirtualizerResult, ViewStatus }\n","import {\n createStickToBottomCache,\n DEFAULT_STICK_SLACK,\n type StickOptions,\n type StickToBottomCache,\n} from '@rocicorp/zero-virtual/core'\nimport { useLayoutEffect, useRef } from 'react'\n\nimport type { QueryVirtualizerResult } from './types'\n\n/** Options for {@link usePinToBottom}: `enabled` and bottom-edge `slack`. */\nexport type PinToBottomOptions = StickOptions\n\n/**\n * Keep a chat/log list pinned to the bottom when content grows — only while the\n * user is already at the bottom. Pass the result of `useQueryVirtualizer` /\n * `useQueryWindowVirtualizer`.\n */\nexport function usePinToBottom<TRow>(\n virtualizer: QueryVirtualizerResult<TRow>,\n { enabled = true, slack = DEFAULT_STICK_SLACK }: PinToBottomOptions = {},\n): void {\n const ref = useRef<StickToBottomCache | null>(null)\n\n // Runs per commit, pre-paint, with no deps on purpose: when the scroll\n // container renders conditionally (or before the first rows) elements can be\n // null and nothing else would re-run — ensure() retries each tick until they\n // exist, and is an identity-check no-op after that.\n useLayoutEffect(() => {\n if (!enabled) {\n ref.current?.detach()\n return\n }\n ;(ref.current ??= createStickToBottomCache()).ensure(virtualizer, slack)\n })\n\n useLayoutEffect(\n () => () => {\n ref.current?.detach()\n ref.current = null\n },\n [],\n )\n}\n","import {\n getHistoryStateServerSnapshot,\n getHistoryStateSnapshot,\n subscribeHistoryState,\n updateHistoryState,\n type ScrollHistoryState,\n} from '@rocicorp/zero-virtual/core'\nimport { useCallback, useMemo, useSyncExternalStore } from 'react'\n\nconst DEFAULT_KEY = 'scrollState'\n\n/**\n * Navigation API current-entry state as a React external store. Shell over pure\n * core history-state helpers — not a re-export of upstream `/react`\n * `useHistoryState` (closeio/doync#180).\n */\nfunction useNavigationHistoryState(): [\n state: unknown,\n setState: (state: unknown) => void,\n] {\n const state = useSyncExternalStore(\n subscribeHistoryState,\n getHistoryStateSnapshot,\n getHistoryStateServerSnapshot,\n )\n return [state, updateHistoryState]\n}\n\n/**\n * Persist virtualizer scroll under a key in `history.state` (Navigation API).\n * Pass the returned `[scrollState, setScrollState]` into `useQueryVirtualizer`.\n * Default key is `\"scrollState\"`; use distinct keys for multiple lists.\n * Requires the Navigation API (Firefox 147+).\n */\nexport function useRestoreScrollState<TStartRow>(\n key: string = DEFAULT_KEY,\n): [\n ScrollHistoryState<TStartRow> | null,\n (state: ScrollHistoryState<TStartRow> | null) => void,\n] {\n const [state, setState] = useNavigationHistoryState()\n\n // Memoize by serialized content so identity is stable when the nested key\n // is unchanged — core compares scrollState by reference on restore.\n const scrollState: ScrollHistoryState<TStartRow> | null = useMemo(() => {\n if (!state) return null\n return ((state as Record<string, unknown>)[key] ??\n null) as ScrollHistoryState<TStartRow> | null\n // eslint-disable-next-line react-hooks/exhaustive-deps -- content identity\n }, [state && JSON.stringify((state as Record<string, unknown>)[key]), key])\n\n const setScrollState = useCallback(\n (newState: ScrollHistoryState<TStartRow> | null) => {\n // Re-read the live history state instead of spreading the render-time\n // snapshot: the virtualizer calls this from a ~100ms persist debounce,\n // so another virtualizer (under a different key) or the app itself may\n // have written a sibling key since this closure was created — spreading\n // the stale snapshot would silently erase that write.\n const current = getHistoryStateSnapshot()\n setState({\n ...(current as Record<string, unknown>),\n [key]: newState,\n })\n },\n [setState, key],\n )\n\n return [scrollState, setScrollState]\n}\n"],"mappings":"8sBAgCA,MAAM,EAAsB,OAAO,OAAO,CAAE,OAAQ,SAAU,CAAC,EAQ/D,SAAgB,EACd,EACA,EACY,CACZ,IAAK,IAAM,KAAK,EACd,GAAI,EAAE,SAAW,QAAS,OAAO,EAGnC,OADI,EAA0B,CAAE,OAAQ,UAAW,EAC5C,CACT,CAgBA,SAAS,EACP,EACS,CACT,OAAO,IAAU,MAAQ,EAAM,SAAS,OAAS,EACnD,CAYA,SAAgB,EACd,CACE,WACA,SACA,UACA,eACA,iBACA,cAWF,EAAyBA,EACyB,CAClD,IAAM,EAAS,CAAE,WAAU,SAAQ,SAAQ,EAGrC,EAAS,EAAiB,EAAQ,CAAuB,EAIzD,CAAC,EAAW,GAAgBC,EAChC,GAAQ,OAAS,GACjB,GAAQ,OACV,EACM,EAAkB,GAAkC,IAAA,GACpD,EAAiB,EAAa,SAAW,WACzC,EAAW,EAAiB,EAAQ,EAAgB,CAAc,EAClE,EAAc,EAAiB,EAAW,CAAc,EAAI,KAC5D,EAAQ,EAAY,CAAM,EAK1B,EAAS,EACb,EACA,EACA,EACA,CACF,EACM,CAAC,EAAS,GAAcA,EAC5B,GAAQ,OAAS,GACjB,GAAQ,OACV,EACM,EAAe,EAAW,SAAW,WACrC,EAAQ,EAAY,CAAM,EAG1B,EAAS,EACb,EACA,EACA,EACA,CACF,EACM,CAAC,EAAU,GAAeA,EAC9B,GAAQ,OAAS,GACjB,GAAQ,OACV,EACM,EAAgB,EAAY,SAAW,WACvC,EAAQ,EAAY,CAAM,EAEhC,OAAO,MAAc,CACnB,IAAM,EAAO,EACX,CAAE,WAAU,SAAQ,SAAQ,EAC5B,CACE,UAAW,EACX,iBACA,SAAU,EACV,eACA,UAAW,EAGX,eACF,CACF,EACM,EAA6B,CAAC,EAIpC,OAHI,GAAO,EAAa,KAAK,CAAY,EACrC,GAAO,EAAa,KAAK,CAAU,EACnC,GAAO,EAAa,KAAK,CAAW,EACjC,CACL,OACA,OAAQ,EAAgB,EAAc,EAAK,QAAQ,CACrD,CACF,EAAG,CACD,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,CACF,CAAC,CACH,CC5JA,SAAS,EAKP,EAEA,EAC8B,CAC9B,GAAM,EAAG,GAAY,OAAkB,CAAC,GAAI,CAAC,CAAC,EAKxC,CAAC,GAAQ,MAEX,IAAI,EACF,EACA,CACF,CACJ,EAGA,EAAK,WAAW,CAAO,EACvB,GAAM,CAAE,WAAU,SAAQ,WAAY,EAAK,eAAe,EACpD,CAAE,OAAM,UAAW,EAAyB,CAChD,WACA,SACA,UACA,aAAc,EAAQ,aACtB,eAAgB,EAAQ,eACxB,WAAY,EAAQ,UACtB,CAAC,EACD,EAAK,QAAQ,CAAI,EAKjB,MAAsB,CACpB,IAAM,EAAc,EAAK,UAAU,CAAQ,EAC3C,UAAa,CACX,EAAY,EACZ,EAAK,OAAO,CACd,CACF,EAAG,CAAC,CAAI,CAAC,EAKT,MAAsB,CACpB,EAAK,OAAO,EAAQ,iBAAiB,CAAC,EACtC,EAAK,eAAe,CACtB,CAAC,EAED,GAAM,CAAE,mBAAkB,qBAAoB,wBAAyB,EACjE,EAAgB,OACb,CAAE,mBAAkB,qBAAoB,sBAAqB,GACpE,CAAC,EAAkB,EAAoB,CAAoB,CAC7D,EACM,EAAW,EAAK,YAAY,EAC5B,EAAY,EAClB,OAAO,MAAc,CAOnB,GAAM,CAAE,SAAU,EAAW,GAAG,GANnB,EACX,EACA,EACA,CAG0C,EAE5C,MAAO,CACL,GAAG,EACH,OAAQ,CACV,CACF,EAAG,CAAC,EAAU,EAAe,EAAsB,CAAS,CAAC,CAC/D,CAqBA,SAAgB,EAKd,EAC8B,CAC9B,OAAO,EACL,CACE,GAAG,EACH,mBAAoB,EAAQ,oBAAsB,EAClD,qBACE,EAAQ,sBAAwB,CACpC,EACA,CACF,CACF,CAMA,SAAgB,EAKd,EAC8B,CAC9B,OAAO,EACL,CACE,GAAG,EACH,mBAAoB,EAAQ,oBAAsB,EAClD,qBAAsB,EAAQ,sBAAwB,CACxD,EACA,CACF,CACF,CClJA,SAAgB,EACd,EACA,CAAE,UAAU,GAAM,QAAQ,GAA4C,CAAC,EACjE,CACN,IAAM,EAAM,EAAkC,IAAI,EAMlD,MAAsB,CACpB,GAAI,CAAC,EAAS,CACZ,EAAI,SAAS,OAAO,EACpB,MACF,EACE,EAAI,UAAY,EAAyB,EAAA,CAAG,OAAO,EAAa,CAAK,CACzE,CAAC,EAED,UACc,CACV,EAAI,SAAS,OAAO,EACpB,EAAI,QAAU,IAChB,EACA,CAAC,CACH,CACF,CC3BA,SAAS,GAGP,CAMA,MAAO,CALO,EACZ,EACA,EACA,CAEU,EAAG,CAAkB,CACnC,CAQA,SAAgB,EACd,EAAc,cAId,CACA,GAAM,CAAC,EAAO,GAAY,EAA0B,EA2BpD,MAAO,CAvBmD,MACnD,EACI,EAAkC,IACzC,KAFiB,KAIlB,CAAC,GAAS,KAAK,UAAW,EAAkC,EAAI,EAAG,CAAG,CAkBvD,EAhBK,EACpB,GAAmD,CAMlD,IAAM,EAAU,EAAwB,EACxC,EAAS,CACP,GAAI,GACH,GAAM,CACT,CAAC,CACH,EACA,CAAC,EAAU,CAAG,CAGkB,CAAC,CACrC"}
File without changes
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1 @@
1
+ export { };
File without changes
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@doync/query-virtualizer",
3
+ "version": "0.1.0",
4
+ "description": "doync React binding: virtualized infinite lists over Subscriptions via @rocicorp/zero-virtual/core",
5
+ "keywords": [
6
+ "doync",
7
+ "infinite-scroll",
8
+ "react",
9
+ "sync-engine",
10
+ "virtualization"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Vitor Buzinaro",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/closeio/doync.git",
17
+ "directory": "packages/query-virtualizer"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "type": "module",
24
+ "main": "./dist/index.cjs",
25
+ "module": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "import": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "require": {
34
+ "types": "./dist/index.d.cts",
35
+ "default": "./dist/index.cjs"
36
+ }
37
+ },
38
+ "./internal": {
39
+ "import": {
40
+ "types": "./dist/internal.d.ts",
41
+ "default": "./dist/internal.js"
42
+ },
43
+ "require": {
44
+ "types": "./dist/internal.d.cts",
45
+ "default": "./dist/internal.cjs"
46
+ }
47
+ }
48
+ },
49
+ "dependencies": {
50
+ "@rocicorp/zero-virtual": "0.6.3",
51
+ "@doync/core": "0.1.0",
52
+ "@doync/react": "0.1.0"
53
+ },
54
+ "devDependencies": {
55
+ "@testing-library/react": "^16.1.0",
56
+ "@types/node": "^24.13.2",
57
+ "@types/react": "^19.0.0",
58
+ "jsdom": "^25.0.1",
59
+ "react": "^19.0.0",
60
+ "react-dom": "^19.0.0",
61
+ "tsdown": "^0.22.3",
62
+ "typescript": "^7.0.2",
63
+ "vitest": "4.1.9"
64
+ },
65
+ "peerDependencies": {
66
+ "react": ">=18"
67
+ },
68
+ "scripts": {
69
+ "build": "rm -rf dist && tsdown",
70
+ "test": "vitest run",
71
+ "typecheck": "tsc --noEmit"
72
+ }
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `@doync/query-virtualizer` (closeio/doync#178 / #180 / #199): thin doync
3
+ * React binding over `@rocicorp/zero-virtual` **`/core` only**. Consumers get
4
+ * virtualized infinite lists over doync Subscriptions without declaring or
5
+ * importing zero-virtual themselves. Stage factories return a Bound query
6
+ * (ADR-0027); core-null stages pass falsy into the three unconditional
7
+ * `useQuery` slots — no placeholders.
8
+ *
9
+ * Deeper / experimental core symbols remain on `@rocicorp/zero-virtual/core`
10
+ * and may break across experimental-core pin bumps — this package's root export
11
+ * is the stable surface.
12
+ */
13
+
14
+ export {
15
+ useQueryVirtualizer,
16
+ useQueryWindowVirtualizer,
17
+ } from './use-query-virtualizer'
18
+
19
+ export { usePinToBottom, type PinToBottomOptions } from './use-pin-to-bottom'
20
+
21
+ export { useRestoreScrollState } from './use-restore-scroll-state'
22
+
23
+ export type {
24
+ GetPageQuery,
25
+ GetPageQueryOptions,
26
+ GetSingleQuery,
27
+ GetSingleQueryOptions,
28
+ QueryResult,
29
+ QueryVirtualizerResult,
30
+ UseQueryVirtualizerOptions,
31
+ } from './types'
32
+
33
+ // Core re-exports matching PRD §G
34
+ export {
35
+ rowAttributes,
36
+ type AnchoringMode,
37
+ type RowKey,
38
+ type ScrollHistoryState,
39
+ type VirtualRow,
40
+ } from '@rocicorp/zero-virtual/core'
41
+
42
+ export type { ViewStatus } from '@doync/react'
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `@doync/query-virtualizer/internal` (ADR-0033): placeholder entry — the
3
+ * current public surface stands. Kept so the nine-package `./internal` contract
4
+ * is uniform. May break in any release. Import virtualizer hooks from
5
+ * `@doync/query-virtualizer`.
6
+ */
7
+
8
+ export {}
package/src/types.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type { BoundQuery } from '@doync/core'
2
+ import type { UseQueryOptions, ViewStatus } from '@doync/react'
3
+ import type {
4
+ AnchoringMode,
5
+ GetPageQueryOptions,
6
+ GetSingleQueryOptions,
7
+ RowKey,
8
+ ScrollHistoryState,
9
+ VirtualRow,
10
+ VirtualizerBindingOptions as CoreVirtualizerBindingOptions,
11
+ VirtualizerResult as CoreVirtualizerResult,
12
+ } from '@rocicorp/zero-virtual/core'
13
+
14
+ /**
15
+ * What a page/single factory returns: a bound query plus optional `useQuery`
16
+ * options (`ttl`, `skip`). Args live on the bound value.
17
+ */
18
+ export type QueryResult<
19
+ Row extends Record<string, unknown> = Record<string, unknown>,
20
+ One extends boolean = boolean,
21
+ > = {
22
+ readonly query: BoundQuery<Row, One>
23
+ readonly options?: UseQueryOptions | undefined
24
+ }
25
+
26
+ /**
27
+ * Page-query factory: given `{ limit, start, dir, settled }`, return a
28
+ * multi-row bound query.
29
+ */
30
+ export type GetPageQuery<TRow extends Record<string, unknown>, TStartRow> = (
31
+ options: GetPageQueryOptions<TStartRow>,
32
+ ) => QueryResult<TRow, false>
33
+
34
+ /**
35
+ * Single-row factory: given `{ id, settled }`, return a one-row bound query (``
36
+ * sql.one`…` `` / `findFirst`).
37
+ */
38
+ export type GetSingleQuery<TRow extends Record<string, unknown>> = (
39
+ options: GetSingleQueryOptions,
40
+ ) => QueryResult<TRow, true>
41
+
42
+ /**
43
+ * Options for {@link useQueryVirtualizer} / {@link useQueryWindowVirtualizer}.
44
+ * Provide `getPageQuery` and `getSingleQuery` factories that return bound
45
+ * queries.
46
+ */
47
+ export type UseQueryVirtualizerOptions<
48
+ TListContextParams,
49
+ TRow extends Record<string, unknown>,
50
+ TStartRow,
51
+ > = CoreVirtualizerBindingOptions<
52
+ TListContextParams,
53
+ TRow,
54
+ TStartRow,
55
+ BoundQuery<TRow, false>,
56
+ UseQueryOptions,
57
+ BoundQuery<TRow, true>,
58
+ UseQueryOptions
59
+ > & {
60
+ getPageQuery: GetPageQuery<TRow, TStartRow>
61
+ getSingleQuery: GetSingleQuery<TRow>
62
+ }
63
+
64
+ /**
65
+ * Virtualizer result: virtual items, scroll helpers, and aggregated
66
+ * {@link ViewStatus} from the live page stages (replaces core's `complete`
67
+ * boolean).
68
+ */
69
+ export type QueryVirtualizerResult<TRow> = Omit<
70
+ CoreVirtualizerResult<TRow>,
71
+ 'complete'
72
+ > & {
73
+ readonly status: ViewStatus
74
+ }
75
+
76
+ export type {
77
+ AnchoringMode,
78
+ GetPageQueryOptions,
79
+ GetSingleQueryOptions,
80
+ RowKey,
81
+ ScrollHistoryState,
82
+ VirtualRow,
83
+ ViewStatus,
84
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ createStickToBottomCache,
3
+ DEFAULT_STICK_SLACK,
4
+ type StickOptions,
5
+ type StickToBottomCache,
6
+ } from '@rocicorp/zero-virtual/core'
7
+ import { useLayoutEffect, useRef } from 'react'
8
+
9
+ import type { QueryVirtualizerResult } from './types'
10
+
11
+ /** Options for {@link usePinToBottom}: `enabled` and bottom-edge `slack`. */
12
+ export type PinToBottomOptions = StickOptions
13
+
14
+ /**
15
+ * Keep a chat/log list pinned to the bottom when content grows — only while the
16
+ * user is already at the bottom. Pass the result of `useQueryVirtualizer` /
17
+ * `useQueryWindowVirtualizer`.
18
+ */
19
+ export function usePinToBottom<TRow>(
20
+ virtualizer: QueryVirtualizerResult<TRow>,
21
+ { enabled = true, slack = DEFAULT_STICK_SLACK }: PinToBottomOptions = {},
22
+ ): void {
23
+ const ref = useRef<StickToBottomCache | null>(null)
24
+
25
+ // Runs per commit, pre-paint, with no deps on purpose: when the scroll
26
+ // container renders conditionally (or before the first rows) elements can be
27
+ // null and nothing else would re-run — ensure() retries each tick until they
28
+ // exist, and is an identity-check no-op after that.
29
+ useLayoutEffect(() => {
30
+ if (!enabled) {
31
+ ref.current?.detach()
32
+ return
33
+ }
34
+ ;(ref.current ??= createStickToBottomCache()).ensure(virtualizer, slack)
35
+ })
36
+
37
+ useLayoutEffect(
38
+ () => () => {
39
+ ref.current?.detach()
40
+ ref.current = null
41
+ },
42
+ [],
43
+ )
44
+ }
@@ -0,0 +1,168 @@
1
+ import type { ViewStatus } from '@doync/react'
2
+
3
+ import {
4
+ observeElementOffset,
5
+ observeElementRect,
6
+ observeWindowOffset,
7
+ observeWindowRect,
8
+ resolveElementScrollElement,
9
+ resolveWindowScrollElement,
10
+ virtualizerResult,
11
+ ZeroVirtualizer,
12
+ type ResolvedScrollOptions,
13
+ type ResolveScrollElement,
14
+ } from '@rocicorp/zero-virtual/core'
15
+ import { useLayoutEffect, useMemo, useReducer, useState } from 'react'
16
+
17
+ import type {
18
+ QueryVirtualizerResult,
19
+ UseQueryVirtualizerOptions,
20
+ } from './types'
21
+
22
+ import { useRows } from './use-rows'
23
+
24
+ /**
25
+ * Thin React binding over framework-agnostic `ZeroVirtualizer`
26
+ * (closeio/doync#180): options and rows push silently every render; DOM work
27
+ * runs from layout effects; re-renders come from the core's subscribe.
28
+ *
29
+ * Public result replaces core's `complete` boolean with aggregated `status:
30
+ * ViewStatus` from live stages.
31
+ */
32
+ function useQueryVirtualizerImpl<
33
+ TListContextParams,
34
+ TRow extends Record<string, unknown>,
35
+ TStartRow,
36
+ >(
37
+ options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow> &
38
+ ResolvedScrollOptions,
39
+ resolveScrollElement: ResolveScrollElement,
40
+ ): QueryVirtualizerResult<TRow> {
41
+ const [, rerender] = useReducer(() => ({}), {})
42
+ // One core instance per hook lifetime. Constructor is pure (no DOM /
43
+ // listeners / timers), so Strict Mode double-construction is harmless —
44
+ // and initializing paging from persisted scroll state here avoids Strict
45
+ // Mode double-mounting the rows.
46
+ const [core] = useState(
47
+ () =>
48
+ new ZeroVirtualizer<TListContextParams, TRow, TStartRow>(
49
+ options,
50
+ resolveScrollElement,
51
+ ),
52
+ )
53
+
54
+ // Silent staging — never notifies during render.
55
+ core.setOptions(options)
56
+ const { pageSize, anchor, settled } = core.getQueryInputs()
57
+ const { rows, status } = useRows<TRow, TStartRow>({
58
+ pageSize,
59
+ anchor,
60
+ settled,
61
+ getPageQuery: options.getPageQuery,
62
+ getSingleQuery: options.getSingleQuery,
63
+ toStartRow: options.toStartRow,
64
+ })
65
+ core.setRows(rows)
66
+
67
+ // Mount/unmount: re-render subscription + listener teardown. Idempotent
68
+ // across Strict Mode mount→unmount→mount (state survives detach; the
69
+ // per-commit effect below re-attaches).
70
+ useLayoutEffect(() => {
71
+ const unsubscribe = core.subscribe(rerender)
72
+ return () => {
73
+ unsubscribe()
74
+ core.detach()
75
+ }
76
+ }, [core])
77
+
78
+ // Every commit, before paint: (re)wire the scroll element and run the
79
+ // core's post-DOM-update pass (anchoring, restore/permalink, paging,
80
+ // persistence).
81
+ useLayoutEffect(() => {
82
+ core.attach(options.getScrollElement())
83
+ core.afterDOMUpdate()
84
+ })
85
+
86
+ const { getScrollElement, observeElementRect, observeElementOffset } = options
87
+ const resultOptions = useMemo(
88
+ () => ({ getScrollElement, observeElementRect, observeElementOffset }),
89
+ [getScrollElement, observeElementRect, observeElementOffset],
90
+ )
91
+ const snapshot = core.getSnapshot()
92
+ const statusRef = status
93
+ return useMemo(() => {
94
+ const base = virtualizerResult(
95
+ snapshot,
96
+ resultOptions,
97
+ resolveScrollElement,
98
+ )
99
+ // Drop core's complete boolean; surface ViewStatus for consumers.
100
+ const { complete: _complete, ...rest } = base
101
+ void _complete
102
+ return {
103
+ ...rest,
104
+ status: statusRef,
105
+ }
106
+ }, [snapshot, resultOptions, resolveScrollElement, statusRef])
107
+ }
108
+
109
+ /**
110
+ * Virtualized infinite list inside an overflow element. `getScrollElement`
111
+ * returns that element (also where rows render). Provide page/single query
112
+ * factories via {@link UseQueryVirtualizerOptions}.
113
+ *
114
+ * ```ts
115
+ * const v = useQueryVirtualizer({
116
+ * getScrollElement: () => listRef.current,
117
+ * estimateSize: () => 48,
118
+ * getPageQuery: ({ limit, start, dir }) => ({
119
+ * query: queries.items.page({ limit, start, dir }),
120
+ * }),
121
+ * getSingleQuery: ({ id }) => ({
122
+ * query: queries.items.byId({ id }),
123
+ * }),
124
+ * // …
125
+ * })
126
+ * ```
127
+ */
128
+ export function useQueryVirtualizer<
129
+ TListContextParams,
130
+ TRow extends Record<string, unknown>,
131
+ TStartRow,
132
+ >(
133
+ options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>,
134
+ ): QueryVirtualizerResult<TRow> {
135
+ return useQueryVirtualizerImpl(
136
+ {
137
+ ...options,
138
+ observeElementRect: options.observeElementRect ?? observeElementRect,
139
+ observeElementOffset:
140
+ options.observeElementOffset ?? observeElementOffset,
141
+ },
142
+ resolveElementScrollElement,
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Like {@link useQueryVirtualizer}, but the window is the scroll container.
148
+ * `getScrollElement` returns the element rows render into (normal page flow).
149
+ */
150
+ export function useQueryWindowVirtualizer<
151
+ TListContextParams,
152
+ TRow extends Record<string, unknown>,
153
+ TStartRow,
154
+ >(
155
+ options: UseQueryVirtualizerOptions<TListContextParams, TRow, TStartRow>,
156
+ ): QueryVirtualizerResult<TRow> {
157
+ return useQueryVirtualizerImpl(
158
+ {
159
+ ...options,
160
+ observeElementRect: options.observeElementRect ?? observeWindowRect,
161
+ observeElementOffset: options.observeElementOffset ?? observeWindowOffset,
162
+ },
163
+ resolveWindowScrollElement,
164
+ )
165
+ }
166
+
167
+ // Re-export for helper modules that type-check against the public result.
168
+ export type { QueryVirtualizerResult, ViewStatus }
@@ -0,0 +1,69 @@
1
+ import {
2
+ getHistoryStateServerSnapshot,
3
+ getHistoryStateSnapshot,
4
+ subscribeHistoryState,
5
+ updateHistoryState,
6
+ type ScrollHistoryState,
7
+ } from '@rocicorp/zero-virtual/core'
8
+ import { useCallback, useMemo, useSyncExternalStore } from 'react'
9
+
10
+ const DEFAULT_KEY = 'scrollState'
11
+
12
+ /**
13
+ * Navigation API current-entry state as a React external store. Shell over pure
14
+ * core history-state helpers — not a re-export of upstream `/react`
15
+ * `useHistoryState` (closeio/doync#180).
16
+ */
17
+ function useNavigationHistoryState(): [
18
+ state: unknown,
19
+ setState: (state: unknown) => void,
20
+ ] {
21
+ const state = useSyncExternalStore(
22
+ subscribeHistoryState,
23
+ getHistoryStateSnapshot,
24
+ getHistoryStateServerSnapshot,
25
+ )
26
+ return [state, updateHistoryState]
27
+ }
28
+
29
+ /**
30
+ * Persist virtualizer scroll under a key in `history.state` (Navigation API).
31
+ * Pass the returned `[scrollState, setScrollState]` into `useQueryVirtualizer`.
32
+ * Default key is `"scrollState"`; use distinct keys for multiple lists.
33
+ * Requires the Navigation API (Firefox 147+).
34
+ */
35
+ export function useRestoreScrollState<TStartRow>(
36
+ key: string = DEFAULT_KEY,
37
+ ): [
38
+ ScrollHistoryState<TStartRow> | null,
39
+ (state: ScrollHistoryState<TStartRow> | null) => void,
40
+ ] {
41
+ const [state, setState] = useNavigationHistoryState()
42
+
43
+ // Memoize by serialized content so identity is stable when the nested key
44
+ // is unchanged — core compares scrollState by reference on restore.
45
+ const scrollState: ScrollHistoryState<TStartRow> | null = useMemo(() => {
46
+ if (!state) return null
47
+ return ((state as Record<string, unknown>)[key] ??
48
+ null) as ScrollHistoryState<TStartRow> | null
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- content identity
50
+ }, [state && JSON.stringify((state as Record<string, unknown>)[key]), key])
51
+
52
+ const setScrollState = useCallback(
53
+ (newState: ScrollHistoryState<TStartRow> | null) => {
54
+ // Re-read the live history state instead of spreading the render-time
55
+ // snapshot: the virtualizer calls this from a ~100ms persist debounce,
56
+ // so another virtualizer (under a different key) or the app itself may
57
+ // have written a sibling key since this closure was created — spreading
58
+ // the stale snapshot would silently erase that write.
59
+ const current = getHistoryStateSnapshot()
60
+ setState({
61
+ ...(current as Record<string, unknown>),
62
+ [key]: newState,
63
+ })
64
+ },
65
+ [setState, key],
66
+ )
67
+
68
+ return [scrollState, setScrollState]
69
+ }
@@ -0,0 +1,188 @@
1
+ import type { BoundQuery } from '@doync/core'
2
+
3
+ import {
4
+ useQuery as useQueryDefault,
5
+ type FalsyQuery,
6
+ type UseQueryOptions,
7
+ type ViewStatus,
8
+ } from '@doync/react'
9
+ import {
10
+ assembleRows,
11
+ buildAfterQuery,
12
+ buildMainQuery,
13
+ buildSingleQuery,
14
+ permalinkMissing,
15
+ type Anchor,
16
+ type RowsSnapshot,
17
+ } from '@rocicorp/zero-virtual/core'
18
+ import { useMemo } from 'react'
19
+
20
+ import type { GetPageQuery, GetSingleQuery, QueryResult } from './types'
21
+
22
+ /**
23
+ * Stage payload from core's `build*Query` helpers under doync's `{ query:
24
+ * BoundQuery, options? }` factory contract (closeio/doync#199 / ADR-0027). Core
25
+ * returns `null` for inactive stages; live stages carry a Bound query whose
26
+ * args already ride inside the value.
27
+ */
28
+ type Stage<
29
+ TRow extends Record<string, unknown>,
30
+ One extends boolean,
31
+ > = QueryResult<TRow, One>
32
+
33
+ const UNKNOWN: ViewStatus = Object.freeze({ status: 'unknown' })
34
+
35
+ /**
36
+ * Aggregate live-stage ViewStatuses into the public virtualizer `status`
37
+ * (closeio/doync#178 §F): first live error wins; assembled complete ⇒ complete;
38
+ * else unknown. Skipped stages (core-null / falsy, or consumer `skip: true`)
39
+ * never contribute.
40
+ */
41
+ export function aggregateStatus(
42
+ liveStatuses: readonly ViewStatus[],
43
+ assembledComplete: boolean,
44
+ ): ViewStatus {
45
+ for (const s of liveStatuses) {
46
+ if (s.status === 'error') return s
47
+ }
48
+ if (assembledComplete) return { status: 'complete' }
49
+ return UNKNOWN
50
+ }
51
+
52
+ /**
53
+ * Injectable `useQuery` seam (closeio/doync#180 / #199): bound form only —
54
+ * `useQuery(bound | falsy, options?)`. Adapter-first unit tests inject a fake
55
+ * of this shape; the real hook assigns via a bound-form cast (its first public
56
+ * overload rejects falsy).
57
+ */
58
+ export type UseQueryLike = <
59
+ Row extends Record<string, unknown> = Record<string, unknown>,
60
+ >(
61
+ query: BoundQuery<Row, boolean> | FalsyQuery,
62
+ options?: UseQueryOptions,
63
+ ) => [readonly Row[] | Row | undefined, ViewStatus]
64
+
65
+ /** Core-null or consumer-`skip: true` stages contribute nothing to ViewStatus. */
66
+ function isLiveStage(
67
+ stage: Stage<Record<string, unknown>, boolean> | null,
68
+ ): boolean {
69
+ return stage !== null && stage.options?.skip !== true
70
+ }
71
+
72
+ /**
73
+ * Internal three-slot adapter: stages Permalink / main / after via core
74
+ * builders, always calls `useQuery` three times (hook-order stable), and passes
75
+ * each stage's Bound query straight through — falsy when the builder returned
76
+ * null (closeio/doync#199 dissolves the placeholder-under-skip workaround from
77
+ * #180). Assembles a `RowsSnapshot` plus public `ViewStatus`.
78
+ *
79
+ * Not package-root-exported. Injectable `useQuery` for adapter-first unit
80
+ * tests.
81
+ */
82
+ export function useRows<TRow extends Record<string, unknown>, TStartRow>(
83
+ {
84
+ pageSize,
85
+ anchor,
86
+ settled,
87
+ getPageQuery,
88
+ getSingleQuery,
89
+ toStartRow,
90
+ }: {
91
+ pageSize: number
92
+ anchor: Anchor<TStartRow>
93
+ settled: boolean
94
+ getPageQuery: GetPageQuery<TRow, TStartRow>
95
+ getSingleQuery: GetSingleQuery<TRow>
96
+ toStartRow: (row: TRow) => TStartRow
97
+ },
98
+ // Bound-form cast: real useQuery's non-falsy first overload is not assignable
99
+ // to the maybe-path UseQueryLike; both sides ARE BoundQuery | falsy (#199).
100
+ useQuery: UseQueryLike = useQueryDefault as UseQueryLike,
101
+ ): { rows: RowsSnapshot<TRow>; status: ViewStatus } {
102
+ const inputs = { pageSize, anchor, settled }
103
+
104
+ // Stage 1: single-item lookup (permalink only). Null stage ⇒ falsy.
105
+ const stage1 = buildSingleQuery(inputs, getSingleQuery as never) as Stage<
106
+ TRow,
107
+ true
108
+ > | null
109
+ const [singleRaw, singleStatus] = useQuery(
110
+ stage1?.query ?? false,
111
+ stage1?.options,
112
+ )
113
+ const typedSingleRow = (singleRaw as TRow | undefined) ?? undefined
114
+ const singleComplete = singleStatus.status === 'complete'
115
+ const notFound = permalinkMissing(inputs, typedSingleRow, singleComplete)
116
+ const singleStart = typedSingleRow ? toStartRow(typedSingleRow) : null
117
+ const live1 = isLiveStage(stage1)
118
+
119
+ // Stage 2: page-before (permalink) or main page. Do not coerce
120
+ // falsy-skipped multi-row `undefined` to `[]` (ADR-0027 a1; assembleRows
121
+ // accepts it).
122
+ const stage2 = buildMainQuery(
123
+ inputs,
124
+ getPageQuery as never,
125
+ singleStart,
126
+ notFound,
127
+ ) as Stage<TRow, false> | null
128
+ const [mainRaw, mainStatus] = useQuery(
129
+ stage2?.query ?? false,
130
+ stage2?.options,
131
+ )
132
+ const mainComplete = mainStatus.status === 'complete'
133
+ const live2 = isLiveStage(stage2)
134
+
135
+ // Stage 3: page-after (permalink only).
136
+ const stage3 = buildAfterQuery(
137
+ inputs,
138
+ getPageQuery as never,
139
+ singleStart,
140
+ notFound,
141
+ ) as Stage<TRow, false> | null
142
+ const [afterRaw, afterStatus] = useQuery(
143
+ stage3?.query ?? false,
144
+ stage3?.options,
145
+ )
146
+ const afterComplete = afterStatus.status === 'complete'
147
+ const live3 = isLiveStage(stage3)
148
+
149
+ return useMemo(() => {
150
+ const rows = assembleRows(
151
+ { pageSize, anchor, settled },
152
+ {
153
+ singleRow: typedSingleRow,
154
+ singleComplete,
155
+ mainRows: mainRaw as readonly TRow[] | undefined as TRow[] | undefined,
156
+ mainComplete,
157
+ afterRows: afterRaw as readonly TRow[] | undefined as
158
+ | TRow[]
159
+ | undefined,
160
+ afterComplete,
161
+ },
162
+ )
163
+ const liveStatuses: ViewStatus[] = []
164
+ if (live1) liveStatuses.push(singleStatus)
165
+ if (live2) liveStatuses.push(mainStatus)
166
+ if (live3) liveStatuses.push(afterStatus)
167
+ return {
168
+ rows,
169
+ status: aggregateStatus(liveStatuses, rows.complete),
170
+ }
171
+ }, [
172
+ pageSize,
173
+ anchor,
174
+ settled,
175
+ typedSingleRow,
176
+ singleComplete,
177
+ singleStatus,
178
+ live1,
179
+ mainRaw,
180
+ mainComplete,
181
+ mainStatus,
182
+ live2,
183
+ afterRaw,
184
+ afterComplete,
185
+ afterStatus,
186
+ live3,
187
+ ])
188
+ }