@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/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
+ }