one 1.2.79 → 1.2.81
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/dist/cjs/Root.cjs +1 -1
- package/dist/cjs/Root.js +1 -1
- package/dist/cjs/Root.js.map +1 -1
- package/dist/cjs/Root.native.js +1 -1
- package/dist/cjs/Root.native.js.map +1 -1
- package/dist/cjs/drawer.cjs +27 -0
- package/dist/cjs/drawer.js +22 -0
- package/dist/cjs/drawer.js.map +6 -0
- package/dist/cjs/drawer.native.js +30 -0
- package/dist/cjs/drawer.native.js.map +1 -0
- package/dist/cjs/hooks.cjs +25 -2
- package/dist/cjs/hooks.js +23 -2
- package/dist/cjs/hooks.js.map +1 -1
- package/dist/cjs/hooks.native.js +147 -14
- package/dist/cjs/hooks.native.js.map +1 -1
- package/dist/cjs/hooks.test.cjs +73 -0
- package/dist/cjs/hooks.test.js +98 -0
- package/dist/cjs/hooks.test.js.map +6 -0
- package/dist/cjs/hooks.test.native.js +194 -0
- package/dist/cjs/hooks.test.native.js.map +1 -0
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/link/prefetchIntent.cjs +122 -0
- package/dist/cjs/link/prefetchIntent.js +85 -0
- package/dist/cjs/link/prefetchIntent.js.map +6 -0
- package/dist/cjs/link/prefetchIntent.native.js +155 -0
- package/dist/cjs/link/prefetchIntent.native.js.map +1 -0
- package/dist/cjs/link/prefetchIntent.test.cjs +217 -0
- package/dist/cjs/link/prefetchIntent.test.js +149 -0
- package/dist/cjs/link/prefetchIntent.test.js.map +6 -0
- package/dist/cjs/link/prefetchIntent.test.native.js +239 -0
- package/dist/cjs/link/prefetchIntent.test.native.js.map +1 -0
- package/dist/cjs/link/prefetchViewport.cjs +67 -0
- package/dist/cjs/link/prefetchViewport.js +55 -0
- package/dist/cjs/link/prefetchViewport.js.map +6 -0
- package/dist/cjs/link/prefetchViewport.native.js +83 -0
- package/dist/cjs/link/prefetchViewport.native.js.map +1 -0
- package/dist/cjs/link/prefetchViewport.test.cjs +57 -0
- package/dist/cjs/link/prefetchViewport.test.js +59 -0
- package/dist/cjs/link/prefetchViewport.test.js.map +6 -0
- package/dist/cjs/link/prefetchViewport.test.native.js +85 -0
- package/dist/cjs/link/prefetchViewport.test.native.js.map +1 -0
- package/dist/cjs/router/findRouteNode.cjs +26 -0
- package/dist/cjs/router/findRouteNode.js +28 -0
- package/dist/cjs/router/findRouteNode.js.map +1 -1
- package/dist/cjs/router/findRouteNode.native.js +31 -0
- package/dist/cjs/router/findRouteNode.native.js.map +1 -1
- package/dist/cjs/router/router.cjs +11 -13
- package/dist/cjs/router/router.js +8 -10
- package/dist/cjs/router/router.js.map +2 -2
- package/dist/cjs/router/router.native.js +38 -122
- package/dist/cjs/router/router.native.js.map +1 -1
- package/dist/cjs/views/PreloadLinks.cjs +102 -18
- package/dist/cjs/views/PreloadLinks.js +95 -19
- package/dist/cjs/views/PreloadLinks.js.map +1 -1
- package/dist/cjs/vite/one.cjs +3 -0
- package/dist/cjs/vite/one.js +3 -0
- package/dist/cjs/vite/one.js.map +1 -1
- package/dist/cjs/vite/one.native.js +4 -0
- package/dist/cjs/vite/one.native.js.map +1 -1
- package/dist/esm/Root.js +1 -1
- package/dist/esm/Root.js.map +1 -1
- package/dist/esm/Root.mjs +1 -1
- package/dist/esm/Root.mjs.map +1 -1
- package/dist/esm/Root.native.js +1 -1
- package/dist/esm/Root.native.js.map +1 -1
- package/dist/esm/drawer.js +6 -0
- package/dist/esm/drawer.js.map +6 -0
- package/dist/esm/drawer.mjs +3 -0
- package/dist/esm/drawer.mjs.map +1 -0
- package/dist/esm/drawer.native.js +3 -0
- package/dist/esm/drawer.native.js.map +1 -0
- package/dist/esm/hooks.js +23 -2
- package/dist/esm/hooks.js.map +1 -1
- package/dist/esm/hooks.mjs +25 -3
- package/dist/esm/hooks.mjs.map +1 -1
- package/dist/esm/hooks.native.js +147 -15
- package/dist/esm/hooks.native.js.map +1 -1
- package/dist/esm/hooks.test.js +98 -0
- package/dist/esm/hooks.test.js.map +6 -0
- package/dist/esm/hooks.test.mjs +74 -0
- package/dist/esm/hooks.test.mjs.map +1 -0
- package/dist/esm/hooks.test.native.js +192 -0
- package/dist/esm/hooks.test.native.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/link/prefetchIntent.js +69 -0
- package/dist/esm/link/prefetchIntent.js.map +6 -0
- package/dist/esm/link/prefetchIntent.mjs +97 -0
- package/dist/esm/link/prefetchIntent.mjs.map +1 -0
- package/dist/esm/link/prefetchIntent.native.js +127 -0
- package/dist/esm/link/prefetchIntent.native.js.map +1 -0
- package/dist/esm/link/prefetchIntent.test.js +150 -0
- package/dist/esm/link/prefetchIntent.test.js.map +6 -0
- package/dist/esm/link/prefetchIntent.test.mjs +218 -0
- package/dist/esm/link/prefetchIntent.test.mjs.map +1 -0
- package/dist/esm/link/prefetchIntent.test.native.js +237 -0
- package/dist/esm/link/prefetchIntent.test.native.js.map +1 -0
- package/dist/esm/link/prefetchViewport.js +39 -0
- package/dist/esm/link/prefetchViewport.js.map +6 -0
- package/dist/esm/link/prefetchViewport.mjs +42 -0
- package/dist/esm/link/prefetchViewport.mjs.map +1 -0
- package/dist/esm/link/prefetchViewport.native.js +55 -0
- package/dist/esm/link/prefetchViewport.native.js.map +1 -0
- package/dist/esm/link/prefetchViewport.test.js +60 -0
- package/dist/esm/link/prefetchViewport.test.js.map +6 -0
- package/dist/esm/link/prefetchViewport.test.mjs +58 -0
- package/dist/esm/link/prefetchViewport.test.mjs.map +1 -0
- package/dist/esm/link/prefetchViewport.test.native.js +83 -0
- package/dist/esm/link/prefetchViewport.test.native.js.map +1 -0
- package/dist/esm/router/findRouteNode.js +28 -0
- package/dist/esm/router/findRouteNode.js.map +1 -1
- package/dist/esm/router/findRouteNode.mjs +26 -1
- package/dist/esm/router/findRouteNode.mjs.map +1 -1
- package/dist/esm/router/findRouteNode.native.js +31 -1
- package/dist/esm/router/findRouteNode.native.js.map +1 -1
- package/dist/esm/router/router.js +10 -11
- package/dist/esm/router/router.js.map +2 -2
- package/dist/esm/router/router.mjs +12 -14
- package/dist/esm/router/router.mjs.map +1 -1
- package/dist/esm/router/router.native.js +39 -123
- package/dist/esm/router/router.native.js.map +1 -1
- package/dist/esm/views/PreloadLinks.js +86 -17
- package/dist/esm/views/PreloadLinks.js.map +1 -1
- package/dist/esm/views/PreloadLinks.mjs +87 -14
- package/dist/esm/views/PreloadLinks.mjs.map +1 -1
- package/dist/esm/vite/one.js +3 -0
- package/dist/esm/vite/one.js.map +1 -1
- package/dist/esm/vite/one.mjs +3 -0
- package/dist/esm/vite/one.mjs.map +1 -1
- package/dist/esm/vite/one.native.js +4 -0
- package/dist/esm/vite/one.native.js.map +1 -1
- package/package.json +33 -12
- package/src/Root.tsx +1 -1
- package/src/drawer.ts +1 -0
- package/src/hooks.test.ts +157 -0
- package/src/hooks.tsx +79 -23
- package/src/index.ts +1 -0
- package/src/link/prefetchIntent.test.ts +416 -0
- package/src/link/prefetchIntent.ts +174 -0
- package/src/link/prefetchViewport.test.ts +120 -0
- package/src/link/prefetchViewport.ts +62 -0
- package/src/router/findRouteNode.ts +67 -0
- package/src/router/router.ts +68 -41
- package/src/views/PreloadLinks.tsx +156 -20
- package/src/vite/one.ts +4 -0
- package/src/vite/types.ts +12 -0
- package/types/drawer.d.ts +2 -0
- package/types/drawer.d.ts.map +1 -0
- package/types/hooks.d.ts +22 -0
- package/types/hooks.d.ts.map +1 -1
- package/types/hooks.test.d.ts +2 -0
- package/types/hooks.test.d.ts.map +1 -0
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/link/prefetchIntent.d.ts +43 -0
- package/types/link/prefetchIntent.d.ts.map +1 -0
- package/types/link/prefetchIntent.test.d.ts +2 -0
- package/types/link/prefetchIntent.test.d.ts.map +1 -0
- package/types/link/prefetchViewport.d.ts +16 -0
- package/types/link/prefetchViewport.d.ts.map +1 -0
- package/types/link/prefetchViewport.test.d.ts +2 -0
- package/types/link/prefetchViewport.test.d.ts.map +1 -0
- package/types/router/findRouteNode.d.ts +11 -0
- package/types/router/findRouteNode.d.ts.map +1 -1
- package/types/router/router.d.ts.map +1 -1
- package/types/views/PreloadLinks.d.ts +9 -0
- package/types/views/PreloadLinks.d.ts.map +1 -1
- package/types/vite/one.d.ts.map +1 -1
- package/types/vite/types.d.ts +11 -0
- package/types/vite/types.d.ts.map +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent-based link prefetching using mouse trajectory prediction.
|
|
3
|
+
*
|
|
4
|
+
* Uses ray-casting to detect when the user is moving toward a link,
|
|
5
|
+
* scoring by distance + perpendicular offset. Winner-takes-all prevents
|
|
6
|
+
* over-fetching when multiple links are in the path.
|
|
7
|
+
*
|
|
8
|
+
* Key features:
|
|
9
|
+
* - Batched IntersectionObserver for efficient rect measurement (300ms intervals)
|
|
10
|
+
* - Velocity smoothing to filter jitter
|
|
11
|
+
* - Prefetched links removed from future checks
|
|
12
|
+
* - Debounce between consecutive prefetches
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type PrefetchIntentOptions = {
|
|
16
|
+
onPrefetch: (href: string) => void
|
|
17
|
+
/** Max distance to consider (default: 600px) */
|
|
18
|
+
maxReach?: number
|
|
19
|
+
/** Penalty multiplier for off-axis aim (default: 4) */
|
|
20
|
+
perpWeight?: number
|
|
21
|
+
/** Min mouse speed to trigger (default: 6px/frame) */
|
|
22
|
+
minSpeed?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type RectEntry = { r: DOMRectReadOnly; h: string }
|
|
26
|
+
|
|
27
|
+
export function createPrefetchIntent(options: PrefetchIntentOptions) {
|
|
28
|
+
const { onPrefetch, maxReach = 500, perpWeight = 5, minSpeed = 8 } = options
|
|
29
|
+
|
|
30
|
+
const done = new Set<string>()
|
|
31
|
+
let rects: RectEntry[] = []
|
|
32
|
+
let px = 0,
|
|
33
|
+
py = 0,
|
|
34
|
+
vx = 0,
|
|
35
|
+
vy = 0
|
|
36
|
+
let moveCount = 0
|
|
37
|
+
let lastPrefetchMove = 0
|
|
38
|
+
|
|
39
|
+
function setRects(newRects: RectEntry[]) {
|
|
40
|
+
rects = newRects.filter((r) => !done.has(r.h))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function move(x: number, y: number, dx: number, dy: number) {
|
|
44
|
+
moveCount++
|
|
45
|
+
// less smoothing on first few moves to build velocity faster
|
|
46
|
+
const smooth = moveCount < 3 ? 0.3 : 0.6
|
|
47
|
+
vx = vx * smooth + dx * (1 - smooth)
|
|
48
|
+
vy = vy * smooth + dy * (1 - smooth)
|
|
49
|
+
px = x
|
|
50
|
+
py = y
|
|
51
|
+
|
|
52
|
+
const speed = Math.sqrt(vx * vx + vy * vy)
|
|
53
|
+
if (speed < minSpeed) return
|
|
54
|
+
|
|
55
|
+
// normalize velocity to unit vector
|
|
56
|
+
const ux = vx / speed
|
|
57
|
+
const uy = vy / speed
|
|
58
|
+
|
|
59
|
+
let best = Infinity
|
|
60
|
+
let href = ''
|
|
61
|
+
|
|
62
|
+
for (const { r, h } of rects) {
|
|
63
|
+
// vector from cursor to link center
|
|
64
|
+
const cx = (r.left + r.right) / 2 - px
|
|
65
|
+
const cy = (r.top + r.bottom) / 2 - py
|
|
66
|
+
const dist = Math.sqrt(cx * cx + cy * cy)
|
|
67
|
+
|
|
68
|
+
// project center onto velocity ray: how far along the ray?
|
|
69
|
+
const along = cx * ux + cy * uy
|
|
70
|
+
if (along < 0) continue // behind us
|
|
71
|
+
|
|
72
|
+
// perpendicular distance from ray to center
|
|
73
|
+
const perpX = cx - along * ux
|
|
74
|
+
const perpY = cy - along * uy
|
|
75
|
+
const perp = Math.sqrt(perpX * perpX + perpY * perpY)
|
|
76
|
+
|
|
77
|
+
// miss if perpendicular distance > half the link size + margin
|
|
78
|
+
// scale margin by distance - farther = need tighter aim
|
|
79
|
+
const baseRadius = Math.max(r.width, r.height) / 2 + 30
|
|
80
|
+
const distanceFactor = Math.max(0.2, 1 - dist / 1000)
|
|
81
|
+
const radius = baseRadius * distanceFactor + 20
|
|
82
|
+
if (perp > radius) continue
|
|
83
|
+
|
|
84
|
+
// score: balance distance and aim quality
|
|
85
|
+
// absolute perp matters more than relative - being 70px off at any distance is worse than 20px off
|
|
86
|
+
// score = distance + perp * perpWeight (lower is better)
|
|
87
|
+
const score = along + perp * perpWeight
|
|
88
|
+
if (score < best) {
|
|
89
|
+
best = score
|
|
90
|
+
href = h
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// winner takes all with max reach
|
|
95
|
+
// debounce: skip if we just prefetched on previous move
|
|
96
|
+
if (href && best < maxReach && moveCount - lastPrefetchMove > 1) {
|
|
97
|
+
done.add(href)
|
|
98
|
+
rects = rects.filter((r) => r.h !== href)
|
|
99
|
+
lastPrefetchMove = moveCount
|
|
100
|
+
onPrefetch(href)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// for integration with DOM
|
|
105
|
+
const nodes = new Map<HTMLElement, string>()
|
|
106
|
+
|
|
107
|
+
function observe(el: HTMLElement, href: string) {
|
|
108
|
+
nodes.set(el, href)
|
|
109
|
+
return () => {
|
|
110
|
+
nodes.delete(el)
|
|
111
|
+
done.delete(href)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { setRects, move, observe, nodes, done }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// singleton for actual DOM usage
|
|
119
|
+
let instance: ReturnType<typeof createPrefetchIntent> | null = null
|
|
120
|
+
let started = false
|
|
121
|
+
|
|
122
|
+
export function startPrefetchIntent(onPrefetch: (href: string) => void) {
|
|
123
|
+
if (started) return instance!
|
|
124
|
+
started = true
|
|
125
|
+
|
|
126
|
+
instance = createPrefetchIntent({ onPrefetch })
|
|
127
|
+
|
|
128
|
+
let frame = 0
|
|
129
|
+
let px = 0,
|
|
130
|
+
py = 0
|
|
131
|
+
|
|
132
|
+
// batch measure all nodes using single IntersectionObserver
|
|
133
|
+
function measure() {
|
|
134
|
+
if (!instance!.nodes.size) return setTimeout(measure, 300)
|
|
135
|
+
const io = new IntersectionObserver((entries) => {
|
|
136
|
+
io.disconnect()
|
|
137
|
+
instance!.setRects(
|
|
138
|
+
entries
|
|
139
|
+
.filter((e) => e.isIntersecting)
|
|
140
|
+
.map((e) => ({
|
|
141
|
+
r: e.boundingClientRect,
|
|
142
|
+
h: instance!.nodes.get(e.target as HTMLElement)!,
|
|
143
|
+
}))
|
|
144
|
+
.filter((x) => x.h)
|
|
145
|
+
)
|
|
146
|
+
setTimeout(measure, 300)
|
|
147
|
+
})
|
|
148
|
+
instance!.nodes.forEach((_, el) => io.observe(el))
|
|
149
|
+
}
|
|
150
|
+
measure()
|
|
151
|
+
|
|
152
|
+
document.addEventListener(
|
|
153
|
+
'mousemove',
|
|
154
|
+
(e) => {
|
|
155
|
+
// throttle to every 4th frame (~66ms at 60fps)
|
|
156
|
+
if (++frame % 4) return
|
|
157
|
+
|
|
158
|
+
const dx = e.clientX - px
|
|
159
|
+
const dy = e.clientY - py
|
|
160
|
+
px = e.clientX
|
|
161
|
+
py = e.clientY
|
|
162
|
+
|
|
163
|
+
instance!.move(px, py, dx, dy)
|
|
164
|
+
},
|
|
165
|
+
{ passive: true }
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return instance
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function observePrefetchIntent(el: HTMLElement, href: string) {
|
|
172
|
+
if (!instance) return () => {}
|
|
173
|
+
return instance.observe(el, href)
|
|
174
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { createPrefetchViewport } from './prefetchViewport'
|
|
3
|
+
|
|
4
|
+
// mock IntersectionObserver
|
|
5
|
+
let mockIOCallback: IntersectionObserverCallback
|
|
6
|
+
const mockObserved = new Set<Element>()
|
|
7
|
+
|
|
8
|
+
vi.stubGlobal(
|
|
9
|
+
'IntersectionObserver',
|
|
10
|
+
class {
|
|
11
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
12
|
+
mockIOCallback = callback
|
|
13
|
+
}
|
|
14
|
+
observe(el: Element) {
|
|
15
|
+
mockObserved.add(el)
|
|
16
|
+
}
|
|
17
|
+
unobserve(el: Element) {
|
|
18
|
+
mockObserved.delete(el)
|
|
19
|
+
}
|
|
20
|
+
disconnect() {
|
|
21
|
+
mockObserved.clear()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
function simulateIntersect(el: Element, isIntersecting: boolean) {
|
|
27
|
+
mockIOCallback(
|
|
28
|
+
[{ target: el, isIntersecting } as IntersectionObserverEntry],
|
|
29
|
+
{} as IntersectionObserver
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('prefetchViewport', () => {
|
|
34
|
+
let prefetched: string[]
|
|
35
|
+
let vp: ReturnType<typeof createPrefetchViewport>
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
prefetched = []
|
|
39
|
+
mockObserved.clear()
|
|
40
|
+
vp = createPrefetchViewport()
|
|
41
|
+
vp.start((href) => prefetched.push(href))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('prefetches when link enters viewport', () => {
|
|
45
|
+
const el = {} as HTMLElement
|
|
46
|
+
vp.observe(el, '/about')
|
|
47
|
+
|
|
48
|
+
simulateIntersect(el, true)
|
|
49
|
+
expect(prefetched).toEqual(['/about'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not prefetch when link exits viewport', () => {
|
|
53
|
+
const el = {} as HTMLElement
|
|
54
|
+
vp.observe(el, '/about')
|
|
55
|
+
|
|
56
|
+
simulateIntersect(el, false)
|
|
57
|
+
expect(prefetched).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('only prefetches each href once', () => {
|
|
61
|
+
const el = {} as HTMLElement
|
|
62
|
+
vp.observe(el, '/about')
|
|
63
|
+
|
|
64
|
+
simulateIntersect(el, true)
|
|
65
|
+
simulateIntersect(el, false)
|
|
66
|
+
simulateIntersect(el, true)
|
|
67
|
+
|
|
68
|
+
expect(prefetched).toEqual(['/about'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('cleanup re-enables prefetch for href', () => {
|
|
72
|
+
const el = {} as HTMLElement
|
|
73
|
+
const cleanup = vp.observe(el, '/about')
|
|
74
|
+
|
|
75
|
+
simulateIntersect(el, true)
|
|
76
|
+
expect(prefetched).toEqual(['/about'])
|
|
77
|
+
|
|
78
|
+
cleanup()
|
|
79
|
+
prefetched.length = 0
|
|
80
|
+
|
|
81
|
+
// re-observe same href after cleanup
|
|
82
|
+
vp.observe(el, '/about')
|
|
83
|
+
simulateIntersect(el, true)
|
|
84
|
+
expect(prefetched).toEqual(['/about'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('memory and performance', () => {
|
|
88
|
+
it('does not leak elements after cleanup', () => {
|
|
89
|
+
const elements: HTMLElement[] = []
|
|
90
|
+
const cleanups: (() => void)[] = []
|
|
91
|
+
|
|
92
|
+
// add 100 elements
|
|
93
|
+
for (let i = 0; i < 100; i++) {
|
|
94
|
+
const el = {} as HTMLElement
|
|
95
|
+
elements.push(el)
|
|
96
|
+
cleanups.push(vp.observe(el, `/page-${i}`))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
expect(vp.nodes.size).toBe(100)
|
|
100
|
+
|
|
101
|
+
// cleanup all
|
|
102
|
+
cleanups.forEach((c) => c())
|
|
103
|
+
|
|
104
|
+
expect(vp.nodes.size).toBe(0)
|
|
105
|
+
expect(vp.done.size).toBe(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('handles rapid observe/unobserve cycles', () => {
|
|
109
|
+
const el = {} as HTMLElement
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < 100; i++) {
|
|
112
|
+
const cleanup = vp.observe(el, '/test')
|
|
113
|
+
cleanup()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// should not leak
|
|
117
|
+
expect(vp.nodes.size).toBe(0)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport-based link prefetching using IntersectionObserver.
|
|
3
|
+
*
|
|
4
|
+
* Prefetches links when they enter the viewport with 100px margin.
|
|
5
|
+
* Lighter weight than intent-based, good for content-heavy pages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ViewportPrefetcher = ReturnType<typeof createPrefetchViewport>
|
|
9
|
+
|
|
10
|
+
export function createPrefetchViewport() {
|
|
11
|
+
const done = new Set<string>()
|
|
12
|
+
const nodes = new Map<HTMLElement, string>()
|
|
13
|
+
let io: IntersectionObserver | null = null
|
|
14
|
+
let onPrefetch: ((href: string) => void) | null = null
|
|
15
|
+
|
|
16
|
+
function getObserver() {
|
|
17
|
+
if (io) return io
|
|
18
|
+
io = new IntersectionObserver(
|
|
19
|
+
(entries) => {
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (!entry.isIntersecting) continue
|
|
22
|
+
const href = nodes.get(entry.target as HTMLElement)
|
|
23
|
+
if (href && !done.has(href)) {
|
|
24
|
+
done.add(href)
|
|
25
|
+
onPrefetch?.(href)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{ threshold: 0.5 } // fires when mostly visible
|
|
30
|
+
)
|
|
31
|
+
return io
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function start(prefetch: (href: string) => void) {
|
|
35
|
+
onPrefetch = prefetch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function observe(el: HTMLElement, href: string) {
|
|
39
|
+
nodes.set(el, href)
|
|
40
|
+
getObserver().observe(el)
|
|
41
|
+
return () => {
|
|
42
|
+
nodes.delete(el)
|
|
43
|
+
io?.unobserve(el)
|
|
44
|
+
done.delete(href)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { start, observe, done, nodes }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// singleton for actual DOM usage
|
|
52
|
+
let instance: ViewportPrefetcher | null = null
|
|
53
|
+
|
|
54
|
+
export function startPrefetchViewport(prefetch: (href: string) => void) {
|
|
55
|
+
if (!instance) instance = createPrefetchViewport()
|
|
56
|
+
instance.start(prefetch)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function observePrefetchViewport(el: HTMLElement, href: string) {
|
|
60
|
+
if (!instance) instance = createPrefetchViewport()
|
|
61
|
+
return instance.observe(el, href)
|
|
62
|
+
}
|
|
@@ -125,3 +125,70 @@ export function extractPathnameFromHref(href: string): string {
|
|
|
125
125
|
|
|
126
126
|
return href.slice(0, endIndex)
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Find all route nodes from root to the current page based on navigation state.
|
|
131
|
+
* Returns an array of RouteNodes in order from root layout to the page.
|
|
132
|
+
* This is used on native to build the full matches array including layouts.
|
|
133
|
+
*/
|
|
134
|
+
export function findAllRouteNodesFromState(
|
|
135
|
+
state: { routes: Array<{ name: string; state?: any }> } | undefined,
|
|
136
|
+
rootNode: RouteNode | null
|
|
137
|
+
): RouteNode[] {
|
|
138
|
+
if (!state || !rootNode) {
|
|
139
|
+
return []
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const nodes: RouteNode[] = []
|
|
143
|
+
|
|
144
|
+
function collectNodes(
|
|
145
|
+
currentState: { routes: Array<{ name: string; state?: any; params?: Record<string, any> }> } | undefined,
|
|
146
|
+
parentNode: RouteNode | null
|
|
147
|
+
) {
|
|
148
|
+
if (!currentState || !parentNode) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// get the current route from state (the active one based on index)
|
|
153
|
+
const currentRoute = currentState.routes[currentState.routes.length - 1]
|
|
154
|
+
if (!currentRoute) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// find the matching child node
|
|
159
|
+
const matchingNode = findNodeByRouteName(parentNode, currentRoute.name)
|
|
160
|
+
if (!matchingNode) {
|
|
161
|
+
if (process.env.NODE_ENV === 'development') {
|
|
162
|
+
console.log('[one] findAllRouteNodesFromState: could not find node for', currentRoute.name, 'in', parentNode.route)
|
|
163
|
+
}
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// add this node to the list
|
|
168
|
+
nodes.push(matchingNode)
|
|
169
|
+
|
|
170
|
+
// if there's a nested state, continue recursively
|
|
171
|
+
if (currentRoute.state && currentRoute.state.routes) {
|
|
172
|
+
collectNodes(currentRoute.state, matchingNode)
|
|
173
|
+
} else if (currentRoute.params?.screen) {
|
|
174
|
+
// react navigation uses params.screen to specify child route when state isn't created yet
|
|
175
|
+
// this happens on initial render before the nested navigator mounts
|
|
176
|
+
const childRouteName = currentRoute.params.screen as string
|
|
177
|
+
const childNode = matchingNode.children.find((c) => c.route === childRouteName)
|
|
178
|
+
if (childNode) {
|
|
179
|
+
nodes.push(childNode)
|
|
180
|
+
// if there are nested params, continue recursively
|
|
181
|
+
if (currentRoute.params.params && (currentRoute.params.params as any).screen) {
|
|
182
|
+
collectNodes(
|
|
183
|
+
{ routes: [{ name: childRouteName, params: currentRoute.params.params }] },
|
|
184
|
+
childNode
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
collectNodes(state, rootNode)
|
|
192
|
+
return nodes
|
|
193
|
+
}
|
|
194
|
+
|
package/src/router/router.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
extractPathnameFromHref,
|
|
38
38
|
extractSearchFromHref,
|
|
39
39
|
findRouteNodeFromState,
|
|
40
|
+
findAllRouteNodesFromState,
|
|
40
41
|
} from './findRouteNode'
|
|
41
42
|
import type { UrlObject } from './getNormalizedStatePath'
|
|
42
43
|
import { getRouteInfo } from './getRouteInfo'
|
|
@@ -399,6 +400,16 @@ export function updateState(state: OneRouter.ResultState, nextStateParam = state
|
|
|
399
400
|
console.info(`[one] 🧭 ${from} → ${to}`, params ? { params } : '')
|
|
400
401
|
}
|
|
401
402
|
routeInfo = nextRouteInfo
|
|
403
|
+
|
|
404
|
+
// On native, update client matches when route changes
|
|
405
|
+
// This enables useMatches to work for initial route and navigation
|
|
406
|
+
// Loader data will be undefined initially (fetched by useLoader)
|
|
407
|
+
if (process.env.TAMAGUI_TARGET === 'native') {
|
|
408
|
+
const params = extractParamsFromState(state)
|
|
409
|
+
const newMatches = buildNativeMatches(state, nextRouteInfo.pathname, params)
|
|
410
|
+
currentMatches = newMatches
|
|
411
|
+
setClientMatches(newMatches)
|
|
412
|
+
}
|
|
402
413
|
}
|
|
403
414
|
|
|
404
415
|
// Expose devtools API in development
|
|
@@ -717,45 +728,43 @@ export function getPreloadHistory(): PreloadEntry[] {
|
|
|
717
728
|
}
|
|
718
729
|
|
|
719
730
|
export function preloadRoute(href: string, injectCSS = false): Promise<any> | undefined {
|
|
720
|
-
if (process.env.TAMAGUI_TARGET
|
|
721
|
-
|
|
722
|
-
|
|
731
|
+
if (process.env.TAMAGUI_TARGET !== 'native') {
|
|
732
|
+
// in dev mode, use a simpler preload that just fetches the loader directly
|
|
733
|
+
// this avoids issues with production-only preload paths while still ensuring
|
|
734
|
+
// loader data is available before navigation completes
|
|
735
|
+
if (process.env.NODE_ENV === 'development') {
|
|
736
|
+
// normalize the path to match what useLoader uses for cache keys
|
|
737
|
+
const normalizedHref = normalizeLoaderPath(href)
|
|
738
|
+
if (!preloadingLoader[normalizedHref]) {
|
|
739
|
+
preloadingLoader[normalizedHref] = doPreloadDev(href).then((data) => {
|
|
740
|
+
preloadedLoaderData[normalizedHref] = data
|
|
741
|
+
return data
|
|
742
|
+
})
|
|
743
|
+
}
|
|
744
|
+
return preloadingLoader[normalizedHref]
|
|
745
|
+
}
|
|
723
746
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
// normalize the path to match what useLoader uses for cache keys
|
|
729
|
-
const normalizedHref = normalizeLoaderPath(href)
|
|
730
|
-
if (!preloadingLoader[normalizedHref]) {
|
|
731
|
-
preloadingLoader[normalizedHref] = doPreloadDev(href).then((data) => {
|
|
732
|
-
preloadedLoaderData[normalizedHref] = data
|
|
747
|
+
if (!preloadingLoader[href]) {
|
|
748
|
+
preloadingLoader[href] = doPreload(href).then((data) => {
|
|
749
|
+
// Store the resolved data for synchronous access
|
|
750
|
+
preloadedLoaderData[href] = data
|
|
733
751
|
return data
|
|
734
752
|
})
|
|
735
753
|
}
|
|
736
|
-
return preloadingLoader[normalizedHref]
|
|
737
|
-
}
|
|
738
754
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
755
|
+
if (injectCSS) {
|
|
756
|
+
// Wait for preload to populate cssInjectFunctions, then inject CSS (max 800ms)
|
|
757
|
+
return preloadingLoader[href]?.then(async (data) => {
|
|
758
|
+
const inject = cssInjectFunctions[href]
|
|
759
|
+
if (inject) {
|
|
760
|
+
await Promise.race([inject(), new Promise((r) => setTimeout(r, 800))])
|
|
761
|
+
}
|
|
762
|
+
return data
|
|
763
|
+
})
|
|
764
|
+
}
|
|
746
765
|
|
|
747
|
-
|
|
748
|
-
// Wait for preload to populate cssInjectFunctions, then inject CSS (max 500ms)
|
|
749
|
-
return preloadingLoader[href]?.then(async (data) => {
|
|
750
|
-
const inject = cssInjectFunctions[href]
|
|
751
|
-
if (inject) {
|
|
752
|
-
await Promise.race([inject(), new Promise((r) => setTimeout(r, 500))])
|
|
753
|
-
}
|
|
754
|
-
return data
|
|
755
|
-
})
|
|
766
|
+
return preloadingLoader[href]
|
|
756
767
|
}
|
|
757
|
-
|
|
758
|
-
return preloadingLoader[href]
|
|
759
768
|
}
|
|
760
769
|
|
|
761
770
|
// normalize path to match what useLoader uses for currentPath
|
|
@@ -797,6 +806,25 @@ function buildClientMatches(
|
|
|
797
806
|
return [...layoutMatches, pageMatch]
|
|
798
807
|
}
|
|
799
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Build all matches for native, including layouts and page.
|
|
811
|
+
* Unlike web which preserves SSR-hydrated layouts, native builds fresh
|
|
812
|
+
* since there's no SSR context to hydrate from.
|
|
813
|
+
*/
|
|
814
|
+
function buildNativeMatches(
|
|
815
|
+
state: OneRouter.ResultState,
|
|
816
|
+
pathname: string,
|
|
817
|
+
params: Record<string, string | string[]>
|
|
818
|
+
): RouteMatch[] {
|
|
819
|
+
const allNodes = findAllRouteNodesFromState(state, routeNode)
|
|
820
|
+
return allNodes.map((node) => ({
|
|
821
|
+
routeId: node.contextKey || pathname,
|
|
822
|
+
pathname,
|
|
823
|
+
params,
|
|
824
|
+
loaderData: undefined, // loader data is fetched async by useLoader on native
|
|
825
|
+
}))
|
|
826
|
+
}
|
|
827
|
+
|
|
800
828
|
/**
|
|
801
829
|
* Initialize client matches from server context during hydration.
|
|
802
830
|
* Called from createApp when hydrating.
|
|
@@ -958,15 +986,14 @@ export async function linkTo(
|
|
|
958
986
|
}
|
|
959
987
|
|
|
960
988
|
// Update client matches for useMatches hook
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
}
|
|
989
|
+
// On web: runs after preload so loaderData is available
|
|
990
|
+
// On native: runs without preloaded data (loaders are fetched by useLoader)
|
|
991
|
+
const normalizedPath = normalizeLoaderPath(href)
|
|
992
|
+
const loaderData = preloadedLoaderData[normalizedPath]
|
|
993
|
+
const params = extractParamsFromState(state)
|
|
994
|
+
const newMatches = buildClientMatches(href, matchingRouteNode, params, loaderData)
|
|
995
|
+
currentMatches = newMatches
|
|
996
|
+
setClientMatches(newMatches)
|
|
970
997
|
|
|
971
998
|
const rootState = navigationRef.getRootState()
|
|
972
999
|
|