@pyreon/virtual 0.43.1 → 0.45.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/README.md +61 -23
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +82 -28
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +53 -1
- package/package.json +12 -8
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ Pyreon adapter for TanStack Virtual — efficient rendering of very large lists.
|
|
|
4
4
|
|
|
5
5
|
`@pyreon/virtual` wraps `@tanstack/virtual-core` so a Pyreon app can render 10k+ items by only drawing the slice in the viewport. `useVirtualizer` is for element-scoped scrolling (an inner scroll container); `useWindowVirtualizer` is for window-scoped scrolling and is SSR-safe. Both take **options as a function** so reactive signals (count, estimateSize, scrollElement ref) trigger automatic recalculation. The exposed reactive surface — `virtualItems`, `totalSize`, `isScrolling` — is updated in a single `batch()` so consumers don't see torn state mid-scroll.
|
|
6
6
|
|
|
7
|
+
Because Pyreon renders without a virtual DOM, rendering rows with a keyed `<For by={row => row.index}>` means a scroll **patches only the entering and leaving rows** — the rows that stay in the window do zero work. A virtual-DOM adapter (e.g. `@tanstack/react-virtual`) instead re-renders its virtualizer component and reconciles the whole visible window on every scroll. Both wrap the identical `@tanstack/virtual-core` engine, so the difference is purely the adapter. See `bench/virtual-bench.ts` (`bun run bench:react-virtual`) for the head-to-head.
|
|
8
|
+
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```bash
|
|
@@ -14,6 +16,7 @@ bun add @pyreon/virtual @pyreon/core @pyreon/reactivity
|
|
|
14
16
|
## Quick start (element-scoped)
|
|
15
17
|
|
|
16
18
|
```tsx
|
|
19
|
+
import { For } from '@pyreon/core'
|
|
17
20
|
import { signal } from '@pyreon/reactivity'
|
|
18
21
|
import { useVirtualizer } from '@pyreon/virtual'
|
|
19
22
|
|
|
@@ -31,13 +34,19 @@ function VirtualList() {
|
|
|
31
34
|
return () => (
|
|
32
35
|
<div ref={(el) => parentRef.set(el)} style="height: 400px; overflow-y: auto;">
|
|
33
36
|
<div style={`height: ${totalSize()}px; position: relative;`}>
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
{/* Keyed <For> → staying rows are reused; only entering/leaving rows touch the DOM.
|
|
38
|
+
Fixed-size list: read the captured row.start directly (it's invariant per index). */}
|
|
39
|
+
<For each={() => virtualItems()} by={(row) => row.index}>
|
|
40
|
+
{(row) => (
|
|
41
|
+
<div
|
|
42
|
+
style={() =>
|
|
43
|
+
`position: absolute; top: 0; width: 100%; height: ${row.size}px; transform: translateY(${row.start}px);`
|
|
44
|
+
}
|
|
45
|
+
>
|
|
46
|
+
{items[row.index]}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</For>
|
|
41
50
|
</div>
|
|
42
51
|
</div>
|
|
43
52
|
)
|
|
@@ -56,6 +65,7 @@ Returns `UseVirtualizerResult`:
|
|
|
56
65
|
| `virtualItems` | `Signal<VirtualItem[]>` | Visible items |
|
|
57
66
|
| `totalSize` | `Signal<number>` | Total scrollable size (px) |
|
|
58
67
|
| `isScrolling` | `Signal<boolean>` | Active scroll |
|
|
68
|
+
| `item` | `(index) => { start(); size(); lane() }` | Fine-grained per-index measurement accessors (dynamic sizing; zero-cost until used) |
|
|
59
69
|
|
|
60
70
|
```ts
|
|
61
71
|
const parentRef = signal<HTMLDivElement | null>(null)
|
|
@@ -88,13 +98,17 @@ function WindowList() {
|
|
|
88
98
|
|
|
89
99
|
return () => (
|
|
90
100
|
<div style={`height: ${totalSize()}px; position: relative;`}>
|
|
91
|
-
{virtualItems()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
<For each={() => virtualItems()} by={(row) => row.index}>
|
|
102
|
+
{(row) => (
|
|
103
|
+
<div
|
|
104
|
+
style={() =>
|
|
105
|
+
`position: absolute; top: 0; width: 100%; height: ${row.size}px; transform: translateY(${row.start}px);`
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
{items[row.index]}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</For>
|
|
98
112
|
</div>
|
|
99
113
|
)
|
|
100
114
|
}
|
|
@@ -102,28 +116,49 @@ function WindowList() {
|
|
|
102
116
|
|
|
103
117
|
## Patterns
|
|
104
118
|
|
|
105
|
-
### Dynamic item sizes via `measureElement`
|
|
119
|
+
### Dynamic item sizes via `measureElement` (use `item()`)
|
|
106
120
|
|
|
107
|
-
For variable-height items
|
|
121
|
+
For variable-height items measured after render, use the fine-grained per-index
|
|
122
|
+
`item(index)` accessor for positioning — **not** the captured `<For>` row.
|
|
123
|
+
|
|
124
|
+
Why: with a keyed `<For by={row => row.index}>`, a row that stays in the window is
|
|
125
|
+
**not re-rendered** when a remeasure above it shifts its position. The captured
|
|
126
|
+
`row.start` is therefore a stale snapshot for dynamic lists. `item(row.index).start()`
|
|
127
|
+
is a signal the adapter updates in place, so a staying row re-positions correctly —
|
|
128
|
+
and only the rows that actually moved patch the DOM (a virtual-DOM adapter re-renders
|
|
129
|
+
the whole visible window). `item()` costs nothing until the first call.
|
|
108
130
|
|
|
109
131
|
```tsx
|
|
132
|
+
import { For } from '@pyreon/core'
|
|
110
133
|
import { measureElement } from '@pyreon/virtual'
|
|
111
134
|
|
|
112
|
-
const
|
|
135
|
+
const v = useVirtualizer(() => ({
|
|
113
136
|
count: items.length,
|
|
114
137
|
getScrollElement: () => parentRef(),
|
|
115
138
|
estimateSize: () => 50,
|
|
116
139
|
measureElement,
|
|
117
140
|
}))
|
|
118
141
|
|
|
119
|
-
// Per row:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
142
|
+
// Per row — position from item(), measure via the ref:
|
|
143
|
+
<For each={() => v.virtualItems()} by={(row) => row.index}>
|
|
144
|
+
{(row) => {
|
|
145
|
+
const m = v.item(row.index) // reactive per-index start/size/lane
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
data-index={row.index}
|
|
149
|
+
ref={(el) => el && v.instance.measureElement(el)}
|
|
150
|
+
style={() => `position: absolute; top: 0; width: 100%; transform: translateY(${m.start()}px);`}
|
|
151
|
+
>
|
|
152
|
+
{items[row.index]}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}}
|
|
156
|
+
</For>
|
|
125
157
|
```
|
|
126
158
|
|
|
159
|
+
> **Fixed-size lists don't need `item()`** — `row.start = index * size` is invariant, so
|
|
160
|
+
> reading the captured `row.start` is correct and marginally cheaper at mount.
|
|
161
|
+
|
|
127
162
|
### Horizontal lists
|
|
128
163
|
|
|
129
164
|
```ts
|
|
@@ -157,6 +192,9 @@ const { virtualItems } = useVirtualizer(() => ({
|
|
|
157
192
|
|
|
158
193
|
## Gotchas
|
|
159
194
|
|
|
195
|
+
- **Render rows with a keyed `<For by={row => row.index}>`, not `.map()`** — `.map()` in a reactive child re-mounts every visible row on each scroll; a keyed `<For>` reuses staying rows so only entering/leaving rows touch the DOM.
|
|
196
|
+
- **Dynamic (`measureElement`) lists: position from `item(row.index).start()`, not the captured `row.start`** — a staying row isn't re-rendered when a remeasure shifts it, so the captured value goes stale. Fixed-size lists can read `row.start` directly.
|
|
197
|
+
- **A `styled()` scroll container needs `ref`, not `innerRef`** — a `styled()` component forwards plain `ref` to its DOM node; `innerRef` is a silent no-op there, so `getScrollElement()` returns `null` and the list renders **zero** rows (`totalSize` still looks correct). `@pyreon/elements`' `Element` is the only component that treats `innerRef` as first-class.
|
|
160
198
|
- **Options must be a function** `() => opts` for reactive tracking. Reading signals inside is the mechanism for live recalculation.
|
|
161
199
|
- **`instance` is the raw TanStack Virtualizer** — use it for imperative methods (`scrollToIndex`, `scrollToOffset`, `getVirtualItemForOffset`). The signals are the reactive subset.
|
|
162
200
|
- **Signals update via `batch()`** — `virtualItems`, `totalSize`, and `isScrolling` flip together; consumers don't see torn state mid-scroll frame.
|
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"d8a71704-1","name":"item-registry.ts"},{"uid":"d8a71704-3","name":"use-virtualizer.ts"},{"uid":"d8a71704-5","name":"use-window-virtualizer.ts"},{"uid":"d8a71704-7","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"d8a71704-1":{"renderedLength":1241,"gzipLength":561,"brotliLength":0,"metaUid":"d8a71704-0"},"d8a71704-3":{"renderedLength":1853,"gzipLength":785,"brotliLength":0,"metaUid":"d8a71704-2"},"d8a71704-5":{"renderedLength":1616,"gzipLength":677,"brotliLength":0,"metaUid":"d8a71704-4"},"d8a71704-7":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"d8a71704-6"}},"nodeMetas":{"d8a71704-0":{"id":"/src/item-registry.ts","moduleParts":{"index.js":"d8a71704-1"},"imported":[{"uid":"d8a71704-10"}],"importedBy":[{"uid":"d8a71704-2"},{"uid":"d8a71704-4"}]},"d8a71704-2":{"id":"/src/use-virtualizer.ts","moduleParts":{"index.js":"d8a71704-3"},"imported":[{"uid":"d8a71704-9"},{"uid":"d8a71704-10"},{"uid":"d8a71704-8"},{"uid":"d8a71704-0"}],"importedBy":[{"uid":"d8a71704-6"}]},"d8a71704-4":{"id":"/src/use-window-virtualizer.ts","moduleParts":{"index.js":"d8a71704-5"},"imported":[{"uid":"d8a71704-9"},{"uid":"d8a71704-10"},{"uid":"d8a71704-8"},{"uid":"d8a71704-0"}],"importedBy":[{"uid":"d8a71704-6"}]},"d8a71704-6":{"id":"/src/index.ts","moduleParts":{"index.js":"d8a71704-7"},"imported":[{"uid":"d8a71704-8"},{"uid":"d8a71704-2"},{"uid":"d8a71704-4"}],"importedBy":[],"isEntry":true},"d8a71704-8":{"id":"@tanstack/virtual-core","moduleParts":{},"imported":[],"importedBy":[{"uid":"d8a71704-6"},{"uid":"d8a71704-2"},{"uid":"d8a71704-4"}]},"d8a71704-9":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"d8a71704-2"},{"uid":"d8a71704-4"}]},"d8a71704-10":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"d8a71704-2"},{"uid":"d8a71704-4"},{"uid":"d8a71704-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -2,6 +2,58 @@ import { Virtualizer, Virtualizer as Virtualizer$1, defaultKeyExtractor, default
|
|
|
2
2
|
import { onMount, onUnmount } from "@pyreon/core";
|
|
3
3
|
import { batch, effect, signal } from "@pyreon/reactivity";
|
|
4
4
|
|
|
5
|
+
//#region src/item-registry.ts
|
|
6
|
+
/**
|
|
7
|
+
* Create the fine-grained per-item measurement registry shared by the element-
|
|
8
|
+
* and window-scoped virtualizers. Zero-cost until `item()` is first used.
|
|
9
|
+
*/
|
|
10
|
+
function createItemRegistry() {
|
|
11
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
12
|
+
const sigs = /* @__PURE__ */ new Map();
|
|
13
|
+
let active = false;
|
|
14
|
+
const sync = (items) => {
|
|
15
|
+
if (!active) return;
|
|
16
|
+
byIndex.clear();
|
|
17
|
+
batch(() => {
|
|
18
|
+
for (const it of items) {
|
|
19
|
+
byIndex.set(it.index, it);
|
|
20
|
+
const s = sigs.get(it.index);
|
|
21
|
+
if (s) {
|
|
22
|
+
if (s.start) s.start.set(it.start);
|
|
23
|
+
if (s.size) s.size.set(it.size);
|
|
24
|
+
if (s.lane) s.lane.set(it.lane);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
if (sigs.size > byIndex.size) {
|
|
29
|
+
for (const idx of sigs.keys()) if (!byIndex.has(idx)) sigs.delete(idx);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const item = (index) => {
|
|
33
|
+
active = true;
|
|
34
|
+
let s = sigs.get(index);
|
|
35
|
+
if (!s) {
|
|
36
|
+
s = {
|
|
37
|
+
start: null,
|
|
38
|
+
size: null,
|
|
39
|
+
lane: null
|
|
40
|
+
};
|
|
41
|
+
sigs.set(index, s);
|
|
42
|
+
}
|
|
43
|
+
const cur = s;
|
|
44
|
+
return {
|
|
45
|
+
start: () => (cur.start ??= signal(byIndex.get(index)?.start ?? 0))(),
|
|
46
|
+
size: () => (cur.size ??= signal(byIndex.get(index)?.size ?? 0))(),
|
|
47
|
+
lane: () => (cur.lane ??= signal(byIndex.get(index)?.lane ?? 0))()
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
sync,
|
|
52
|
+
item
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
5
57
|
//#region src/use-virtualizer.ts
|
|
6
58
|
/**
|
|
7
59
|
* Create a reactive TanStack Virtual virtualizer for element-based scrolling.
|
|
@@ -29,36 +81,36 @@ function useVirtualizer(options) {
|
|
|
29
81
|
const virtualItems = signal([]);
|
|
30
82
|
const totalSize = signal(0);
|
|
31
83
|
const isScrolling = signal(false);
|
|
84
|
+
const registry = createItemRegistry();
|
|
32
85
|
let latestUserOpts = options();
|
|
33
86
|
const instance = new Virtualizer$1(resolvedOptions);
|
|
87
|
+
const emit = () => {
|
|
88
|
+
batch(() => {
|
|
89
|
+
const items = instance.getVirtualItems();
|
|
90
|
+
virtualItems.set(items);
|
|
91
|
+
totalSize.set(instance.getTotalSize());
|
|
92
|
+
isScrolling.set(instance.isScrolling);
|
|
93
|
+
registry.sync(items);
|
|
94
|
+
});
|
|
95
|
+
};
|
|
34
96
|
const effectCleanup = effect(() => {
|
|
35
97
|
latestUserOpts = options();
|
|
36
98
|
instance.setOptions({
|
|
37
99
|
...instance.options,
|
|
38
100
|
...latestUserOpts,
|
|
39
101
|
onChange: (inst, sync) => {
|
|
40
|
-
|
|
41
|
-
virtualItems.set(inst.getVirtualItems());
|
|
42
|
-
totalSize.set(inst.getTotalSize());
|
|
43
|
-
isScrolling.set(inst.isScrolling);
|
|
44
|
-
});
|
|
102
|
+
emit();
|
|
45
103
|
latestUserOpts.onChange?.(inst, sync);
|
|
46
104
|
}
|
|
47
105
|
});
|
|
48
106
|
instance._willUpdate();
|
|
49
|
-
|
|
50
|
-
virtualItems.set(instance.getVirtualItems());
|
|
51
|
-
totalSize.set(instance.getTotalSize());
|
|
52
|
-
});
|
|
107
|
+
emit();
|
|
53
108
|
});
|
|
54
109
|
let mountCleanup;
|
|
55
110
|
onMount(() => {
|
|
56
111
|
mountCleanup = instance._didMount();
|
|
57
112
|
instance._willUpdate();
|
|
58
|
-
|
|
59
|
-
virtualItems.set(instance.getVirtualItems());
|
|
60
|
-
totalSize.set(instance.getTotalSize());
|
|
61
|
-
});
|
|
113
|
+
emit();
|
|
62
114
|
});
|
|
63
115
|
onUnmount(() => {
|
|
64
116
|
effectCleanup.dispose();
|
|
@@ -68,7 +120,8 @@ function useVirtualizer(options) {
|
|
|
68
120
|
instance,
|
|
69
121
|
virtualItems,
|
|
70
122
|
totalSize,
|
|
71
|
-
isScrolling
|
|
123
|
+
isScrolling,
|
|
124
|
+
item: registry.item
|
|
72
125
|
};
|
|
73
126
|
}
|
|
74
127
|
|
|
@@ -87,6 +140,7 @@ function useWindowVirtualizer(options) {
|
|
|
87
140
|
const virtualItems = signal([]);
|
|
88
141
|
const totalSize = signal(0);
|
|
89
142
|
const isScrolling = signal(false);
|
|
143
|
+
const registry = createItemRegistry();
|
|
90
144
|
const resolvedOptions = {
|
|
91
145
|
observeElementRect: observeWindowRect$1,
|
|
92
146
|
observeElementOffset: observeWindowOffset$1,
|
|
@@ -97,34 +151,33 @@ function useWindowVirtualizer(options) {
|
|
|
97
151
|
};
|
|
98
152
|
let latestUserOpts = options();
|
|
99
153
|
const instance = new Virtualizer$1(resolvedOptions);
|
|
154
|
+
const emit = () => {
|
|
155
|
+
batch(() => {
|
|
156
|
+
const items = instance.getVirtualItems();
|
|
157
|
+
virtualItems.set(items);
|
|
158
|
+
totalSize.set(instance.getTotalSize());
|
|
159
|
+
isScrolling.set(instance.isScrolling);
|
|
160
|
+
registry.sync(items);
|
|
161
|
+
});
|
|
162
|
+
};
|
|
100
163
|
const effectCleanup = effect(() => {
|
|
101
164
|
latestUserOpts = options();
|
|
102
165
|
instance.setOptions({
|
|
103
166
|
...instance.options,
|
|
104
167
|
...latestUserOpts,
|
|
105
168
|
onChange: (inst, sync) => {
|
|
106
|
-
|
|
107
|
-
virtualItems.set(inst.getVirtualItems());
|
|
108
|
-
totalSize.set(inst.getTotalSize());
|
|
109
|
-
isScrolling.set(inst.isScrolling);
|
|
110
|
-
});
|
|
169
|
+
emit();
|
|
111
170
|
latestUserOpts.onChange?.(inst, sync);
|
|
112
171
|
}
|
|
113
172
|
});
|
|
114
173
|
instance._willUpdate();
|
|
115
|
-
|
|
116
|
-
virtualItems.set(instance.getVirtualItems());
|
|
117
|
-
totalSize.set(instance.getTotalSize());
|
|
118
|
-
});
|
|
174
|
+
emit();
|
|
119
175
|
});
|
|
120
176
|
let mountCleanup;
|
|
121
177
|
onMount(() => {
|
|
122
178
|
mountCleanup = instance._didMount();
|
|
123
179
|
instance._willUpdate();
|
|
124
|
-
|
|
125
|
-
virtualItems.set(instance.getVirtualItems());
|
|
126
|
-
totalSize.set(instance.getTotalSize());
|
|
127
|
-
});
|
|
180
|
+
emit();
|
|
128
181
|
});
|
|
129
182
|
onUnmount(() => {
|
|
130
183
|
effectCleanup.dispose();
|
|
@@ -134,7 +187,8 @@ function useWindowVirtualizer(options) {
|
|
|
134
187
|
instance,
|
|
135
188
|
virtualItems,
|
|
136
189
|
totalSize,
|
|
137
|
-
isScrolling
|
|
190
|
+
isScrolling,
|
|
191
|
+
item: registry.item
|
|
138
192
|
};
|
|
139
193
|
}
|
|
140
194
|
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["elementScroll","Virtualizer","observeWindowRect","observeWindowOffset","windowScroll","Virtualizer"],"sources":["../src/use-virtualizer.ts","../src/use-window-virtualizer.ts"],"sourcesContent":["import { onMount, onUnmount } from '@pyreon/core'\nimport type { Signal } from '@pyreon/reactivity'\nimport { batch, effect, signal } from '@pyreon/reactivity'\nimport {\n elementScroll,\n observeElementOffset,\n observeElementRect,\n type VirtualItem,\n Virtualizer,\n type VirtualizerOptions,\n} from '@tanstack/virtual-core'\n\nexport type UseVirtualizerOptions<\n TScrollElement extends Element,\n TItemElement extends Element,\n> = () => Omit<\n VirtualizerOptions<TScrollElement, TItemElement>,\n 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'\n> &\n Partial<\n Pick<\n VirtualizerOptions<TScrollElement, TItemElement>,\n 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'\n >\n >\n\nexport interface UseVirtualizerResult<\n TScrollElement extends Element,\n TItemElement extends Element,\n> {\n /** The virtualizer instance — read to access all methods. */\n instance: Virtualizer<TScrollElement, TItemElement>\n /** Reactive signal of currently visible virtual items. */\n virtualItems: Signal<VirtualItem[]>\n /** Reactive signal of the total scrollable size in pixels. */\n totalSize: Signal<number>\n /** Reactive signal indicating whether the user is scrolling. */\n isScrolling: Signal<boolean>\n}\n\n/**\n * Create a reactive TanStack Virtual virtualizer for element-based scrolling.\n *\n * Options are passed as a function so reactive signals (e.g. count, estimateSize)\n * can be read inside, and the virtualizer updates automatically when they change.\n *\n * @example\n * const parentRef = signal<HTMLDivElement | null>(null)\n * const virtual = useVirtualizer(() => ({\n * count: 10000,\n * getScrollElement: () => parentRef(),\n * estimateSize: () => 35,\n * }))\n * // virtual.virtualItems() — array of visible VirtualItem\n * // virtual.totalSize() — total height/width for the inner container\n */\nexport function useVirtualizer<TScrollElement extends Element, TItemElement extends Element>(\n options: UseVirtualizerOptions<TScrollElement, TItemElement>,\n): UseVirtualizerResult<TScrollElement, TItemElement> {\n const resolvedOptions: VirtualizerOptions<TScrollElement, TItemElement> = {\n observeElementRect,\n observeElementOffset,\n scrollToFn: elementScroll,\n ...options(),\n }\n\n const virtualItems = signal<VirtualItem[]>([])\n const totalSize = signal(0)\n const isScrolling = signal(false)\n\n // Store latest user options so onChange always reads the freshest reference\n let latestUserOpts = options()\n\n const instance = new Virtualizer<TScrollElement, TItemElement>(resolvedOptions)\n\n // Track reactive options: when signals inside options() change, update the virtualizer.\n const effectCleanup = effect(() => {\n latestUserOpts = options()\n instance.setOptions({\n ...instance.options,\n ...latestUserOpts,\n onChange: (inst, sync) => {\n batch(() => {\n virtualItems.set(inst.getVirtualItems())\n totalSize.set(inst.getTotalSize())\n isScrolling.set(inst.isScrolling)\n })\n // Read latest opts to avoid stale closure\n latestUserOpts.onChange?.(inst, sync)\n },\n })\n\n // After updating options, recalculate and re-emit\n instance._willUpdate()\n batch(() => {\n virtualItems.set(instance.getVirtualItems())\n totalSize.set(instance.getTotalSize())\n })\n })\n\n // Lifecycle: mount observers, clean up on unmount.\n let mountCleanup: (() => void) | undefined\n onMount(() => {\n mountCleanup = instance._didMount()\n instance._willUpdate()\n batch(() => {\n virtualItems.set(instance.getVirtualItems())\n totalSize.set(instance.getTotalSize())\n })\n return undefined\n })\n\n onUnmount(() => {\n effectCleanup.dispose()\n mountCleanup?.()\n })\n\n return { instance, virtualItems, totalSize, isScrolling }\n}\n","import { onMount, onUnmount } from '@pyreon/core'\nimport type { Signal } from '@pyreon/reactivity'\nimport { batch, effect, signal } from '@pyreon/reactivity'\nimport {\n observeWindowOffset,\n observeWindowRect,\n type VirtualItem,\n Virtualizer,\n type VirtualizerOptions,\n windowScroll,\n} from '@tanstack/virtual-core'\n\nexport type UseWindowVirtualizerOptions<TItemElement extends Element> = () => Omit<\n VirtualizerOptions<Window, TItemElement>,\n 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' | 'getScrollElement'\n> &\n Partial<\n Pick<\n VirtualizerOptions<Window, TItemElement>,\n 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'\n >\n >\n\nexport interface UseWindowVirtualizerResult<TItemElement extends Element> {\n instance: Virtualizer<Window, TItemElement>\n virtualItems: Signal<VirtualItem[]>\n totalSize: Signal<number>\n isScrolling: Signal<boolean>\n}\n\n/**\n * Create a reactive TanStack Virtual virtualizer for window-based scrolling.\n *\n * @example\n * const virtual = useWindowVirtualizer(() => ({\n * count: 10000,\n * estimateSize: () => 35,\n * }))\n */\nexport function useWindowVirtualizer<TItemElement extends Element>(\n options: UseWindowVirtualizerOptions<TItemElement>,\n): UseWindowVirtualizerResult<TItemElement> {\n const virtualItems = signal<VirtualItem[]>([])\n const totalSize = signal(0)\n const isScrolling = signal(false)\n\n const resolvedOptions: VirtualizerOptions<Window, TItemElement> = {\n observeElementRect: observeWindowRect,\n observeElementOffset: observeWindowOffset,\n scrollToFn: windowScroll,\n initialOffset: typeof document !== 'undefined' ? window.scrollY : 0,\n getScrollElement: () => (typeof window !== 'undefined' ? window : (null as unknown as Window)),\n ...options(),\n }\n\n // Store latest user options so onChange always reads the freshest reference\n let latestUserOpts = options()\n\n const instance = new Virtualizer<Window, TItemElement>(resolvedOptions)\n\n const effectCleanup = effect(() => {\n latestUserOpts = options()\n instance.setOptions({\n ...instance.options,\n ...latestUserOpts,\n onChange: (inst, sync) => {\n batch(() => {\n virtualItems.set(inst.getVirtualItems())\n totalSize.set(inst.getTotalSize())\n isScrolling.set(inst.isScrolling)\n })\n // Read latest opts to avoid stale closure\n latestUserOpts.onChange?.(inst, sync)\n },\n })\n\n instance._willUpdate()\n batch(() => {\n virtualItems.set(instance.getVirtualItems())\n totalSize.set(instance.getTotalSize())\n })\n })\n\n let mountCleanup: (() => void) | undefined\n onMount(() => {\n mountCleanup = instance._didMount()\n instance._willUpdate()\n batch(() => {\n virtualItems.set(instance.getVirtualItems())\n totalSize.set(instance.getTotalSize())\n })\n return undefined\n })\n\n onUnmount(() => {\n effectCleanup.dispose()\n mountCleanup?.()\n })\n\n return { instance, virtualItems, totalSize, isScrolling }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAwDA,SAAgB,eACd,SACoD;CACpD,MAAM,kBAAoE;EACxE;EACA;EACA,YAAYA;EACZ,GAAG,QAAQ;CACb;CAEA,MAAM,eAAe,OAAsB,CAAC,CAAC;CAC7C,MAAM,YAAY,OAAO,CAAC;CAC1B,MAAM,cAAc,OAAO,KAAK;CAGhC,IAAI,iBAAiB,QAAQ;CAE7B,MAAM,WAAW,IAAIC,cAA0C,eAAe;CAG9E,MAAM,gBAAgB,aAAa;EACjC,iBAAiB,QAAQ;EACzB,SAAS,WAAW;GAClB,GAAG,SAAS;GACZ,GAAG;GACH,WAAW,MAAM,SAAS;IACxB,YAAY;KACV,aAAa,IAAI,KAAK,gBAAgB,CAAC;KACvC,UAAU,IAAI,KAAK,aAAa,CAAC;KACjC,YAAY,IAAI,KAAK,WAAW;IAClC,CAAC;IAED,eAAe,WAAW,MAAM,IAAI;GACtC;EACF,CAAC;EAGD,SAAS,YAAY;EACrB,YAAY;GACV,aAAa,IAAI,SAAS,gBAAgB,CAAC;GAC3C,UAAU,IAAI,SAAS,aAAa,CAAC;EACvC,CAAC;CACH,CAAC;CAGD,IAAI;CACJ,cAAc;EACZ,eAAe,SAAS,UAAU;EAClC,SAAS,YAAY;EACrB,YAAY;GACV,aAAa,IAAI,SAAS,gBAAgB,CAAC;GAC3C,UAAU,IAAI,SAAS,aAAa,CAAC;EACvC,CAAC;CAEH,CAAC;CAED,gBAAgB;EACd,cAAc,QAAQ;EACtB,eAAe;CACjB,CAAC;CAED,OAAO;EAAE;EAAU;EAAc;EAAW;CAAY;AAC1D;;;;;;;;;;;;;AC/EA,SAAgB,qBACd,SAC0C;CAC1C,MAAM,eAAe,OAAsB,CAAC,CAAC;CAC7C,MAAM,YAAY,OAAO,CAAC;CAC1B,MAAM,cAAc,OAAO,KAAK;CAEhC,MAAM,kBAA4D;EAChE,oBAAoBC;EACpB,sBAAsBC;EACtB,YAAYC;EACZ,eAAe,OAAO,aAAa,cAAc,OAAO,UAAU;EAClE,wBAAyB,OAAO,WAAW,cAAc,SAAU;EACnE,GAAG,QAAQ;CACb;CAGA,IAAI,iBAAiB,QAAQ;CAE7B,MAAM,WAAW,IAAIC,cAAkC,eAAe;CAEtE,MAAM,gBAAgB,aAAa;EACjC,iBAAiB,QAAQ;EACzB,SAAS,WAAW;GAClB,GAAG,SAAS;GACZ,GAAG;GACH,WAAW,MAAM,SAAS;IACxB,YAAY;KACV,aAAa,IAAI,KAAK,gBAAgB,CAAC;KACvC,UAAU,IAAI,KAAK,aAAa,CAAC;KACjC,YAAY,IAAI,KAAK,WAAW;IAClC,CAAC;IAED,eAAe,WAAW,MAAM,IAAI;GACtC;EACF,CAAC;EAED,SAAS,YAAY;EACrB,YAAY;GACV,aAAa,IAAI,SAAS,gBAAgB,CAAC;GAC3C,UAAU,IAAI,SAAS,aAAa,CAAC;EACvC,CAAC;CACH,CAAC;CAED,IAAI;CACJ,cAAc;EACZ,eAAe,SAAS,UAAU;EAClC,SAAS,YAAY;EACrB,YAAY;GACV,aAAa,IAAI,SAAS,gBAAgB,CAAC;GAC3C,UAAU,IAAI,SAAS,aAAa,CAAC;EACvC,CAAC;CAEH,CAAC;CAED,gBAAgB;EACd,cAAc,QAAQ;EACtB,eAAe;CACjB,CAAC;CAED,OAAO;EAAE;EAAU;EAAc;EAAW;CAAY;AAC1D"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["elementScroll","Virtualizer","observeWindowRect","observeWindowOffset","windowScroll","Virtualizer"],"sources":["../src/item-registry.ts","../src/use-virtualizer.ts","../src/use-window-virtualizer.ts"],"sourcesContent":["import { batch, type Signal, signal } from '@pyreon/reactivity'\nimport type { VirtualItem } from '@tanstack/virtual-core'\n\n/**\n * Reactive per-index measurement accessors for a single virtual item.\n *\n * Each field is a signal-backed accessor that gates on numeric equality — so a\n * row reading `start()`/`size()` re-runs (and patches the DOM) ONLY when THAT\n * item's measurement actually changes. A fixed-size scroll leaves a staying\n * row's `start` invariant (`index * size`), so its accessor never re-fires; a\n * dynamic remeasure that shifts a row's position fires only the shifted rows.\n */\nexport interface VirtualItemMeasurement {\n /** Reactive pixel offset of this item along the scroll axis. */\n start: () => number\n /** Reactive pixel size of this item along the scroll axis. */\n size: () => number\n /** Reactive lane index (masonry / multi-column). `0` for single-lane lists. */\n lane: () => number\n}\n\ninterface ItemSignals {\n // Per-field signals are created lazily on first read — a row that only reads\n // `start()` allocates one signal, not three. `null` = not yet observed.\n start: Signal<number> | null\n size: Signal<number> | null\n lane: Signal<number> | null\n}\n\nexport interface ItemRegistry {\n /**\n * Push the latest visible measurements into the per-index signals.\n *\n * No-op until `item()` has been called at least once — so the captured-item\n * fast path (fixed-size lists that read `<For>`'s item directly) pays ZERO\n * extra cost. Once active, each update sets the numeric signals (Object.is\n * gate → unchanged rows don't fire) and prunes signals for indices that left\n * the window (their rows unmounted), keeping the map bounded to the window.\n */\n sync: (items: VirtualItem[]) => void\n /**\n * Get reactive `start`/`size`/`lane` accessors for `index`. Stable for the\n * lifetime of the calling row. Use inside a row's style/position accessor for\n * dynamically-measured lists so a staying row re-positions when a remeasure\n * above it shifts its `start`.\n */\n item: (index: number) => VirtualItemMeasurement\n}\n\n/**\n * Create the fine-grained per-item measurement registry shared by the element-\n * and window-scoped virtualizers. Zero-cost until `item()` is first used.\n */\nexport function createItemRegistry(): ItemRegistry {\n const byIndex = new Map<number, VirtualItem>()\n const sigs = new Map<number, ItemSignals>()\n let active = false\n\n const sync = (items: VirtualItem[]): void => {\n if (!active) return\n byIndex.clear()\n // Batch every per-index write so all rows flip atomically (a no-op nest when\n // called from the virtualizer's already-batched emit; self-contained otherwise).\n batch(() => {\n for (const it of items) {\n byIndex.set(it.index, it)\n const s = sigs.get(it.index)\n if (s) {\n // Object.is-gated: only the observed fields that changed notify.\n if (s.start) s.start.set(it.start)\n if (s.size) s.size.set(it.size)\n if (s.lane) s.lane.set(it.lane)\n }\n }\n })\n // Prune signals for indices that left the window. A row exists iff its index\n // is in `items` (the <For> renders exactly the visible set), so any signal\n // whose index is now absent belongs to an unmounting row — dropping it keeps\n // the map bounded to the window (no full-scroll-through leak, class C).\n if (sigs.size > byIndex.size) {\n for (const idx of sigs.keys()) {\n if (!byIndex.has(idx)) sigs.delete(idx)\n }\n }\n }\n\n const item = (index: number): VirtualItemMeasurement => {\n active = true\n let s = sigs.get(index)\n if (!s) {\n s = { start: null, size: null, lane: null }\n sigs.set(index, s)\n }\n const cur = s\n // Each accessor lazily materializes its own signal on first read, seeded\n // from the freshest measurement at that moment.\n return {\n start: () => (cur.start ??= signal(byIndex.get(index)?.start ?? 0))(),\n size: () => (cur.size ??= signal(byIndex.get(index)?.size ?? 0))(),\n lane: () => (cur.lane ??= signal(byIndex.get(index)?.lane ?? 0))(),\n }\n }\n\n return { sync, item }\n}\n","import { onMount, onUnmount } from '@pyreon/core'\nimport type { Signal } from '@pyreon/reactivity'\nimport { batch, effect, signal } from '@pyreon/reactivity'\nimport {\n elementScroll,\n observeElementOffset,\n observeElementRect,\n type VirtualItem,\n Virtualizer,\n type VirtualizerOptions,\n} from '@tanstack/virtual-core'\nimport { createItemRegistry, type VirtualItemMeasurement } from './item-registry'\n\nexport type UseVirtualizerOptions<\n TScrollElement extends Element,\n TItemElement extends Element,\n> = () => Omit<\n VirtualizerOptions<TScrollElement, TItemElement>,\n 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'\n> &\n Partial<\n Pick<\n VirtualizerOptions<TScrollElement, TItemElement>,\n 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'\n >\n >\n\nexport interface UseVirtualizerResult<\n TScrollElement extends Element,\n TItemElement extends Element,\n> {\n /** The virtualizer instance — read to access all methods. */\n instance: Virtualizer<TScrollElement, TItemElement>\n /** Reactive signal of currently visible virtual items. */\n virtualItems: Signal<VirtualItem[]>\n /** Reactive signal of the total scrollable size in pixels. */\n totalSize: Signal<number>\n /** Reactive signal indicating whether the user is scrolling. */\n isScrolling: Signal<boolean>\n /**\n * Fine-grained per-index measurement accessors (`start`/`size`/`lane`), for\n * dynamically-measured lists. Read inside a row's style accessor so a staying\n * row re-positions when a remeasure above it shifts its `start` — the\n * captured `<For>` item is a stale snapshot and never updates in place. Each\n * field gates on numeric equality, so only genuinely-moved rows patch the DOM\n * (fixed-size lists never fire it → zero cost until first used).\n */\n item: (index: number) => VirtualItemMeasurement\n}\n\n/**\n * Create a reactive TanStack Virtual virtualizer for element-based scrolling.\n *\n * Options are passed as a function so reactive signals (e.g. count, estimateSize)\n * can be read inside, and the virtualizer updates automatically when they change.\n *\n * @example\n * const parentRef = signal<HTMLDivElement | null>(null)\n * const virtual = useVirtualizer(() => ({\n * count: 10000,\n * getScrollElement: () => parentRef(),\n * estimateSize: () => 35,\n * }))\n * // virtual.virtualItems() — array of visible VirtualItem\n * // virtual.totalSize() — total height/width for the inner container\n */\nexport function useVirtualizer<TScrollElement extends Element, TItemElement extends Element>(\n options: UseVirtualizerOptions<TScrollElement, TItemElement>,\n): UseVirtualizerResult<TScrollElement, TItemElement> {\n const resolvedOptions: VirtualizerOptions<TScrollElement, TItemElement> = {\n observeElementRect,\n observeElementOffset,\n scrollToFn: elementScroll,\n ...options(),\n }\n\n const virtualItems = signal<VirtualItem[]>([])\n const totalSize = signal(0)\n const isScrolling = signal(false)\n const registry = createItemRegistry()\n\n // Store latest user options so onChange always reads the freshest reference\n let latestUserOpts = options()\n\n const instance = new Virtualizer<TScrollElement, TItemElement>(resolvedOptions)\n\n // Single emission point: pull the instance's current state into all reactive\n // surfaces (coarse signals + fine-grained per-index registry) in one batch.\n const emit = (): void => {\n batch(() => {\n const items = instance.getVirtualItems()\n virtualItems.set(items)\n totalSize.set(instance.getTotalSize())\n isScrolling.set(instance.isScrolling)\n registry.sync(items)\n })\n }\n\n // Track reactive options: when signals inside options() change, update the virtualizer.\n const effectCleanup = effect(() => {\n latestUserOpts = options()\n instance.setOptions({\n ...instance.options,\n ...latestUserOpts,\n onChange: (inst, sync) => {\n emit()\n // Read latest opts to avoid stale closure\n latestUserOpts.onChange?.(inst, sync)\n },\n })\n\n // After updating options, recalculate and re-emit\n instance._willUpdate()\n emit()\n })\n\n // Lifecycle: mount observers, clean up on unmount.\n let mountCleanup: (() => void) | undefined\n onMount(() => {\n mountCleanup = instance._didMount()\n instance._willUpdate()\n emit()\n return undefined\n })\n\n onUnmount(() => {\n effectCleanup.dispose()\n mountCleanup?.()\n })\n\n return { instance, virtualItems, totalSize, isScrolling, item: registry.item }\n}\n","import { onMount, onUnmount } from '@pyreon/core'\nimport type { Signal } from '@pyreon/reactivity'\nimport { batch, effect, signal } from '@pyreon/reactivity'\nimport {\n observeWindowOffset,\n observeWindowRect,\n type VirtualItem,\n Virtualizer,\n type VirtualizerOptions,\n windowScroll,\n} from '@tanstack/virtual-core'\nimport { createItemRegistry, type VirtualItemMeasurement } from './item-registry'\n\nexport type UseWindowVirtualizerOptions<TItemElement extends Element> = () => Omit<\n VirtualizerOptions<Window, TItemElement>,\n 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' | 'getScrollElement'\n> &\n Partial<\n Pick<\n VirtualizerOptions<Window, TItemElement>,\n 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'\n >\n >\n\nexport interface UseWindowVirtualizerResult<TItemElement extends Element> {\n instance: Virtualizer<Window, TItemElement>\n virtualItems: Signal<VirtualItem[]>\n totalSize: Signal<number>\n isScrolling: Signal<boolean>\n /**\n * Fine-grained per-index measurement accessors (`start`/`size`/`lane`), for\n * dynamically-measured lists. See {@link UseVirtualizerResult.item}.\n */\n item: (index: number) => VirtualItemMeasurement\n}\n\n/**\n * Create a reactive TanStack Virtual virtualizer for window-based scrolling.\n *\n * @example\n * const virtual = useWindowVirtualizer(() => ({\n * count: 10000,\n * estimateSize: () => 35,\n * }))\n */\nexport function useWindowVirtualizer<TItemElement extends Element>(\n options: UseWindowVirtualizerOptions<TItemElement>,\n): UseWindowVirtualizerResult<TItemElement> {\n const virtualItems = signal<VirtualItem[]>([])\n const totalSize = signal(0)\n const isScrolling = signal(false)\n const registry = createItemRegistry()\n\n const resolvedOptions: VirtualizerOptions<Window, TItemElement> = {\n observeElementRect: observeWindowRect,\n observeElementOffset: observeWindowOffset,\n scrollToFn: windowScroll,\n initialOffset: typeof document !== 'undefined' ? window.scrollY : 0,\n getScrollElement: () => (typeof window !== 'undefined' ? window : (null as unknown as Window)),\n ...options(),\n }\n\n // Store latest user options so onChange always reads the freshest reference\n let latestUserOpts = options()\n\n const instance = new Virtualizer<Window, TItemElement>(resolvedOptions)\n\n const emit = (): void => {\n batch(() => {\n const items = instance.getVirtualItems()\n virtualItems.set(items)\n totalSize.set(instance.getTotalSize())\n isScrolling.set(instance.isScrolling)\n registry.sync(items)\n })\n }\n\n const effectCleanup = effect(() => {\n latestUserOpts = options()\n instance.setOptions({\n ...instance.options,\n ...latestUserOpts,\n onChange: (inst, sync) => {\n emit()\n // Read latest opts to avoid stale closure\n latestUserOpts.onChange?.(inst, sync)\n },\n })\n\n instance._willUpdate()\n emit()\n })\n\n let mountCleanup: (() => void) | undefined\n onMount(() => {\n mountCleanup = instance._didMount()\n instance._willUpdate()\n emit()\n return undefined\n })\n\n onUnmount(() => {\n effectCleanup.dispose()\n mountCleanup?.()\n })\n\n return { instance, virtualItems, totalSize, isScrolling, item: registry.item }\n}\n"],"mappings":"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"}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
import { Range, Rect, ScrollToOptions, VirtualItem, VirtualItem as VirtualItem$1, Virtualizer, Virtualizer as Virtualizer$1, VirtualizerOptions, VirtualizerOptions as VirtualizerOptions$1, defaultKeyExtractor, defaultRangeExtractor, elementScroll, measureElement, observeElementOffset, observeElementRect, observeWindowOffset, observeWindowRect, windowScroll } from "@tanstack/virtual-core";
|
|
2
2
|
import { Signal } from "@pyreon/reactivity";
|
|
3
3
|
|
|
4
|
+
//#region src/item-registry.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Reactive per-index measurement accessors for a single virtual item.
|
|
7
|
+
*
|
|
8
|
+
* Each field is a signal-backed accessor that gates on numeric equality — so a
|
|
9
|
+
* row reading `start()`/`size()` re-runs (and patches the DOM) ONLY when THAT
|
|
10
|
+
* item's measurement actually changes. A fixed-size scroll leaves a staying
|
|
11
|
+
* row's `start` invariant (`index * size`), so its accessor never re-fires; a
|
|
12
|
+
* dynamic remeasure that shifts a row's position fires only the shifted rows.
|
|
13
|
+
*/
|
|
14
|
+
interface VirtualItemMeasurement {
|
|
15
|
+
/** Reactive pixel offset of this item along the scroll axis. */
|
|
16
|
+
start: () => number;
|
|
17
|
+
/** Reactive pixel size of this item along the scroll axis. */
|
|
18
|
+
size: () => number;
|
|
19
|
+
/** Reactive lane index (masonry / multi-column). `0` for single-lane lists. */
|
|
20
|
+
lane: () => number;
|
|
21
|
+
}
|
|
22
|
+
interface ItemRegistry {
|
|
23
|
+
/**
|
|
24
|
+
* Push the latest visible measurements into the per-index signals.
|
|
25
|
+
*
|
|
26
|
+
* No-op until `item()` has been called at least once — so the captured-item
|
|
27
|
+
* fast path (fixed-size lists that read `<For>`'s item directly) pays ZERO
|
|
28
|
+
* extra cost. Once active, each update sets the numeric signals (Object.is
|
|
29
|
+
* gate → unchanged rows don't fire) and prunes signals for indices that left
|
|
30
|
+
* the window (their rows unmounted), keeping the map bounded to the window.
|
|
31
|
+
*/
|
|
32
|
+
sync: (items: VirtualItem$1[]) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Get reactive `start`/`size`/`lane` accessors for `index`. Stable for the
|
|
35
|
+
* lifetime of the calling row. Use inside a row's style/position accessor for
|
|
36
|
+
* dynamically-measured lists so a staying row re-positions when a remeasure
|
|
37
|
+
* above it shifts its `start`.
|
|
38
|
+
*/
|
|
39
|
+
item: (index: number) => VirtualItemMeasurement;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
4
42
|
//#region src/use-virtualizer.d.ts
|
|
5
43
|
type UseVirtualizerOptions<TScrollElement extends Element, TItemElement extends Element> = () => Omit<VirtualizerOptions$1<TScrollElement, TItemElement>, 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'> & Partial<Pick<VirtualizerOptions$1<TScrollElement, TItemElement>, 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'>>;
|
|
6
44
|
interface UseVirtualizerResult<TScrollElement extends Element, TItemElement extends Element> {
|
|
@@ -12,6 +50,15 @@ interface UseVirtualizerResult<TScrollElement extends Element, TItemElement exte
|
|
|
12
50
|
totalSize: Signal<number>;
|
|
13
51
|
/** Reactive signal indicating whether the user is scrolling. */
|
|
14
52
|
isScrolling: Signal<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Fine-grained per-index measurement accessors (`start`/`size`/`lane`), for
|
|
55
|
+
* dynamically-measured lists. Read inside a row's style accessor so a staying
|
|
56
|
+
* row re-positions when a remeasure above it shifts its `start` — the
|
|
57
|
+
* captured `<For>` item is a stale snapshot and never updates in place. Each
|
|
58
|
+
* field gates on numeric equality, so only genuinely-moved rows patch the DOM
|
|
59
|
+
* (fixed-size lists never fire it → zero cost until first used).
|
|
60
|
+
*/
|
|
61
|
+
item: (index: number) => VirtualItemMeasurement;
|
|
15
62
|
}
|
|
16
63
|
/**
|
|
17
64
|
* Create a reactive TanStack Virtual virtualizer for element-based scrolling.
|
|
@@ -38,6 +85,11 @@ interface UseWindowVirtualizerResult<TItemElement extends Element> {
|
|
|
38
85
|
virtualItems: Signal<VirtualItem$1[]>;
|
|
39
86
|
totalSize: Signal<number>;
|
|
40
87
|
isScrolling: Signal<boolean>;
|
|
88
|
+
/**
|
|
89
|
+
* Fine-grained per-index measurement accessors (`start`/`size`/`lane`), for
|
|
90
|
+
* dynamically-measured lists. See {@link UseVirtualizerResult.item}.
|
|
91
|
+
*/
|
|
92
|
+
item: (index: number) => VirtualItemMeasurement;
|
|
41
93
|
}
|
|
42
94
|
/**
|
|
43
95
|
* Create a reactive TanStack Virtual virtualizer for window-based scrolling.
|
|
@@ -50,4 +102,4 @@ interface UseWindowVirtualizerResult<TItemElement extends Element> {
|
|
|
50
102
|
*/
|
|
51
103
|
declare function useWindowVirtualizer<TItemElement extends Element>(options: UseWindowVirtualizerOptions<TItemElement>): UseWindowVirtualizerResult<TItemElement>;
|
|
52
104
|
//#endregion
|
|
53
|
-
export { type Range, type Rect, type ScrollToOptions, type UseVirtualizerOptions, type UseVirtualizerResult, type UseWindowVirtualizerOptions, type UseWindowVirtualizerResult, type VirtualItem, Virtualizer, type VirtualizerOptions, defaultKeyExtractor, defaultRangeExtractor, elementScroll, measureElement, observeElementOffset, observeElementRect, observeWindowOffset, observeWindowRect, useVirtualizer, useWindowVirtualizer, windowScroll };
|
|
105
|
+
export { type ItemRegistry, type Range, type Rect, type ScrollToOptions, type UseVirtualizerOptions, type UseVirtualizerResult, type UseWindowVirtualizerOptions, type UseWindowVirtualizerResult, type VirtualItem, type VirtualItemMeasurement, Virtualizer, type VirtualizerOptions, defaultKeyExtractor, defaultRangeExtractor, elementScroll, measureElement, observeElementOffset, observeElementRect, observeWindowOffset, observeWindowRect, useVirtualizer, useWindowVirtualizer, windowScroll };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/virtual",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "Pyreon adapter for TanStack Virtual",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/fundamentals/virtual#readme",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "https://github.com/pyreon/pyreon.git",
|
|
12
|
+
"url": "git+https://github.com/pyreon/pyreon.git",
|
|
13
13
|
"directory": "packages/fundamentals/virtual"
|
|
14
14
|
},
|
|
15
15
|
"type": "module",
|
|
@@ -34,11 +34,12 @@
|
|
|
34
34
|
"test": "vitest run",
|
|
35
35
|
"test:browser": "vitest run --config ./vitest.browser.config.ts",
|
|
36
36
|
"typecheck": "tsc --noEmit",
|
|
37
|
-
"lint": "oxlint ."
|
|
37
|
+
"lint": "oxlint .",
|
|
38
|
+
"bench:react-virtual": "NODE_ENV=production bun bench/virtual-bench.ts"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"@pyreon/core": "^0.
|
|
41
|
-
"@pyreon/reactivity": "^0.
|
|
41
|
+
"@pyreon/core": "^0.45.0",
|
|
42
|
+
"@pyreon/reactivity": "^0.45.0"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"@tanstack/virtual-core": "^3.17.3"
|
|
@@ -46,10 +47,13 @@
|
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@happy-dom/global-registrator": "^20.9.0",
|
|
48
49
|
"@pyreon/manifest": "0.13.2",
|
|
49
|
-
"@pyreon/runtime-dom": "^0.
|
|
50
|
-
"@pyreon/test-utils": "^0.13.
|
|
50
|
+
"@pyreon/runtime-dom": "^0.45.0",
|
|
51
|
+
"@pyreon/test-utils": "^0.13.33",
|
|
51
52
|
"@pyreon/vitest-config": "0.13.3",
|
|
52
|
-
"@
|
|
53
|
+
"@tanstack/react-virtual": "3.14.6",
|
|
54
|
+
"@vitest/browser-playwright": "^4.1.8",
|
|
55
|
+
"react": "^19.2.7",
|
|
56
|
+
"react-dom": "^19.2.7"
|
|
53
57
|
},
|
|
54
58
|
"publishConfig": {
|
|
55
59
|
"access": "public"
|