@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 +21 -0
- package/README.md +620 -0
- package/Virtual.d.ts +218 -0
- package/Virtual.js +447 -0
- package/llms.txt +244 -0
- package/package.json +86 -0
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
|
+
[](https://www.npmjs.com/package/@zakkster/lite-virtual)
|
|
4
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
5
|
+
[](#why-this-exists)
|
|
6
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-resource)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-virtual)
|
|
8
|
+
[](https://www.npmjs.com/package/@zakkster/lite-virtual)
|
|
9
|
+

|
|
10
|
+
[](https://github.com/PeshoVurtoleta/lite-signal)
|
|
11
|
+
[](./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*
|