@zakkster/lite-virtual 1.0.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.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zahary Shinikchiev
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,620 @@
1
+ # @zakkster/lite-virtual
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-virtual.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-virtual)
4
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
5
+ [![zero-gc](https://img.shields.io/badge/zero--GC-steady--state-5fe39f.svg)](#why-this-exists)
6
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-virtual?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-resource)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-virtual?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-virtual)
8
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-virtual?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-virtual)
9
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational?style=flat-square)
10
+ [![lite-signal peer](https://img.shields.io/npm/dependency-version/@zakkster/lite-virtual/peer/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://github.com/PeshoVurtoleta/lite-signal)
11
+ [![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE.txt)
12
+
13
+ > Thrash-free list/grid windowing on `@zakkster/lite-signal`.
14
+ > Integer-gated reactive indices + Object.is cutoff =
15
+ > **scrolling within a row writes nothing to the DOM and allocates zero bytes.**
16
+
17
+ ```bash
18
+ npm i @zakkster/lite-virtual @zakkster/lite-signal
19
+ ```
20
+
21
+ ```js
22
+ import { mount } from "@zakkster/lite-element";
23
+ import { mountList } from "@zakkster/lite-virtual";
24
+
25
+ mount(document.getElementById("box"), (host, scope) => {
26
+ mountList(host, scope, {
27
+ count: 1_000_000, // a million rows
28
+ itemHeight: 32,
29
+ viewport: host.clientHeight,
30
+ render: (rowEl, i) => { rowEl.textContent = `row ${i}`; },
31
+ });
32
+ });
33
+ ```
34
+
35
+ **Headline (measured on Node 22, see [Benchmarks](#benchmarks)):**
36
+ **~3.6 million sub-row scroll events per second at 0 bytes/op.**
37
+ A `mountList` with 1,000,000 items keeps ~21 DOM nodes alive.
38
+ ~800K full scroll events/sec on a 1M-item list. Scrolling within
39
+ one row writes literally nothing to the DOM and allocates literally
40
+ zero bytes — verified by the test suite, not by hope.
41
+
42
+ ---
43
+
44
+ ## Table of contents
45
+
46
+ - [Why this exists](#why-this-exists)
47
+ - [What you get](#what-you-get)
48
+ - [Quickstart](#quickstart)
49
+ - [Pure-math axes](#pure-math-axes)
50
+ - [Recycling renderers](#recycling-renderers)
51
+ - [Stateful rows: when to use `mountKeyedList`](#stateful-rows-when-to-use-mountkeyedlist)
52
+ - [Variable heights](#variable-heights)
53
+ - [Observer safety](#observer-safety)
54
+ - [API reference](#api-reference)
55
+ - [Benchmarks](#benchmarks)
56
+ - [Edge cases pinned down](#edge-cases-pinned-down)
57
+ - [What this is **not**](#what-this-is-not)
58
+ - [Browser / runtime support](#browser--runtime-support)
59
+ - [Peer dependency](#peer-dependency)
60
+ - [FAQ](#faq)
61
+ - [License](#license)
62
+
63
+ ---
64
+
65
+ ## Why this exists
66
+
67
+ A small set of design constraints picked deliberately:
68
+
69
+ - **Boundary-only updates.** The visible range derives from
70
+ `Math.floor(scrollTop / itemSize)` — an integer. lite-signal's
71
+ `Object.is` cutoff means that integer only changes when you actually
72
+ cross a row boundary. A fast 1-pixel scroll inside one row produces
73
+ ZERO reactive work, ZERO DOM writes, ZERO bytes allocated. The whole
74
+ chain — start, end, offsetStart, totalSize, your bind effects — sits
75
+ idle between crossings.
76
+ - **Bounded DOM pool.** A `mountList` of 1,000,000 items keeps ~21 DOM
77
+ nodes alive, no matter how far you scroll. Recycling is index-based:
78
+ on a one-row scroll, exactly one node's transform + content updates.
79
+ - **No virtual DOM. No template compiler. No renderer.** The math
80
+ (`virtualAxis`, `virtualGrid`, `variableAxis`) is pure reactive state
81
+ you can pair with any drawing primitive — DOM, canvas, WebGL, terminal
82
+ ANSI. The included recycling renderers are opinionated, ~1 KB each,
83
+ and use lite-element's scope.
84
+ - **Observer-safe by design.** Each renderer sets `host.style.overflow =
85
+ "auto"`, so the giant inner spacer scrolls INSIDE the host and never
86
+ inflates the host's reported size to outer observers (IntersectionObserver,
87
+ parent ResizeObserver, flex containers). The library's own RO reads
88
+ `contentRect.height` — never `scrollHeight` — so no feedback loop is
89
+ reachable.
90
+ - **Two layers, separated.** Pure math axes (1 KB) are useful on their own.
91
+ Recycling renderers (3 KB total) layer on top. Use what you need, skip
92
+ what you don't.
93
+
94
+ If you want a framework integration with hooks, a `<VirtualList>` component,
95
+ SSR helpers, snap-to-row, or sticky headers — this is the wrong library.
96
+ Seven functions, one peer dep, ~4 KB minified.
97
+
98
+ ---
99
+
100
+ ## What you get
101
+
102
+ ```js
103
+ // Pure math — pair with any renderer:
104
+ const axis = virtualAxis({ count, itemSize, viewport, overscan });
105
+ axis.start(); // reactive integer — only changes on boundary cross
106
+ axis.end(); // reactive integer
107
+ axis.offsetStart(); // reactive px
108
+ axis.totalSize(); // reactive px (count * itemSize)
109
+ axis.setScroll(px); axis.setViewport(px); axis.setCount(n);
110
+
111
+ const grid = virtualGrid({ rowCount, colCount, rowHeight, colWidth,
112
+ viewportHeight, viewportWidth, overscan });
113
+ grid.rowStart, grid.rowEnd, grid.colStart, grid.colEnd, /* ... */
114
+
115
+ const va = variableAxis({ count, sizeAt: i => sizes[i], viewport, overscan });
116
+ va.positionAt(i); // O(1) prefix-sum offset
117
+ va.minSize(); // smallest row (for pool pre-sizing)
118
+ va.remeasure(); // rebuild offsets from current sizeAt
119
+
120
+ // Opinionated recycling renderers — built on lite-element's scope shape:
121
+ mountList(host, scope, { count, itemHeight, viewport, render });
122
+ mountKeyedList(host, scope, { items, itemHeight, viewport, key, render });
123
+ mountGrid(host, scope, { rowCount, colCount, rowHeight, colWidth,
124
+ viewportWidth, viewportHeight, render });
125
+ mountVariableList(host, scope, { count, sizeAt, viewport, render });
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Quickstart
131
+
132
+ A 1,000,000-row list rendered as a single `<lite-element>`:
133
+
134
+ ```js
135
+ import { define } from "@zakkster/lite-element";
136
+ import { mountList } from "@zakkster/lite-virtual";
137
+
138
+ define("huge-list", (host, scope) => {
139
+ host.style.height = "400px";
140
+ mountList(host, scope, {
141
+ count: 1_000_000,
142
+ itemHeight: 32,
143
+ viewport: 400,
144
+ render: (rowEl, i) => {
145
+ rowEl.textContent = `row ${i}`;
146
+ rowEl.style.padding = "6px 12px";
147
+ rowEl.style.borderBottom = "1px solid #eee";
148
+ },
149
+ });
150
+ });
151
+ ```
152
+
153
+ ```html
154
+ <huge-list></huge-list>
155
+ ```
156
+
157
+ The DOM has ~21 `<div>` rows inside `<huge-list>`. Scrolling re-uses them.
158
+
159
+ ---
160
+
161
+ ## Pure-math axes
162
+
163
+ For renderers that aren't a DOM list — canvas / WebGL grids, terminal UIs,
164
+ scroll-bound parallax — use the math directly:
165
+
166
+ ```js
167
+ import { virtualAxis } from "@zakkster/lite-virtual";
168
+ import { effect } from "@zakkster/lite-signal";
169
+
170
+ const axis = virtualAxis({
171
+ count: 1_000_000,
172
+ itemSize: 18,
173
+ viewport: 600,
174
+ overscan: 3,
175
+ });
176
+
177
+ scrollEl.addEventListener("scroll", () => axis.setScroll(scrollEl.scrollTop));
178
+
179
+ effect(() => {
180
+ // Runs ONLY when start or end changes — i.e. on boundary crossings.
181
+ drawCanvasWindow(ctx, axis.start(), axis.end(), axis.offsetStart());
182
+ });
183
+ ```
184
+
185
+ The `effect` body fires once per boundary crossing — never on sub-row scrolls.
186
+ You can throttle / coalesce externally if you want fewer; the cutoff already
187
+ ensures you never do MORE work than necessary.
188
+
189
+ ---
190
+
191
+ ## Recycling renderers
192
+
193
+ ### `mountList` — index-based, stateless rows
194
+
195
+ Stateless content (text, static markup, derived display strings) goes through
196
+ `mountList`. A pool of `perView + 2 * overscan` row `<div>`s is created once;
197
+ each row's `transform = translateY(index * itemHeight)` and its content are
198
+ updated only when the index it represents changes. On a one-row scroll,
199
+ exactly one node updates.
200
+
201
+ ```js
202
+ mountList(host, scope, {
203
+ count: data.length,
204
+ itemHeight: 28,
205
+ viewport: host.clientHeight,
206
+ overscan: 3,
207
+ render: (rowEl, i) => {
208
+ rowEl.textContent = data[i];
209
+ },
210
+ });
211
+ ```
212
+
213
+ The recycling is INDEX-based — meaning the same `<div>` is reused across
214
+ different logical indices. That's why rows must be stateless: any DOM state
215
+ on a row (input focus, video playback, accordion-expanded) would follow the
216
+ node, not the data row, and break the moment the user scrolls.
217
+
218
+ ### `mountGrid` — 2-D recycling
219
+
220
+ ```js
221
+ mountGrid(host, scope, {
222
+ rowCount: 50_000,
223
+ colCount: 500,
224
+ rowHeight: 28,
225
+ colWidth: 120,
226
+ viewportWidth: host.clientWidth,
227
+ viewportHeight: host.clientHeight,
228
+ overscan: 2,
229
+ render: (cellEl, row, col) => {
230
+ cellEl.textContent = data[row][col];
231
+ },
232
+ });
233
+ ```
234
+
235
+ The pool is pre-sized to the maximum window size, so scrolling never
236
+ reshuffles the modulo mapping. Crossing a row boundary re-renders one new
237
+ row of cells; crossing a column boundary re-renders one new column.
238
+ Stateless cells (same constraint as `mountList`).
239
+
240
+ ---
241
+
242
+ ## Stateful rows: when to use `mountKeyedList`
243
+
244
+ If a row owns DOM state — an `<input>` you can type into, a `<video>` that's
245
+ playing, a `<details>` element the user expanded — you cannot recycle nodes
246
+ by index slot. Each node has to stay with its row's identity.
247
+
248
+ `mountKeyedList` does that: a node is created when its key enters the window
249
+ and removed when the key leaves. Reorder moves nodes by transform; `render`
250
+ is **not** called again for an existing node.
251
+
252
+ ```js
253
+ import { signal } from "@zakkster/lite-signal";
254
+
255
+ const rows = signal([
256
+ { id: "a", draft: "" },
257
+ { id: "b", draft: "" },
258
+ /* ... */
259
+ ]);
260
+
261
+ mountKeyedList(host, scope, {
262
+ items: () => rows(), // reactive accessor
263
+ itemHeight: 48,
264
+ viewport: host.clientHeight,
265
+ key: (item) => item.id,
266
+ render: (rowEl, item, index) => {
267
+ // Called ONCE per key. Bind reactively inside.
268
+ rowEl.innerHTML = `<input>`;
269
+ const inp = rowEl.firstChild;
270
+ inp.value = item.draft;
271
+ inp.addEventListener("input", (ev) => { item.draft = ev.target.value; });
272
+ },
273
+ });
274
+
275
+ // Reorder — typed text and focus survive:
276
+ rows.update((arr) => [...arr].reverse());
277
+ ```
278
+
279
+ More allocation than `mountList` (one DOM node creation per key entering the
280
+ window, one removal per key leaving), but correct for stateful content.
281
+
282
+ ---
283
+
284
+ ## Variable heights
285
+
286
+ If row heights are not uniform but you know them up front, use
287
+ `mountVariableList`. The library builds a prefix-sum offsets array once
288
+ (O(n)), then resolves each scroll position by binary search (O(log n)).
289
+ The no-thrash property carries over — firstItem is still an integer, so
290
+ Object.is still gates downstream work.
291
+
292
+ ```js
293
+ const sizes = rows.map(measureHeight); // your own measurement
294
+
295
+ mountVariableList(host, scope, {
296
+ count: rows.length,
297
+ sizeAt: (i) => sizes[i],
298
+ viewport: host.clientHeight,
299
+ render: (rowEl, i) => {
300
+ rowEl.textContent = rows[i].text;
301
+ },
302
+ });
303
+ ```
304
+
305
+ If your sizes change later (responsive layout, font load, ResizeObserver on
306
+ rendered rows), call `axis.remeasure()` — it walks `sizeAt` again and bumps
307
+ an internal version signal that re-triggers the downstream chain.
308
+
309
+ > **Not handled:** measured / auto-height rows whose size is unknown until
310
+ > rendered. That needs an estimate-then-correct loop on top: render at an
311
+ > estimated height, measure via ResizeObserver, patch the affected entries,
312
+ > bump the version, and anchor the scroll position to the item under the
313
+ > viewport top so the correction doesn't visibly jump. The windowing /
314
+ > recycling above works verbatim; only the offsets source becomes
315
+ > incremental. (Future `@zakkster/lite-virtual-measure` package.)
316
+
317
+ ---
318
+
319
+ ## Observer safety
320
+
321
+ The classic gotcha with virtualization: a giant inner spacer leaks its size
322
+ to parent layout (flex grow, `IntersectionObserver`, parent `ResizeObserver`),
323
+ which feeds back into the library's viewport reading, which renders more
324
+ rows, which... infinite loop.
325
+
326
+ lite-virtual breaks the loop on **two** independent fronts:
327
+
328
+ 1. **`host.style.overflow = "auto"`.** The spacer (whose `style.height` is
329
+ the full `count * itemSize`) scrolls INSIDE the host. The host itself
330
+ only takes the space its parent gave it. Outer `IntersectionObserver` /
331
+ `ResizeObserver` see `host`'s own `clientHeight` — not the inflated
332
+ spacer — because the host clips.
333
+ 2. **The library's RO reads `contentRect.height`, never `scrollHeight`.**
334
+ So even in the (very unusual) case that some part of layout did inflate
335
+ the host's reported size, the renderer would only see the actual visible
336
+ viewport, not anything the spacer can influence.
337
+
338
+ This is pinned down by [test/03-observers.test.js](./test/03-observers.test.js)
339
+ — 10 tests including the worst case (simultaneous external IO + RO + scroll
340
+ across 100K items), all asserting the pool stays bounded and no feedback
341
+ loop is reachable.
342
+
343
+ The library **does not** register any `IntersectionObserver` of its own —
344
+ only ONE `ResizeObserver` per renderer, disconnected on `scope.dispose()`.
345
+
346
+ ---
347
+
348
+ ## API reference
349
+
350
+ ### `virtualAxis(options) → VirtualAxis`
351
+
352
+ | option | type | default | notes |
353
+ |--------------|----------|---------|-----------------------------------------------|
354
+ | `count` | number | — | number of items |
355
+ | `itemSize` | number | — | pixel size along the scroll axis |
356
+ | `viewport` | number | — | viewport size along the scroll axis |
357
+ | `overscan` | number | 3 | extra items kept mounted on each side |
358
+
359
+ Returns reactive `scrollPos`, `start`, `end`, `offsetStart`, `totalSize`
360
+ plus setters `setScroll`, `setViewport`, `setCount`.
361
+
362
+ ### `virtualGrid(options) → VirtualGrid`
363
+
364
+ | option | type | default |
365
+ |------------------|--------|---------|
366
+ | `rowCount` | number | — |
367
+ | `colCount` | number | — |
368
+ | `rowHeight` | number | — |
369
+ | `colWidth` | number | — |
370
+ | `viewportHeight` | number | — |
371
+ | `viewportWidth` | number | — |
372
+ | `overscan` | number | 2 |
373
+
374
+ Returns `rowStart`, `rowEnd`, `rowOffset`, `totalHeight`, `colStart`,
375
+ `colEnd`, `colOffset`, `totalWidth`, `setScroll`, `setViewport`, `setCounts`.
376
+
377
+ ### `variableAxis(options) → VariableAxis`
378
+
379
+ | option | type | default |
380
+ |--------------|----------|---------|
381
+ | `count` | number | — |
382
+ | `sizeAt` | `(i) => number` | — |
383
+ | `viewport` | number | — |
384
+ | `overscan` | number | 3 |
385
+
386
+ Returns the `virtualAxis` surface plus `positionAt(i)`, `minSize()`,
387
+ `remeasure()`.
388
+
389
+ ### `mountList(host, scope, options) → VirtualAxis`
390
+
391
+ | option | type | default | notes |
392
+ |--------------|----------|---------|------------------------------------------------------|
393
+ | `count` | number | — | |
394
+ | `itemHeight` | number | — | |
395
+ | `viewport` | number | — | initial — auto-synced via ResizeObserver if available |
396
+ | `overscan` | number | 3 | |
397
+ | `render` | `(rowEl, index) => void` | — | called when an index enters the window or recycles |
398
+
399
+ Returns the axis (use it for `setCount` on growth, etc).
400
+
401
+ ### `mountKeyedList<T>(host, scope, options) → VirtualAxis`
402
+
403
+ | option | type | default |
404
+ |--------------|----------|---------|
405
+ | `items` | `() => readonly T[]` | — |
406
+ | `itemHeight` | number | — |
407
+ | `viewport` | number | — |
408
+ | `overscan` | number | 3 |
409
+ | `key` | `(item, index) => string \| number` | — |
410
+ | `render` | `(rowEl, item, index) => void` | — |
411
+
412
+ ### `mountGrid(host, scope, options) → VirtualGrid`
413
+
414
+ | option | type | default |
415
+ |------------------|----------|---------|
416
+ | `rowCount` | number | — |
417
+ | `colCount` | number | — |
418
+ | `rowHeight` | number | — |
419
+ | `colWidth` | number | — |
420
+ | `viewportWidth` | number | — |
421
+ | `viewportHeight` | number | — |
422
+ | `overscan` | number | 2 |
423
+ | `render` | `(cellEl, row, col) => void` | — |
424
+
425
+ ### `mountVariableList(host, scope, options) → VariableAxis`
426
+
427
+ | option | type | default |
428
+ |--------------|----------|---------|
429
+ | `count` | number | — |
430
+ | `sizeAt` | `(i) => number` | — |
431
+ | `viewport` | number | — |
432
+ | `overscan` | number | 3 |
433
+ | `render` | `(rowEl, index) => void` | — |
434
+
435
+ ---
436
+
437
+ ## Benchmarks
438
+
439
+ Measured on Node 22.22 with `--expose-gc`. Run yourself: `npm run bench`.
440
+
441
+ | Scenario | N | ops/sec | transient/op | retained/op |
442
+ |-------------------------------------------------------------------|--------:|----------:|-------------:|------------:|
443
+ | **A) virtualAxis: sub-row scroll** (1 px at a time, within a row) | 200K | **~3.6M** | **0 B** | 0 B |
444
+ | **B) virtualAxis: boundary crossing** (lands on a new row each tick) | 200K | ~2.0M | 0 B | 0 B |
445
+ | **C) variableAxis: sub-item scroll** (binary search converges) | 200K | **~2.6M** | **0 B** | 0 B |
446
+ | **D) variableAxis: boundary crossing** (binary search + chain) | 200K | ~1.5M | 0 B | 0 B |
447
+ | **E) mountList: realistic scroll on 1M-item list** | 50K | **~800K** | ~25 B | ~1 B |
448
+ | **F) Naive baseline: re-render every visible row on every scroll**| 50K | ~580K | ~60 B | 0 B |
449
+
450
+ **Headline:**
451
+
452
+ - **Sub-row scroll is genuinely free.** 3.6M ops/sec at 0 bytes/op transient
453
+ is the cutoff: Object.is sees the same integer firstItem and short-circuits
454
+ the entire chain. No effect re-runs, no DOM writes, no allocations.
455
+ - **Boundary crossing is ~2M/sec.** That's the cost of recomputing
456
+ `start`/`end`/`offsetStart` and propagating through one bind effect. For
457
+ 60Hz scrolling, you have a ~30,000× budget — boundary crossings are
458
+ essentially never the bottleneck.
459
+ - **mountList handles ~800K full scroll events/sec on a 1M-item list** in
460
+ pure Node. The DOM-write savings vs. a naive "render every visible row"
461
+ approach don't show clearly in a stub (no layout, no paint) — they appear
462
+ in the browser, where naive triggers ~21 style writes + reflow per scroll
463
+ event vs lite-virtual's ~1 write per boundary.
464
+ - **Pool size on 1M items: ~21 DOM nodes.** Not 1M. Not 100. Exactly the
465
+ visible window + 2× overscan.
466
+
467
+ > *Numbers vary ~10% run-to-run with GC timing. The bench file is
468
+ > `bench/bench.mjs`; copy it, modify, re-run.*
469
+
470
+ ---
471
+
472
+ ## Edge cases pinned down
473
+
474
+ - **iOS rubber-band scroll.** Negative `scrollTop` (overscroll up) pins
475
+ `firstItem` to 0; `start` and `offsetStart` stay at 0. No NaN renders.
476
+ - **Overscroll past the end.** `scrollTop > totalSize` pins `firstItem` to
477
+ `count`; `start` is `max(0, count - overscan)`, `end` is `count`. No crash.
478
+ - **Shrinking `count` mid-scroll.** Calling `setCount(smaller)` while
479
+ scrolled past the new end pins `start`/`end` to the new tail; the
480
+ scrollbar shrinks; no off-by-one renders.
481
+ - **Growing `count` mid-scroll.** `setCount(larger)` updates `totalSize`
482
+ but leaves the visible window where it was — the user's scroll position
483
+ is unchanged.
484
+ - **Viewport resize mid-scroll.** The library's `ResizeObserver` reads
485
+ `contentRect.height` and calls `setViewport`. The pool grows to fit;
486
+ it never shrinks (a `300 → 800 → 300` round-trip retains the larger pool
487
+ so the next grow is allocation-free).
488
+ - **`scope.dispose()` is complete.** Every signal, computed, effect,
489
+ listener, AND the renderer's `ResizeObserver` are disposed. Verified by
490
+ test suite (53 tests, including an explicit RO-leak audit).
491
+ - **Pool grows ONCE.** On first scroll off the top, the upper overscan is
492
+ no longer clipped and the pool grows from `perView + overscan` to
493
+ `perView + 2 * overscan`. After that, the pool never grows during scroll.
494
+ - **No internal IntersectionObserver.** The library uses exactly one
495
+ `ResizeObserver` per renderer. External IO on the host can't trigger
496
+ any internal behavior — it observes `host`'s own `clientHeight`, which
497
+ the library does not depend on after mount.
498
+ - **Host becomes keyboard-focusable on mount.** Each renderer sets
499
+ `host.tabIndex = 0` so Arrow / PageUp / PageDown / Spacebar reach the
500
+ scroll container. If you already set a `tabIndex` (positive or zero),
501
+ the library leaves it alone. Pass `tabIndex = -1` after mount to opt out.
502
+ - **The spacer is layout-invariant.** Each renderer's internal spacer is
503
+ `position: absolute`, 1 px wide, `visibility: hidden`, `aria-hidden`. It
504
+ drives `scrollHeight` via an explicit pixel height, independent of the
505
+ host's formatting context (flex, grid, block — all behave the same). No
506
+ margin collapse, no flex-row sideways layout, no sub-pixel rounding.
507
+
508
+ ---
509
+
510
+ ## What this is **not**
511
+
512
+ - **Not a measured / auto-height layer.** `mountVariableList` takes a
513
+ `sizeAt` you can answer. For "measure-then-correct" (heights unknown until
514
+ render), build it on top — the windowing/recycling math works verbatim;
515
+ only the offsets source becomes incremental. (Future
516
+ `@zakkster/lite-virtual-measure` package.)
517
+ - **Not a renderer per row.** Recycling renderers reuse a single `<div>`
518
+ pool. For per-row React/Vue/Solid components, build your own on top of
519
+ `virtualAxis` — the math is everything you need.
520
+ - **Not for non-scrolling layouts.** Windowing assumes a scroll container.
521
+ CSS Grid auto-fill, marquee, parallax-without-scroll — different problem.
522
+ - **Not a framework wrapper.** The recycling renderers expect a lite-element
523
+ scope (`effect`, `on`, `onCleanup`). For other reactive systems, write
524
+ a 20-line adapter or use the math directly.
525
+
526
+ ---
527
+
528
+ ## Browser / runtime support
529
+
530
+ | target | works | notes |
531
+ |------------------|:-----:|------------------------------------------|
532
+ | Node ≥ 18 | ✅ | math only — renderers need a DOM |
533
+ | Chrome / Edge | ✅ | `ResizeObserver` shipped 64 |
534
+ | Firefox | ✅ | RO shipped 69 |
535
+ | Safari ≥ 13.1 | ✅ | RO shipped 13.1 |
536
+ | Deno / Bun | ✅ (math) | ditto Node |
537
+
538
+ ESM only. No CJS build. If you need CJS, bundle through esbuild/rollup.
539
+
540
+ ---
541
+
542
+ ## Peer dependency
543
+
544
+ ```json
545
+ "peerDependencies": { "@zakkster/lite-signal": "^1.1.3" }
546
+ ```
547
+
548
+ The recycling renderers also assume a lite-element-shaped mount scope
549
+ (`effect`, `on`, `onCleanup`). `@zakkster/lite-element ^1.0.0` provides it;
550
+ a 20-line adapter wraps any other reactive system.
551
+
552
+ ---
553
+
554
+ ## FAQ
555
+
556
+ **Q: Why is `mountList` index-based instead of always keyed?**
557
+ Index-based recycling is O(1) per boundary crossing with zero allocation —
558
+ the same `<div>` is reused across logical indices. That's the cheapest
559
+ possible scroll path. The trade-off is statelessness: DOM state would
560
+ follow the node, not the row. For static lists (text, derived display),
561
+ that's a non-issue; use `mountList`. For inputs/media/expansion state, use
562
+ `mountKeyedList` and pay the create/remove cost (still 1 per boundary).
563
+
564
+ **Q: Will my list freeze when the spacer gets huge?**
565
+ No. Browsers handle `style.height = "30000000px"` fine — the scrollbar maps
566
+ to the value, but no layout cost is proportional to the spacer's pixel
567
+ size. We've tested up to `count * itemSize = 30M px` (1M items × 30px)
568
+ without issue.
569
+
570
+ **Q: Can I scroll to a specific index?**
571
+ Set `host.scrollTop = index * itemHeight` (fixed) or
572
+ `host.scrollTop = axis.positionAt(index)` (variable). The scroll event
573
+ fires, the axis advances, the renderer follows.
574
+
575
+ **Q: What's the difference between `overscan` and the +1 in `perView`?**
576
+ The `+1` is for the partial row at the bottom edge of the viewport that's
577
+ always visible. `overscan` adds buffer rows on EACH side so scrolling
578
+ doesn't reveal blank space during the brief moment between scroll event
579
+ and effect re-run. Three rows on each side is plenty for most workloads.
580
+
581
+ **Q: My rows have `<input>` and editing causes them to lose focus when I scroll.**
582
+ You're using `mountList` (index-based recycling) with stateful rows. Switch
583
+ to `mountKeyedList` — the node stays with the data row across scrolls.
584
+
585
+ **Q: How do I add sticky headers / sub-headers / dividers?**
586
+ Compose: the axis only knows about uniform rows. Add a sticky header
587
+ outside the scroll container, or use `mountVariableList` with the divider
588
+ rows having a height. For row-spanning headers inside the list, your
589
+ `render` callback can switch DOM based on `data[i].kind`.
590
+
591
+ **Q: Why isn't there a "smooth scroll to index" helper?**
592
+ Because the browser already has `host.scrollTo({ top, behavior: "smooth" })`.
593
+ Call that with `axis.positionAt(index)` and you're done.
594
+
595
+ **Q: Is variable axis async-safe? What if `sizeAt` throws?**
596
+ `sizeAt` is called synchronously inside `build()` and (for the renderer)
597
+ on render. If it throws, build throws; no inconsistent state is committed.
598
+ Don't make `sizeAt` do anything fancier than an array lookup.
599
+
600
+ **Q: Can I use this with React / Vue / Svelte?**
601
+ The pure-math axes (`virtualAxis`, `virtualGrid`, `variableAxis`) — yes,
602
+ trivially. Subscribe to `start` / `end` from your framework's reactive
603
+ system. The recycling renderers — only if you can supply a lite-element-
604
+ shaped scope; for that, a 20-line adapter is straightforward.
605
+
606
+ ---
607
+
608
+ ## License
609
+
610
+ MIT © Zahary Shinikchiev
611
+
612
+ ---
613
+
614
+ #### The @zakkster stack
615
+
616
+ - [@zakkster/lite-signal](https://www.npmjs.com/package/@zakkster/lite-signal) — the reactive primitives this all builds on
617
+ - [@zakkster/lite-element](https://www.npmjs.com/package/@zakkster/lite-element) — Custom Elements with state that survives reparents
618
+ - [@zakkster/lite-time](https://www.npmjs.com/package/@zakkster/lite-time) — drift-corrected wall-clock cadence
619
+ - [@zakkster/lite-form](https://www.npmjs.com/package/@zakkster/lite-form) — headless reactive forms
620
+ - **@zakkster/lite-virtual** — *this package*