@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/llms.txt
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# @zakkster/lite-virtual
|
|
2
|
+
|
|
3
|
+
Thrash-free list/grid windowing on `@zakkster/lite-signal`. The visible range
|
|
4
|
+
derives from `floor(scrollPos / itemSize)` (or a binary search for variable
|
|
5
|
+
sizes), so it changes ONLY when you cross a row/column boundary — not on
|
|
6
|
+
every scroll pixel. Object.is cutoff then halts propagation between
|
|
7
|
+
boundaries, so a fast scroll inside one row does zero reactive work and
|
|
8
|
+
writes nothing to the DOM.
|
|
9
|
+
|
|
10
|
+
Two layers:
|
|
11
|
+
• Pure math axes — headless reactive state, pair with any renderer.
|
|
12
|
+
• Recycling renderers — opinionated, lite-element-shaped, built on the math.
|
|
13
|
+
|
|
14
|
+
ESM-only. Node ≥ 18. Peer dep: `@zakkster/lite-signal ^1.1.3`. The recycling
|
|
15
|
+
renderers also need a lite-element-shaped `scope` (`effect`, `on`, `onCleanup`).
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
npm i @zakkster/lite-virtual @zakkster/lite-signal
|
|
20
|
+
|
|
21
|
+
## Imports
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
virtualAxis, virtualGrid, variableAxis,
|
|
25
|
+
mountList, mountKeyedList, mountGrid, mountVariableList,
|
|
26
|
+
} from "@zakkster/lite-virtual";
|
|
27
|
+
|
|
28
|
+
## Pure-math exports
|
|
29
|
+
|
|
30
|
+
### `virtualAxis({ count, itemSize, viewport, overscan = 3 }) → VirtualAxis`
|
|
31
|
+
|
|
32
|
+
Fixed-size 1-D windowing. Returns:
|
|
33
|
+
|
|
34
|
+
scrollPos WritableSignal<number> current scroll px (set integer-truncates)
|
|
35
|
+
start ReadSignal<number> first visible index (incl.) — Object.is gated
|
|
36
|
+
end ReadSignal<number> last visible index (excl.)
|
|
37
|
+
offsetStart ReadSignal<number> px offset of start (= start * itemSize)
|
|
38
|
+
totalSize ReadSignal<number> total scroll length (= count * itemSize)
|
|
39
|
+
setScroll(px) setter
|
|
40
|
+
setViewport(px) setter
|
|
41
|
+
setCount(n) setter
|
|
42
|
+
|
|
43
|
+
Sub-row scrolling produces zero downstream work: firstItem is integer, so
|
|
44
|
+
Object.is short-circuits start/end/offsetStart between boundary crossings.
|
|
45
|
+
Overscroll is pinned at both ends: negative scrollPos pins firstItem to 0;
|
|
46
|
+
scrolling past the end pins it to count.
|
|
47
|
+
|
|
48
|
+
### `virtualGrid({ rowCount, colCount, rowHeight, colWidth, viewportHeight, viewportWidth, overscan = 2 }) → VirtualGrid`
|
|
49
|
+
|
|
50
|
+
2-D windowing. Independent row + column axes share the same math.
|
|
51
|
+
Returns: `rowStart`, `rowEnd`, `rowOffset`, `totalHeight`, `colStart`,
|
|
52
|
+
`colEnd`, `colOffset`, `totalWidth`, plus `setScroll(left, top)`,
|
|
53
|
+
`setViewport(w, h)`, `setCounts(rc, cc)`. Scrolling within a cell does
|
|
54
|
+
nothing; scrolling along one axis does not retrigger the other.
|
|
55
|
+
|
|
56
|
+
### `variableAxis({ count, sizeAt, viewport, overscan = 3 }) → VariableAxis`
|
|
57
|
+
|
|
58
|
+
Variable-size 1-D windowing. Prefix sum is built once at construction
|
|
59
|
+
(O(n)); each scroll position is found by binary search (O(log n)). The
|
|
60
|
+
no-thrash property carries over: firstItem is still an integer, so
|
|
61
|
+
Object.is gates downstream work the same way.
|
|
62
|
+
|
|
63
|
+
Same surface as `virtualAxis`, PLUS:
|
|
64
|
+
|
|
65
|
+
positionAt(i) number O(1) pixel offset of item i
|
|
66
|
+
minSize() number smallest row at build time (for pool pre-sizing)
|
|
67
|
+
remeasure() rebuild prefix sums from current sizeAt
|
|
68
|
+
setCount(n) grows the offset array
|
|
69
|
+
|
|
70
|
+
Not handled: measured / auto-height rows whose size is unknown until
|
|
71
|
+
rendered. Build that on top by maintaining your own `sizes` array and
|
|
72
|
+
calling `remeasure()` after each ResizeObserver-driven measurement.
|
|
73
|
+
|
|
74
|
+
## Renderer exports
|
|
75
|
+
|
|
76
|
+
All renderers take `(host, scope, options)` and return the underlying axis.
|
|
77
|
+
The `scope` is lite-element's mount scope shape:
|
|
78
|
+
|
|
79
|
+
{ effect(fn) → unsub,
|
|
80
|
+
on(el, type, handler, opts?) → unsub,
|
|
81
|
+
onCleanup(fn) → void }
|
|
82
|
+
|
|
83
|
+
### `mountList(host, scope, { count, itemHeight, viewport, overscan = 3, render })`
|
|
84
|
+
|
|
85
|
+
Index-based recycling list. Pool size is bounded by `perView + 2*overscan`.
|
|
86
|
+
A scroll re-renders only indices that newly entered the window; the rest
|
|
87
|
+
keep their last render. Stateless rows only (a node is reused across logical
|
|
88
|
+
indices; DOM state would follow the node, not the row).
|
|
89
|
+
|
|
90
|
+
### `mountKeyedList(host, scope, { items, itemHeight, viewport, overscan = 3, key, render })`
|
|
91
|
+
|
|
92
|
+
Keyed renderer for STATEFUL rows (inputs, media, expanded panels). A node
|
|
93
|
+
is created when its key enters the window and removed when it leaves; the
|
|
94
|
+
node's identity stays with the data key for its lifetime. Reorder moves
|
|
95
|
+
nodes by transform, render() is NOT called again.
|
|
96
|
+
|
|
97
|
+
`items` is a reactive accessor (`() => yourSignal()`) — the renderer
|
|
98
|
+
re-reads it when its dependencies change.
|
|
99
|
+
|
|
100
|
+
### `mountGrid(host, scope, { rowCount, colCount, rowHeight, colWidth, viewportWidth, viewportHeight, overscan = 2, render })`
|
|
101
|
+
|
|
102
|
+
2-D recycling grid. Pool is pre-sized to max window so scrolling never
|
|
103
|
+
reshuffles the modulo mapping. Crossing a row boundary re-renders one new
|
|
104
|
+
row of cells; crossing a column boundary one new column. Stateless cells.
|
|
105
|
+
|
|
106
|
+
### `mountVariableList(host, scope, { count, sizeAt, viewport, overscan = 3, render })`
|
|
107
|
+
|
|
108
|
+
Variable-height list. Pool is pre-sized from the smallest row, so the
|
|
109
|
+
visible count never exceeds it. Stateless rows only.
|
|
110
|
+
|
|
111
|
+
## Key invariants
|
|
112
|
+
|
|
113
|
+
• **Boundary-only updates.** Scrolling within a row writes nothing to the
|
|
114
|
+
DOM and allocates no transient bytes (~3.6M sub-row scrolls/sec in JS).
|
|
115
|
+
• **Bounded pool.** Renderer DOM node count is `perView + 2*overscan`,
|
|
116
|
+
regardless of how big `count` gets. Pool grows once on first off-top
|
|
117
|
+
scroll (where overscan above is no longer clipped), then stays put.
|
|
118
|
+
• **Stateless rows for mountList / mountGrid / mountVariableList.** Nodes
|
|
119
|
+
are recycled by index slot; per-node DOM state (input focus, video
|
|
120
|
+
playback, expanded accordion) would follow the node, not the row data.
|
|
121
|
+
Use mountKeyedList for stateful content.
|
|
122
|
+
• **Render is called once per index entering the window.** On a
|
|
123
|
+
one-row scroll, exactly one render call fires. On a jump, exactly
|
|
124
|
+
`(new window) - (old window ∩ new window)` calls fire.
|
|
125
|
+
• **Observer-safe isolation.** Each renderer sets `host.style.overflow =
|
|
126
|
+
"auto"` and `host.style.position = "relative"`, so the giant inner
|
|
127
|
+
spacer scrolls INSIDE the host and never inflates the host's reported
|
|
128
|
+
size to outer observers (IntersectionObserver / ResizeObserver / flex
|
|
129
|
+
parents). The library's own ResizeObserver reads `contentRect.height`,
|
|
130
|
+
never `scrollHeight` — no feedback loop is reachable.
|
|
131
|
+
• **No internal IntersectionObserver.** The library uses ONLY a single
|
|
132
|
+
ResizeObserver per renderer to keep `viewport` synced. IO is your
|
|
133
|
+
business and won't see anything inflated.
|
|
134
|
+
• **scope.dispose() is complete.** The ResizeObserver disconnects, all
|
|
135
|
+
effects stop, all listeners detach. Verified by test suite.
|
|
136
|
+
• **Host becomes keyboard-focusable.** Renderers set `host.tabIndex = 0`
|
|
137
|
+
only if the caller hasn't set one. Arrow / PageUp / PageDown / Space
|
|
138
|
+
work out of the box. Set `tabIndex` before calling mount* to opt in
|
|
139
|
+
with a different value, or `host.tabIndex = -1` after mount to opt out.
|
|
140
|
+
• **Internal spacer is layout-invariant.** Each renderer's spacer is
|
|
141
|
+
`position: absolute`, 1 px wide, `visibility: hidden`, `aria-hidden`.
|
|
142
|
+
It drives `scrollHeight` through an explicit pixel height regardless
|
|
143
|
+
of the host's parent formatting context (flex / grid / block). No
|
|
144
|
+
margin collapse, no flex sideways layout, no sub-pixel rounding.
|
|
145
|
+
|
|
146
|
+
## Recipes
|
|
147
|
+
|
|
148
|
+
### Massive flat list
|
|
149
|
+
|
|
150
|
+
import { mount } from "@zakkster/lite-element";
|
|
151
|
+
import { mountList } from "@zakkster/lite-virtual";
|
|
152
|
+
|
|
153
|
+
mount(document.getElementById("box"), (host, scope) => {
|
|
154
|
+
mountList(host, scope, {
|
|
155
|
+
count: 1_000_000,
|
|
156
|
+
itemHeight: 32,
|
|
157
|
+
viewport: host.clientHeight,
|
|
158
|
+
render: (rowEl, i) => { rowEl.textContent = `row ${i}`; },
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
### Stateful list (forms, media, expansion)
|
|
163
|
+
|
|
164
|
+
Use the keyed renderer so each row's DOM state stays with its data.
|
|
165
|
+
|
|
166
|
+
mountKeyedList(host, scope, {
|
|
167
|
+
items: () => rows(), // reactive accessor
|
|
168
|
+
itemHeight: 48,
|
|
169
|
+
viewport: host.clientHeight,
|
|
170
|
+
key: (item) => item.id,
|
|
171
|
+
render: (rowEl, item, index) => {
|
|
172
|
+
// bind ONCE; this node is item.id's home for its lifetime
|
|
173
|
+
rowEl.innerHTML = `<input value="${item.draft}">`;
|
|
174
|
+
const inp = rowEl.firstChild;
|
|
175
|
+
inp.addEventListener("input", (ev) => item.draft = ev.target.value);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
### 2-D data grid
|
|
180
|
+
|
|
181
|
+
mountGrid(host, scope, {
|
|
182
|
+
rowCount: 50_000, colCount: 500,
|
|
183
|
+
rowHeight: 28, colWidth: 120,
|
|
184
|
+
viewportWidth: host.clientWidth,
|
|
185
|
+
viewportHeight: host.clientHeight,
|
|
186
|
+
render: (cellEl, row, col) => {
|
|
187
|
+
cellEl.textContent = data[row][col];
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
### Variable-height rows from a known sizes array
|
|
192
|
+
|
|
193
|
+
const sizes = computeSizes(items);
|
|
194
|
+
mountVariableList(host, scope, {
|
|
195
|
+
count: items.length,
|
|
196
|
+
sizeAt: (i) => sizes[i],
|
|
197
|
+
viewport: host.clientHeight,
|
|
198
|
+
render: (rowEl, i) => { rowEl.textContent = items[i].title; },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
### Reading the axis externally (custom renderer)
|
|
202
|
+
|
|
203
|
+
const axis = virtualAxis({ count: 100_000, itemSize: 30, viewport: 400 });
|
|
204
|
+
host.addEventListener("scroll", () => axis.setScroll(host.scrollTop));
|
|
205
|
+
effect(() => {
|
|
206
|
+
// your own DOM/canvas drawing keyed on start/end
|
|
207
|
+
renderWindow(axis.start(), axis.end());
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
### Tail-following (chat / log)
|
|
211
|
+
|
|
212
|
+
const axis = mountList(host, scope, { count: messages.length, itemHeight, viewport, render });
|
|
213
|
+
effect(() => {
|
|
214
|
+
axis.setCount(messages.length); // reactive growth
|
|
215
|
+
if (isAtBottom) host.scrollTop = axis.totalSize();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
## Performance (Node 22 measured)
|
|
219
|
+
|
|
220
|
+
• virtualAxis sub-row scroll: ~3.6M ops/sec, 0 B/op transient
|
|
221
|
+
• virtualAxis boundary crossing: ~2.0M ops/sec
|
|
222
|
+
• variableAxis sub-item scroll: ~2.5M ops/sec, 0 B/op transient
|
|
223
|
+
• variableAxis boundary crossing: ~1.5M ops/sec (binary search)
|
|
224
|
+
• mountList realistic scroll (1M list): ~800K scroll events/sec
|
|
225
|
+
• Pool size on 1M items: ~21 DOM nodes
|
|
226
|
+
|
|
227
|
+
The DOM-write savings (the real point of windowing) are NOT measurable in
|
|
228
|
+
Node — there's no layout or paint. They surface in the browser as smooth 60
|
|
229
|
+
fps scroll on lists the browser can't even render statically.
|
|
230
|
+
|
|
231
|
+
## What this is NOT
|
|
232
|
+
|
|
233
|
+
• Not a measure-then-correct auto-height layer (variable axis takes sizes
|
|
234
|
+
you already know; rolling your own ResizeObserver loop on top is the
|
|
235
|
+
documented path).
|
|
236
|
+
• Not a renderer per row. The recycling renderers reuse nodes by index
|
|
237
|
+
slot; if you need per-row React/Vue/etc components you build your own
|
|
238
|
+
on top of `virtualAxis`.
|
|
239
|
+
• Not virtualization for non-scrolling layouts (CSS Grid auto-fill / etc).
|
|
240
|
+
Windowing assumes a scroll container.
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
MIT © Zahary Shinikchiev
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zakkster/lite-virtual",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Thrash-free list/grid windowing on @zakkster/lite-signal. Integer-gated reactive indices + Object.is cutoff = scrolling within a row writes zero bytes to the DOM. ~3.6M sub-row scrolls/sec, bounded pool regardless of count, fixed and variable heights, 2-D grid, keyed renderer for stateful rows.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"virtual",
|
|
7
|
+
"virtualization",
|
|
8
|
+
"virtualized",
|
|
9
|
+
"windowing",
|
|
10
|
+
"windowed",
|
|
11
|
+
"list",
|
|
12
|
+
"grid",
|
|
13
|
+
"scroll",
|
|
14
|
+
"scrolling",
|
|
15
|
+
"infinite-scroll",
|
|
16
|
+
"recycler",
|
|
17
|
+
"reactive",
|
|
18
|
+
"signal",
|
|
19
|
+
"signals",
|
|
20
|
+
"lite-signal",
|
|
21
|
+
"lite-element",
|
|
22
|
+
"fine-grained",
|
|
23
|
+
"cutoff",
|
|
24
|
+
"zero-gc",
|
|
25
|
+
"zero-allocation",
|
|
26
|
+
"react-window",
|
|
27
|
+
"react-virtual",
|
|
28
|
+
"tanstack-virtual",
|
|
29
|
+
"alternative",
|
|
30
|
+
"headless",
|
|
31
|
+
"framework-free",
|
|
32
|
+
"esm",
|
|
33
|
+
"typescript",
|
|
34
|
+
"tree-shakeable",
|
|
35
|
+
"zakkster"
|
|
36
|
+
],
|
|
37
|
+
"type": "module",
|
|
38
|
+
"main": "./Virtual.js",
|
|
39
|
+
"module": "./Virtual.js",
|
|
40
|
+
"types": "./Virtual.d.ts",
|
|
41
|
+
"exports": {
|
|
42
|
+
".": {
|
|
43
|
+
"node": "./Virtual.js",
|
|
44
|
+
"import": "./Virtual.js",
|
|
45
|
+
"types": "./Virtual.d.ts",
|
|
46
|
+
"default": "./Virtual.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"Virtual.js",
|
|
51
|
+
"Virtual.d.ts",
|
|
52
|
+
"README.md",
|
|
53
|
+
"llms.txt",
|
|
54
|
+
"LICENSE.txt"
|
|
55
|
+
],
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@zakkster/lite-signal": "^1.1.3"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@zakkster/lite-signal": "^1.1.3"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"test": "node --test 'test/*.test.js'",
|
|
64
|
+
"test:watch": "node --test --watch 'test/*.test.js'",
|
|
65
|
+
"bench": "node --expose-gc bench/bench.mjs",
|
|
66
|
+
"verify": "npm test && npm run bench"
|
|
67
|
+
},
|
|
68
|
+
"author": "Zahary Shinikchiev",
|
|
69
|
+
"license": "MIT",
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=18"
|
|
72
|
+
},
|
|
73
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-virtual",
|
|
74
|
+
"repository": {
|
|
75
|
+
"type": "git",
|
|
76
|
+
"url": "https://github.com/PeshoVurtoleta/lite-virtual.git"
|
|
77
|
+
},
|
|
78
|
+
"bugs": {
|
|
79
|
+
"url": "https://github.com/PeshoVurtoleta/lite-virtual/issues"
|
|
80
|
+
},
|
|
81
|
+
"funding": {
|
|
82
|
+
"type": "github",
|
|
83
|
+
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
84
|
+
},
|
|
85
|
+
"sideEffects": false
|
|
86
|
+
}
|