@octanejs/tanstack-virtual 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @octanejs/tanstack-virtual
2
+
3
+ [TanStack Virtual](https://tanstack.com/virtual) for the [octane](https://github.com/octanejs/octane) UI framework.
4
+
5
+ TanStack Virtual separates a framework-agnostic core (`@tanstack/virtual-core`:
6
+ the `Virtualizer` + scroll/rect observers + all windowing math) from a small
7
+ React adapter (`useVirtualizer`, `useWindowVirtualizer`). This package reuses
8
+ the core unchanged (re-exported verbatim) and transcribes only the adapter onto
9
+ octane's hooks, preserving upstream's exact shape — a force-update reducer
10
+ wired into the instance's `onChange` (`flushSync` for sync scroll notifies), a
11
+ create-once `Virtualizer`, options re-composed every render, and the
12
+ `_didMount`/`_willUpdate` layout-effect lifecycle. The public surface matches
13
+ `@tanstack/react-virtual` 1:1 — existing code works by changing the import.
14
+
15
+ ```tsx
16
+ // before
17
+ import { useVirtualizer } from '@tanstack/react-virtual';
18
+ // after
19
+ import { useVirtualizer } from '@octanejs/tanstack-virtual';
20
+
21
+ function List() @{
22
+ const parentRef = useRef(null);
23
+ const virtualizer = useVirtualizer({
24
+ count: 10000,
25
+ getScrollElement: () => parentRef.current,
26
+ estimateSize: () => 35,
27
+ });
28
+ <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
29
+ <div style={{ height: virtualizer.getTotalSize() + 'px', position: 'relative' }}>
30
+ @for (const item of virtualizer.getVirtualItems(); key item.key) {
31
+ <div
32
+ ref={virtualizer.measureElement}
33
+ data-index={item.index}
34
+ style={{
35
+ position: 'absolute',
36
+ top: '0px',
37
+ left: '0px',
38
+ width: '100%',
39
+ transform: 'translateY(' + item.start + 'px)',
40
+ }}
41
+ >
42
+ {'Row ' + item.index}
43
+ </div>
44
+ }
45
+ </div>
46
+ </div>
47
+ }
48
+ ```
49
+
50
+ ## Entry points
51
+
52
+ | import | what you get | notes |
53
+ | --- | --- | --- |
54
+ | `@octanejs/tanstack-virtual` | everything `@tanstack/virtual-core` exports + `useVirtualizer`, `useWindowVirtualizer` (+ `ReactVirtualizer`/`ReactVirtualizerOptions` types) | core verbatim + the octane-bound adapter (single entry, mirroring upstream) |
55
+
56
+ ## How it works
57
+
58
+ `useVirtualizer` is a line-for-line transcription of the upstream adapter: the
59
+ `Virtualizer` instance is created once, a force-update reducer is wired into
60
+ its `onChange` (sync scroll notifies go through octane's `flushSync`), options
61
+ are re-composed into the instance during every render, and the
62
+ `_didMount`/`_willUpdate` lifecycle runs in layout effects. Dynamic measurement
63
+ works React-19 style: `ref={virtualizer.measureElement}` on item elements (a
64
+ member-expression callback ref). The experimental `directDomUpdates` surface is
65
+ ported verbatim.
66
+
67
+ One octane nuance (consumer-invisible, pinned by tests): octane's `flushSync`
68
+ called while a flush is already on the stack — e.g. a scroll dispatched from
69
+ inside a click handler — degrades to a plain call drained by the ambient
70
+ flush, so the update lands at that flush's boundary instead of nested.
71
+
72
+ octane keys hooks by a compiler-injected per-call-site `Symbol`, appended as
73
+ the last argument of every `use*` call. The hooks here forward that slot into
74
+ their composed base hooks, so two virtualizers in one component stay
75
+ independent, exactly like in React.
76
+
77
+ ## Status
78
+
79
+ Current scope, known divergences, and verification status are tracked in the
80
+ generated [bindings status table](../../docs/bindings-status.md), sourced from
81
+ this package's [`status.json`](./status.json).
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@octanejs/tanstack-virtual",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "octane": {
7
+ "hookSlots": {
8
+ "manual": [
9
+ "src"
10
+ ]
11
+ }
12
+ },
13
+ "description": "TanStack Virtual bindings for the octane renderer — reuses the framework-agnostic @tanstack/virtual-core and swaps the React adapter (useVirtualizer, useWindowVirtualizer) for an octane port.",
14
+ "author": {
15
+ "name": "Dominic Gannaway",
16
+ "email": "dg@domgan.com"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/octanejs/octane.git",
24
+ "directory": "packages/tanstack-virtual"
25
+ },
26
+ "main": "src/index.ts",
27
+ "module": "src/index.ts",
28
+ "types": "src/index.ts",
29
+ "files": [
30
+ "src",
31
+ "README.md"
32
+ ],
33
+ "exports": {
34
+ ".": "./src/index.ts"
35
+ },
36
+ "dependencies": {
37
+ "@tanstack/virtual-core": "3.17.3",
38
+ "octane": "0.1.3"
39
+ },
40
+ "devDependencies": {
41
+ "@tanstack/react-virtual": "3.14.5",
42
+ "@tsrx/react": "^0.2.37",
43
+ "esbuild": "^0.28.1",
44
+ "react": "^19.2.0",
45
+ "react-dom": "^19.2.0",
46
+ "vitest": "^4.1.9"
47
+ },
48
+ "scripts": {
49
+ "test": "vitest run"
50
+ }
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,261 @@
1
+ // @octanejs/tanstack-virtual — TanStack Virtual for the octane renderer.
2
+ //
3
+ // TanStack Virtual separates a framework-agnostic core (`@tanstack/virtual-core`:
4
+ // the Virtualizer class + scroll/rect observers + windowing math) from a small
5
+ // React adapter (`useVirtualizer`, `useWindowVirtualizer`). This package reuses
6
+ // the core UNCHANGED (re-exported verbatim) and transcribes only the adapter
7
+ // onto octane's hooks, preserving upstream's exact shape — a force-update
8
+ // useReducer wired into the instance's onChange (flushSync for sync scroll
9
+ // notifies), a create-once Virtualizer in useState, setOptions re-composed
10
+ // every render, and three layout effects (_didMount once, _willUpdate every
11
+ // render, direct-DOM style application every render). The public surface
12
+ // matches @tanstack/react-virtual 1:1 — existing code works by changing the
13
+ // import.
14
+ //
15
+ // The one octane-specific detail is hook slots: octane keys hooks by a
16
+ // compiler-injected per-call-site Symbol, appended as the LAST argument of
17
+ // every `use*` call. The hooks here forward that slot into their composed base
18
+ // hooks (deriving a stable sub-slot each), so two virtualizers in one
19
+ // component stay independent, just like in React.
20
+ //
21
+ // Note on flushSync: octane's flushSync called while a flush is already on the
22
+ // stack degrades to a plain fn() and lets the ambient flush drain the work
23
+ // (runtime.ts re-entrancy guard) — consumer-invisible at flush boundaries;
24
+ // pinned by a conformance test.
25
+ import { flushSync, useEffect, useLayoutEffect, useReducer, useRef, useState } from 'octane';
26
+ import {
27
+ Virtualizer,
28
+ elementScroll,
29
+ observeElementOffset,
30
+ observeElementRect,
31
+ observeWindowOffset,
32
+ observeWindowRect,
33
+ windowScroll,
34
+ } from '@tanstack/virtual-core';
35
+ import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core';
36
+ import { splitSlot, subSlot } from './internal';
37
+
38
+ export * from '@tanstack/virtual-core';
39
+
40
+ export type ReactVirtualizer<
41
+ TScrollElement extends Element | Window,
42
+ TItemElement extends Element,
43
+ > = Virtualizer<TScrollElement, TItemElement> & {
44
+ /**
45
+ * Ref callback for the inner size container element. Only meaningful when
46
+ * `directDomUpdates: true` — the virtualizer writes the container's
47
+ * main-axis size (`height` or `width`) directly to skip re-renders.
48
+ */
49
+ containerRef: (node: HTMLElement | null) => void;
50
+ };
51
+
52
+ export type ReactVirtualizerOptions<
53
+ TScrollElement extends Element | Window,
54
+ TItemElement extends Element,
55
+ > = VirtualizerOptions<TScrollElement, TItemElement> & {
56
+ useFlushSync?: boolean;
57
+ /**
58
+ * Skip re-renders for scroll-only updates: the virtualizer writes item
59
+ * positions and the container size directly to the DOM, and only
60
+ * re-renders when the visible index range or `isScrolling` changes. See
61
+ * upstream @tanstack/react-virtual docs for the layout requirements.
62
+ */
63
+ directDomUpdates?: boolean;
64
+ /** How `directDomUpdates` positions items: `'transform'` (default) or `'position'`. */
65
+ directDomUpdatesMode?: 'position' | 'transform';
66
+ };
67
+
68
+ interface DirectDomState {
69
+ enabled: boolean;
70
+ mode: 'position' | 'transform';
71
+ container: HTMLElement | null;
72
+ lastSize: number | null;
73
+ // Keyed by the element itself so a remounted node (same key, new DOM
74
+ // node — e.g. when `enabled` is toggled off then on) is treated as fresh
75
+ // and gets its style written.
76
+ lastPositions: WeakMap<Element, number>;
77
+ prevRange: { startIndex: number; endIndex: number; isScrolling: boolean } | null;
78
+ }
79
+
80
+ function useVirtualizerBase<TScrollElement extends Element | Window, TItemElement extends Element>(
81
+ {
82
+ useFlushSync = true,
83
+ directDomUpdates = false,
84
+ directDomUpdatesMode = 'transform',
85
+ ...options
86
+ }: VirtualizerOptions<TScrollElement, TItemElement> & {
87
+ useFlushSync?: boolean;
88
+ directDomUpdates?: boolean;
89
+ directDomUpdatesMode?: 'position' | 'transform';
90
+ },
91
+ slot: symbol | undefined,
92
+ ): ReactVirtualizer<TScrollElement, TItemElement> {
93
+ const rerender = useReducer<number, void, number>((x) => x + 1, 0, subSlot(slot, 'uvb:r'))[1];
94
+
95
+ const directRef = useRef<DirectDomState>(
96
+ {
97
+ enabled: directDomUpdates,
98
+ mode: directDomUpdatesMode,
99
+ container: null,
100
+ lastSize: null,
101
+ lastPositions: new WeakMap(),
102
+ prevRange: null,
103
+ },
104
+ subSlot(slot, 'uvb:d'),
105
+ );
106
+ directRef.current.enabled = directDomUpdates;
107
+ directRef.current.mode = directDomUpdatesMode;
108
+
109
+ const applyDirectStyles = (instance: Virtualizer<TScrollElement, TItemElement>) => {
110
+ const state = directRef.current;
111
+ if (!state.enabled || !state.container) return;
112
+ const totalSize = instance.getTotalSize();
113
+ if (totalSize !== state.lastSize) {
114
+ state.lastSize = totalSize;
115
+ const sizeAxis = instance.options.horizontal ? 'width' : 'height';
116
+ state.container.style[sizeAxis] = `${totalSize}px`;
117
+ }
118
+ const horizontal = !!instance.options.horizontal;
119
+ const useTransform = state.mode === 'transform';
120
+ const posAxis = horizontal ? 'left' : 'top';
121
+ const scrollMargin = instance.options.scrollMargin;
122
+ const items = instance.getVirtualItems();
123
+ for (const item of items) {
124
+ const next = item.start - scrollMargin;
125
+ const el = instance.elementsCache.get(item.key) as HTMLElement | undefined;
126
+ if (!el) continue;
127
+ if (state.lastPositions.get(el) === next) continue;
128
+ state.lastPositions.set(el, next);
129
+ if (useTransform) {
130
+ el.style.transform = horizontal
131
+ ? `translate3d(${next}px, 0, 0)`
132
+ : `translate3d(0, ${next}px, 0)`;
133
+ } else {
134
+ el.style[posAxis] = `${next}px`;
135
+ }
136
+ }
137
+ };
138
+
139
+ const resolvedOptions: VirtualizerOptions<TScrollElement, TItemElement> = {
140
+ ...options,
141
+ onChange: (instance, sync) => {
142
+ const state = directRef.current;
143
+ let shouldRerender = true;
144
+ if (state.enabled) {
145
+ applyDirectStyles(instance);
146
+ const range = instance.range;
147
+ const prev = state.prevRange;
148
+ shouldRerender =
149
+ !prev ||
150
+ prev.isScrolling !== instance.isScrolling ||
151
+ prev.startIndex !== range?.startIndex ||
152
+ prev.endIndex !== range?.endIndex;
153
+ if (shouldRerender) {
154
+ state.prevRange = range
155
+ ? {
156
+ startIndex: range.startIndex,
157
+ endIndex: range.endIndex,
158
+ isScrolling: instance.isScrolling,
159
+ }
160
+ : null;
161
+ }
162
+ }
163
+ if (shouldRerender) {
164
+ if (useFlushSync && sync) {
165
+ flushSync(rerender);
166
+ } else {
167
+ rerender();
168
+ }
169
+ }
170
+ options.onChange?.(instance, sync);
171
+ },
172
+ };
173
+
174
+ const [instance] = useState(
175
+ () => {
176
+ const v = new Virtualizer<TScrollElement, TItemElement>(resolvedOptions);
177
+ return Object.assign(v, {
178
+ containerRef: (node: HTMLElement | null) => {
179
+ const state = directRef.current;
180
+ state.container = node;
181
+ state.lastSize = null;
182
+ if (node && state.enabled) {
183
+ const total = v.getTotalSize();
184
+ state.lastSize = total;
185
+ const axis = v.options.horizontal ? 'width' : 'height';
186
+ node.style[axis] = `${total}px`;
187
+ }
188
+ },
189
+ });
190
+ },
191
+ subSlot(slot, 'uvb:i'),
192
+ );
193
+
194
+ instance.setOptions(resolvedOptions);
195
+
196
+ useIsomorphicLayoutEffect(() => instance._didMount(), [], subSlot(slot, 'uvb:m'));
197
+ useIsomorphicLayoutEffect(() => instance._willUpdate(), undefined, subSlot(slot, 'uvb:w'));
198
+ useIsomorphicLayoutEffect(
199
+ () => {
200
+ applyDirectStyles(instance);
201
+ },
202
+ undefined,
203
+ subSlot(slot, 'uvb:s'),
204
+ );
205
+
206
+ return instance;
207
+ }
208
+
209
+ // SSR guard, ported as-is: on the server the layout-effect work (observer
210
+ // wiring) is skipped and the first paint windows from initialRect/initialOffset.
211
+ function useIsomorphicLayoutEffect(
212
+ fn: () => void | (() => void),
213
+ deps: unknown[] | undefined,
214
+ slot: symbol,
215
+ ): void {
216
+ if (typeof document !== 'undefined') {
217
+ useLayoutEffect(fn, deps, slot);
218
+ } else {
219
+ useEffect(fn, deps, slot);
220
+ }
221
+ }
222
+
223
+ export function useVirtualizer<TScrollElement extends Element, TItemElement extends Element>(
224
+ options: PartialKeys<
225
+ ReactVirtualizerOptions<TScrollElement, TItemElement>,
226
+ 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'
227
+ >,
228
+ ...rest: unknown[]
229
+ ): ReactVirtualizer<TScrollElement, TItemElement> {
230
+ const [, slot] = splitSlot(rest);
231
+ return useVirtualizerBase<TScrollElement, TItemElement>(
232
+ {
233
+ observeElementRect,
234
+ observeElementOffset,
235
+ scrollToFn: elementScroll,
236
+ ...options,
237
+ },
238
+ slot,
239
+ );
240
+ }
241
+
242
+ export function useWindowVirtualizer<TItemElement extends Element>(
243
+ options: PartialKeys<
244
+ ReactVirtualizerOptions<Window, TItemElement>,
245
+ 'getScrollElement' | 'observeElementRect' | 'observeElementOffset' | 'scrollToFn'
246
+ >,
247
+ ...rest: unknown[]
248
+ ): ReactVirtualizer<Window, TItemElement> {
249
+ const [, slot] = splitSlot(rest);
250
+ return useVirtualizerBase<Window, TItemElement>(
251
+ {
252
+ getScrollElement: () => (typeof document !== 'undefined' ? window : null),
253
+ observeElementRect: observeWindowRect,
254
+ observeElementOffset: observeWindowOffset,
255
+ scrollToFn: windowScroll,
256
+ initialOffset: () => (typeof document !== 'undefined' ? window.scrollY : 0),
257
+ ...options,
258
+ },
259
+ slot,
260
+ );
261
+ }
@@ -0,0 +1,37 @@
1
+ // Slot mechanics shared by the binding's plain-`.ts` hooks (same helper as
2
+ // @octanejs/tanstack-query). The octane compiler injects a per-call-site Symbol slot into
3
+ // every hook call in compiled files; these binding files are NOT compiled, so a
4
+ // hook here receives the caller's slot as its trailing argument and derives a
5
+ // distinct sub-slot for each base hook it composes.
6
+
7
+ // Memoized: subSlot runs on EVERY hook call every render; the cache returns the
8
+ // identical Symbol.for-interned value without the concat + registry lookup.
9
+ const subSlotCache = new Map<symbol, Map<string, symbol>>();
10
+ // Tag-only symbols for the slotless-caller case (see below).
11
+ const bareTagCache = new Map<string, symbol>();
12
+
13
+ export function subSlot(slot: symbol | undefined, tag: string): symbol {
14
+ // No inherited slot (the caller was NOT compiled — e.g. a vendored wrapper
15
+ // hook): return a stable TAG-ONLY symbol rather than undefined. The runtime
16
+ // combines it with the ambient withSlot path, so sibling base hooks inside
17
+ // one composed hook stay DISTINCT per tag. Returning undefined here made
18
+ // them all resolve to the bare path — one shared slot, state collision.
19
+ if (slot === undefined) {
20
+ let bare = bareTagCache.get(tag);
21
+ if (bare === undefined) bareTagCache.set(tag, (bare = Symbol.for(':' + tag)));
22
+ return bare;
23
+ }
24
+ let byTag = subSlotCache.get(slot);
25
+ if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
26
+ let sym = byTag.get(tag);
27
+ if (sym === undefined) byTag.set(tag, (sym = Symbol.for((slot.description ?? '') + ':' + tag)));
28
+ return sym;
29
+ }
30
+
31
+ // Split the compiler-injected trailing slot off a hook's runtime args, returning
32
+ // the user args (everything before it) and the slot.
33
+ export function splitSlot(args: any[]): [any[], symbol | undefined] {
34
+ const tail = args[args.length - 1];
35
+ const slot = typeof tail === 'symbol' ? (tail as symbol) : undefined;
36
+ return [slot !== undefined ? args.slice(0, -1) : args, slot];
37
+ }