@pyreon/virtual 0.44.0 → 0.46.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 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
- {virtualItems().map((row) => (
35
- <div
36
- style={`position: absolute; top: 0; width: 100%; height: ${row.size}px; transform: translateY(${row.start}px);`}
37
- >
38
- {items[row.index]}
39
- </div>
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().map((row) => (
92
- <div
93
- style={`position: absolute; top: 0; width: 100%; height: ${row.size}px; transform: translateY(${row.start}px);`}
94
- >
95
- {items[row.index]}
96
- </div>
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 that need to be measured after render:
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 { virtualItems, totalSize, instance } = useVirtualizer(() => ({
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
- virtualItems().map((row) => (
121
- <div ref={(el) => instance.measureElement(el)} data-index={row.index}>
122
- {items[row.index]}
123
- </div>
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":"7b79fc83-1","name":"use-virtualizer.ts"},{"uid":"7b79fc83-3","name":"use-window-virtualizer.ts"},{"uid":"7b79fc83-5","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"7b79fc83-1":{"renderedLength":1909,"gzipLength":755,"brotliLength":0,"metaUid":"7b79fc83-0"},"7b79fc83-3":{"renderedLength":1672,"gzipLength":646,"brotliLength":0,"metaUid":"7b79fc83-2"},"7b79fc83-5":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"7b79fc83-4"}},"nodeMetas":{"7b79fc83-0":{"id":"/src/use-virtualizer.ts","moduleParts":{"index.js":"7b79fc83-1"},"imported":[{"uid":"7b79fc83-7"},{"uid":"7b79fc83-8"},{"uid":"7b79fc83-6"}],"importedBy":[{"uid":"7b79fc83-4"}]},"7b79fc83-2":{"id":"/src/use-window-virtualizer.ts","moduleParts":{"index.js":"7b79fc83-3"},"imported":[{"uid":"7b79fc83-7"},{"uid":"7b79fc83-8"},{"uid":"7b79fc83-6"}],"importedBy":[{"uid":"7b79fc83-4"}]},"7b79fc83-4":{"id":"/src/index.ts","moduleParts":{"index.js":"7b79fc83-5"},"imported":[{"uid":"7b79fc83-6"},{"uid":"7b79fc83-0"},{"uid":"7b79fc83-2"}],"importedBy":[],"isEntry":true},"7b79fc83-6":{"id":"@tanstack/virtual-core","moduleParts":{},"imported":[],"importedBy":[{"uid":"7b79fc83-4"},{"uid":"7b79fc83-0"},{"uid":"7b79fc83-2"}]},"7b79fc83-7":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"7b79fc83-0"},{"uid":"7b79fc83-2"}]},"7b79fc83-8":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"7b79fc83-0"},{"uid":"7b79fc83-2"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
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
- batch(() => {
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
- batch(() => {
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
- batch(() => {
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
- batch(() => {
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
- batch(() => {
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
- batch(() => {
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":";;;;;;;;;AAqDA,SAAgB,qBAAmC;CACjD,MAAM,0BAAU,IAAI,IAAyB;CAC7C,MAAM,uBAAO,IAAI,IAAyB;CAC1C,IAAI,SAAS;CAEb,MAAM,QAAQ,UAA+B;EAC3C,IAAI,CAAC,QAAQ;EACb,QAAQ,MAAM;EAGd,YAAY;GACV,KAAK,MAAM,MAAM,OAAO;IACtB,QAAQ,IAAI,GAAG,OAAO,EAAE;IACxB,MAAM,IAAI,KAAK,IAAI,GAAG,KAAK;IAC3B,IAAI,GAAG;KAEL,IAAI,EAAE,OAAO,EAAE,MAAM,IAAI,GAAG,KAAK;KACjC,IAAI,EAAE,MAAM,EAAE,KAAK,IAAI,GAAG,IAAI;KAC9B,IAAI,EAAE,MAAM,EAAE,KAAK,IAAI,GAAG,IAAI;IAChC;GACF;EACF,CAAC;EAKD,IAAI,KAAK,OAAO,QAAQ,MACtB;QAAK,MAAM,OAAO,KAAK,KAAK,GAC1B,IAAI,CAAC,QAAQ,IAAI,GAAG,GAAG,KAAK,OAAO,GAAG;EACxC;CAEJ;CAEA,MAAM,QAAQ,UAA0C;EACtD,SAAS;EACT,IAAI,IAAI,KAAK,IAAI,KAAK;EACtB,IAAI,CAAC,GAAG;GACN,IAAI;IAAE,OAAO;IAAM,MAAM;IAAM,MAAM;GAAK;GAC1C,KAAK,IAAI,OAAO,CAAC;EACnB;EACA,MAAM,MAAM;EAGZ,OAAO;GACL,cAAc,IAAI,UAAU,OAAO,QAAQ,IAAI,KAAK,CAAC,EAAE,SAAS,CAAC,EAAC,CAAE;GACpE,aAAa,IAAI,SAAS,OAAO,QAAQ,IAAI,KAAK,CAAC,EAAE,QAAQ,CAAC,EAAC,CAAE;GACjE,aAAa,IAAI,SAAS,OAAO,QAAQ,IAAI,KAAK,CAAC,EAAE,QAAQ,CAAC,EAAC,CAAE;EACnE;CACF;CAEA,OAAO;EAAE;EAAM;CAAK;AACtB;;;;;;;;;;;;;;;;;;;;ACtCA,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;CAChC,MAAM,WAAW,mBAAmB;CAGpC,IAAI,iBAAiB,QAAQ;CAE7B,MAAM,WAAW,IAAIC,cAA0C,eAAe;CAI9E,MAAM,aAAmB;EACvB,YAAY;GACV,MAAM,QAAQ,SAAS,gBAAgB;GACvC,aAAa,IAAI,KAAK;GACtB,UAAU,IAAI,SAAS,aAAa,CAAC;GACrC,YAAY,IAAI,SAAS,WAAW;GACpC,SAAS,KAAK,KAAK;EACrB,CAAC;CACH;CAGA,MAAM,gBAAgB,aAAa;EACjC,iBAAiB,QAAQ;EACzB,SAAS,WAAW;GAClB,GAAG,SAAS;GACZ,GAAG;GACH,WAAW,MAAM,SAAS;IACxB,KAAK;IAEL,eAAe,WAAW,MAAM,IAAI;GACtC;EACF,CAAC;EAGD,SAAS,YAAY;EACrB,KAAK;CACP,CAAC;CAGD,IAAI;CACJ,cAAc;EACZ,eAAe,SAAS,UAAU;EAClC,SAAS,YAAY;EACrB,KAAK;CAEP,CAAC;CAED,gBAAgB;EACd,cAAc,QAAQ;EACtB,eAAe;CACjB,CAAC;CAED,OAAO;EAAE;EAAU;EAAc;EAAW;EAAa,MAAM,SAAS;CAAK;AAC/E;;;;;;;;;;;;;ACtFA,SAAgB,qBACd,SAC0C;CAC1C,MAAM,eAAe,OAAsB,CAAC,CAAC;CAC7C,MAAM,YAAY,OAAO,CAAC;CAC1B,MAAM,cAAc,OAAO,KAAK;CAChC,MAAM,WAAW,mBAAmB;CAEpC,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,aAAmB;EACvB,YAAY;GACV,MAAM,QAAQ,SAAS,gBAAgB;GACvC,aAAa,IAAI,KAAK;GACtB,UAAU,IAAI,SAAS,aAAa,CAAC;GACrC,YAAY,IAAI,SAAS,WAAW;GACpC,SAAS,KAAK,KAAK;EACrB,CAAC;CACH;CAEA,MAAM,gBAAgB,aAAa;EACjC,iBAAiB,QAAQ;EACzB,SAAS,WAAW;GAClB,GAAG,SAAS;GACZ,GAAG;GACH,WAAW,MAAM,SAAS;IACxB,KAAK;IAEL,eAAe,WAAW,MAAM,IAAI;GACtC;EACF,CAAC;EAED,SAAS,YAAY;EACrB,KAAK;CACP,CAAC;CAED,IAAI;CACJ,cAAc;EACZ,eAAe,SAAS,UAAU;EAClC,SAAS,YAAY;EACrB,KAAK;CAEP,CAAC;CAED,gBAAgB;EACd,cAAc,QAAQ;EACtB,eAAe;CACjB,CAAC;CAED,OAAO;EAAE;EAAU;EAAc;EAAW;EAAa,MAAM,SAAS;CAAK;AAC/E"}
@@ -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.44.0",
3
+ "version": "0.46.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.44.0",
41
- "@pyreon/reactivity": "^0.44.0"
41
+ "@pyreon/core": "^0.46.0",
42
+ "@pyreon/reactivity": "^0.46.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.44.0",
50
- "@pyreon/test-utils": "^0.13.32",
50
+ "@pyreon/runtime-dom": "^0.46.0",
51
+ "@pyreon/test-utils": "^0.13.34",
51
52
  "@pyreon/vitest-config": "0.13.3",
52
- "@vitest/browser-playwright": "^4.1.8"
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"