@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 +21 -0
- package/README.md +81 -0
- package/package.json +51 -0
- package/src/index.ts +261 -0
- package/src/internal.ts +37 -0
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
|
+
}
|
package/src/internal.ts
ADDED
|
@@ -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
|
+
}
|