one 1.2.78 → 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
- /package/types/vercel/build/generate/{createSsrServerlessFunction.d.ts → createSSRServerlessFunction.d.ts} +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// test the ReadOnlyURLSearchParams behavior directly
|
|
4
|
+
// (hooks require React context, so we test the URLSearchParams logic separately)
|
|
5
|
+
|
|
6
|
+
class ReadOnlyURLSearchParams extends URLSearchParams {
|
|
7
|
+
override set(_name: string, _value: string): void {
|
|
8
|
+
throw new Error('useSearchParams returns a read-only URLSearchParams object')
|
|
9
|
+
}
|
|
10
|
+
override append(_name: string, _value: string): void {
|
|
11
|
+
throw new Error('useSearchParams returns a read-only URLSearchParams object')
|
|
12
|
+
}
|
|
13
|
+
override delete(_name: string, _value?: string): void {
|
|
14
|
+
throw new Error('useSearchParams returns a read-only URLSearchParams object')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('ReadOnlyURLSearchParams', () => {
|
|
19
|
+
it('should allow reading values', () => {
|
|
20
|
+
const params = new ReadOnlyURLSearchParams([
|
|
21
|
+
['sort', 'price'],
|
|
22
|
+
['category', 'electronics'],
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
expect(params.get('sort')).toBe('price')
|
|
26
|
+
expect(params.get('category')).toBe('electronics')
|
|
27
|
+
expect(params.get('nonexistent')).toBe(null)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should support has()', () => {
|
|
31
|
+
const params = new ReadOnlyURLSearchParams([['key', 'value']])
|
|
32
|
+
|
|
33
|
+
expect(params.has('key')).toBe(true)
|
|
34
|
+
expect(params.has('missing')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should support getAll() for repeated params', () => {
|
|
38
|
+
const params = new ReadOnlyURLSearchParams([
|
|
39
|
+
['tag', 'a'],
|
|
40
|
+
['tag', 'b'],
|
|
41
|
+
['tag', 'c'],
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
expect(params.getAll('tag')).toEqual(['a', 'b', 'c'])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should support iteration', () => {
|
|
48
|
+
const params = new ReadOnlyURLSearchParams([
|
|
49
|
+
['a', '1'],
|
|
50
|
+
['b', '2'],
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
const entries = Array.from(params.entries())
|
|
54
|
+
expect(entries).toEqual([
|
|
55
|
+
['a', '1'],
|
|
56
|
+
['b', '2'],
|
|
57
|
+
])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should support toString()', () => {
|
|
61
|
+
const params = new ReadOnlyURLSearchParams([
|
|
62
|
+
['sort', 'price'],
|
|
63
|
+
['page', '1'],
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
expect(params.toString()).toBe('sort=price&page=1')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should throw on set()', () => {
|
|
70
|
+
const params = new ReadOnlyURLSearchParams([['key', 'value']])
|
|
71
|
+
|
|
72
|
+
expect(() => params.set('key', 'new')).toThrow(
|
|
73
|
+
'useSearchParams returns a read-only URLSearchParams object'
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should throw on append()', () => {
|
|
78
|
+
const params = new ReadOnlyURLSearchParams([['key', 'value']])
|
|
79
|
+
|
|
80
|
+
expect(() => params.append('key', 'another')).toThrow(
|
|
81
|
+
'useSearchParams returns a read-only URLSearchParams object'
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should throw on delete()', () => {
|
|
86
|
+
const params = new ReadOnlyURLSearchParams([['key', 'value']])
|
|
87
|
+
|
|
88
|
+
expect(() => params.delete('key')).toThrow(
|
|
89
|
+
'useSearchParams returns a read-only URLSearchParams object'
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('useSearchParams param conversion', () => {
|
|
95
|
+
// test the logic that converts params object to URLSearchParams entries
|
|
96
|
+
|
|
97
|
+
function paramsToEntries(
|
|
98
|
+
params: Record<string, string | string[] | undefined>
|
|
99
|
+
): [string, string][] {
|
|
100
|
+
return Object.entries(params).flatMap(([key, value]) => {
|
|
101
|
+
if (value === undefined) return []
|
|
102
|
+
return Array.isArray(value)
|
|
103
|
+
? value.map((v) => [key, String(v)] as [string, string])
|
|
104
|
+
: [[key, String(value)] as [string, string]]
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it('should convert simple string params', () => {
|
|
109
|
+
const params = { sort: 'price', page: '1' }
|
|
110
|
+
const entries = paramsToEntries(params)
|
|
111
|
+
|
|
112
|
+
expect(entries).toEqual([
|
|
113
|
+
['sort', 'price'],
|
|
114
|
+
['page', '1'],
|
|
115
|
+
])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('should expand array params into multiple entries', () => {
|
|
119
|
+
const params = { tags: ['a', 'b', 'c'] }
|
|
120
|
+
const entries = paramsToEntries(params)
|
|
121
|
+
|
|
122
|
+
expect(entries).toEqual([
|
|
123
|
+
['tags', 'a'],
|
|
124
|
+
['tags', 'b'],
|
|
125
|
+
['tags', 'c'],
|
|
126
|
+
])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should filter out undefined values', () => {
|
|
130
|
+
const params = { present: 'yes', missing: undefined }
|
|
131
|
+
const entries = paramsToEntries(params)
|
|
132
|
+
|
|
133
|
+
expect(entries).toEqual([['present', 'yes']])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('should handle mixed params', () => {
|
|
137
|
+
const params = {
|
|
138
|
+
single: 'value',
|
|
139
|
+
multiple: ['x', 'y'],
|
|
140
|
+
absent: undefined,
|
|
141
|
+
}
|
|
142
|
+
const entries = paramsToEntries(params)
|
|
143
|
+
|
|
144
|
+
expect(entries).toEqual([
|
|
145
|
+
['single', 'value'],
|
|
146
|
+
['multiple', 'x'],
|
|
147
|
+
['multiple', 'y'],
|
|
148
|
+
])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should handle empty params', () => {
|
|
152
|
+
const params = {}
|
|
153
|
+
const entries = paramsToEntries(params)
|
|
154
|
+
|
|
155
|
+
expect(entries).toEqual([])
|
|
156
|
+
})
|
|
157
|
+
})
|
package/src/hooks.tsx
CHANGED
|
@@ -184,27 +184,83 @@ export const useGlobalSearchParams = useActiveParams
|
|
|
184
184
|
export function useParams<TParams extends object = SearchParams>(): Partial<TParams> {
|
|
185
185
|
const params = React.useContext(RouteParamsContext) ?? {}
|
|
186
186
|
|
|
187
|
-
return
|
|
188
|
-
Object.
|
|
189
|
-
.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
187
|
+
return React.useMemo(() => {
|
|
188
|
+
return Object.fromEntries(
|
|
189
|
+
Object.entries(params)
|
|
190
|
+
.filter(([_, value]) => value !== undefined)
|
|
191
|
+
.map(([key, value]) => {
|
|
192
|
+
if (Array.isArray(value)) {
|
|
193
|
+
return [
|
|
194
|
+
key,
|
|
195
|
+
value.map((v) => {
|
|
196
|
+
try {
|
|
197
|
+
return decodeURIComponent(v)
|
|
198
|
+
} catch {
|
|
199
|
+
return v
|
|
200
|
+
}
|
|
201
|
+
}),
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
return [key, decodeURIComponent(value as string)]
|
|
206
|
+
} catch {
|
|
207
|
+
return [key, value]
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
) as TParams
|
|
211
|
+
}, [params])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
class ReadOnlyURLSearchParams extends URLSearchParams {
|
|
215
|
+
override set(_name: string, _value: string): void {
|
|
216
|
+
throw new Error('useSearchParams returns a read-only URLSearchParams object')
|
|
217
|
+
}
|
|
218
|
+
override append(_name: string, _value: string): void {
|
|
219
|
+
throw new Error('useSearchParams returns a read-only URLSearchParams object')
|
|
220
|
+
}
|
|
221
|
+
override delete(_name: string, _value?: string): void {
|
|
222
|
+
throw new Error('useSearchParams returns a read-only URLSearchParams object')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Returns URL search parameters as a read-only URLSearchParams object.
|
|
228
|
+
* Use this when you need the standard web URLSearchParams API.
|
|
229
|
+
*
|
|
230
|
+
* @param options.global - If true, returns params that update even when route is not focused
|
|
231
|
+
* @returns Read-only URLSearchParams object
|
|
232
|
+
* @link https://onestack.dev/docs/api/hooks/useSearchParams
|
|
233
|
+
* @see useParams for a plain object with both path and search params
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```tsx
|
|
237
|
+
* // URL: /products?sort=price&category=electronics
|
|
238
|
+
* const searchParams = useSearchParams()
|
|
239
|
+
* searchParams.get('sort') // 'price'
|
|
240
|
+
* searchParams.get('category') // 'electronics'
|
|
241
|
+
* searchParams.has('sort') // true
|
|
242
|
+
* searchParams.getAll('tag') // ['a', 'b'] for ?tag=a&tag=b
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
export function useSearchParams({ global = false } = {}): URLSearchParams {
|
|
246
|
+
const globalRef = React.useRef(global)
|
|
247
|
+
|
|
248
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
249
|
+
if (global !== globalRef.current) {
|
|
250
|
+
console.warn("useSearchParams: the 'global' option cannot change between renders")
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// biome-ignore lint/correctness/useHookAtTopLevel: global option is stable (validated above)
|
|
255
|
+
const params = global ? useActiveParams() : useParams()
|
|
256
|
+
|
|
257
|
+
return React.useMemo(() => {
|
|
258
|
+
const entries = Object.entries(params).flatMap(([key, value]) => {
|
|
259
|
+
if (value === undefined) return []
|
|
260
|
+
return Array.isArray(value)
|
|
261
|
+
? value.map((v) => [key, String(v)] as [string, string])
|
|
262
|
+
: [[key, String(value)] as [string, string]]
|
|
263
|
+
})
|
|
264
|
+
return new ReadOnlyURLSearchParams(entries)
|
|
265
|
+
}, [params])
|
|
210
266
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { createPrefetchIntent, type PrefetchIntentOptions } from './prefetchIntent'
|
|
3
|
+
|
|
4
|
+
// helper to create a mock rect
|
|
5
|
+
const rect = (x: number, y: number, w = 100, h = 40): DOMRectReadOnly => ({
|
|
6
|
+
left: x,
|
|
7
|
+
top: y,
|
|
8
|
+
right: x + w,
|
|
9
|
+
bottom: y + h,
|
|
10
|
+
width: w,
|
|
11
|
+
height: h,
|
|
12
|
+
x,
|
|
13
|
+
y,
|
|
14
|
+
toJSON: () => ({}),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('prefetchIntent', () => {
|
|
18
|
+
let prefetched: string[]
|
|
19
|
+
let intent: ReturnType<typeof createPrefetchIntent>
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
prefetched = []
|
|
23
|
+
intent = createPrefetchIntent({
|
|
24
|
+
onPrefetch: (href) => prefetched.push(href),
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('basic targeting', () => {
|
|
29
|
+
it('prefetches when moving directly toward a link', () => {
|
|
30
|
+
intent.setRects([{ r: rect(500, 300), h: '/about' }])
|
|
31
|
+
|
|
32
|
+
// start at 100,300 moving right toward link at 500,300
|
|
33
|
+
// need larger movements to exceed minSpeed of 8
|
|
34
|
+
intent.move(100, 300, 0, 0) // initial position
|
|
35
|
+
intent.move(140, 300, 40, 0) // moving right
|
|
36
|
+
intent.move(200, 300, 60, 0) // still moving right, building velocity
|
|
37
|
+
|
|
38
|
+
expect(prefetched).toEqual(['/about'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('does not prefetch when moving away from a link', () => {
|
|
42
|
+
intent.setRects([{ r: rect(500, 300), h: '/about' }])
|
|
43
|
+
|
|
44
|
+
// start at 400,300 moving left (away from link)
|
|
45
|
+
intent.move(400, 300, 0, 0)
|
|
46
|
+
intent.move(380, 300, -20, 0)
|
|
47
|
+
intent.move(350, 300, -30, 0)
|
|
48
|
+
|
|
49
|
+
expect(prefetched).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not prefetch when moving perpendicular to a link', () => {
|
|
53
|
+
intent.setRects([{ r: rect(500, 300), h: '/about' }])
|
|
54
|
+
|
|
55
|
+
// start at 200,300 moving down (perpendicular)
|
|
56
|
+
intent.move(200, 300, 0, 0)
|
|
57
|
+
intent.move(200, 330, 0, 30)
|
|
58
|
+
intent.move(200, 370, 0, 40)
|
|
59
|
+
|
|
60
|
+
expect(prefetched).toEqual([])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('prefetches link at long distance with good aim', () => {
|
|
64
|
+
// link ~350px from cursor position after moves (within maxReach of 500)
|
|
65
|
+
// rect at 500,280 so center is at 550,300 - dead on y=300 trajectory
|
|
66
|
+
intent.setRects([{ r: rect(500, 280), h: '/far' }])
|
|
67
|
+
|
|
68
|
+
// start at 100,300 moving right toward 550,300
|
|
69
|
+
intent.move(100, 300, 0, 0)
|
|
70
|
+
intent.move(140, 300, 40, 0)
|
|
71
|
+
intent.move(200, 300, 60, 0)
|
|
72
|
+
|
|
73
|
+
expect(prefetched).toEqual(['/far'])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('does not prefetch when aim is slightly off at long distance', () => {
|
|
77
|
+
intent.setRects([{ r: rect(600, 300), h: '/far' }])
|
|
78
|
+
|
|
79
|
+
// moving toward 800,500 instead of 800,300 - will miss
|
|
80
|
+
intent.move(100, 300, 0, 0)
|
|
81
|
+
intent.move(140, 320, 40, 20)
|
|
82
|
+
intent.move(200, 360, 60, 40)
|
|
83
|
+
|
|
84
|
+
expect(prefetched).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('prevents over-fetching', () => {
|
|
89
|
+
it('only prefetches each href once', () => {
|
|
90
|
+
intent.setRects([{ r: rect(500, 300), h: '/about' }])
|
|
91
|
+
|
|
92
|
+
// need larger movements to exceed minSpeed of 8
|
|
93
|
+
intent.move(100, 300, 0, 0)
|
|
94
|
+
intent.move(140, 300, 40, 0)
|
|
95
|
+
intent.move(200, 300, 60, 0)
|
|
96
|
+
// first prefetch
|
|
97
|
+
expect(prefetched).toEqual(['/about'])
|
|
98
|
+
|
|
99
|
+
// simulate leaving and coming back
|
|
100
|
+
intent.move(100, 300, -100, 0)
|
|
101
|
+
intent.move(140, 300, 40, 0)
|
|
102
|
+
intent.move(200, 300, 60, 0)
|
|
103
|
+
|
|
104
|
+
// should not prefetch again
|
|
105
|
+
expect(prefetched).toEqual(['/about'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('removes prefetched links from future consideration', () => {
|
|
109
|
+
intent.setRects([
|
|
110
|
+
{ r: rect(400, 300), h: '/first' },
|
|
111
|
+
{ r: rect(600, 300), h: '/second' },
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
// move toward first link
|
|
115
|
+
intent.move(100, 300, 0, 0)
|
|
116
|
+
intent.move(150, 300, 50, 0)
|
|
117
|
+
expect(prefetched).toEqual(['/first'])
|
|
118
|
+
|
|
119
|
+
// keep moving, should eventually hit second
|
|
120
|
+
intent.move(250, 300, 100, 0)
|
|
121
|
+
intent.move(350, 300, 100, 0)
|
|
122
|
+
expect(prefetched).toEqual(['/first', '/second'])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('winner-takes-all with clustered links', () => {
|
|
127
|
+
it('only prefetches the best target when multiple links are in path', () => {
|
|
128
|
+
// 3 links stacked vertically, all roughly in the direction of travel
|
|
129
|
+
intent.setRects([
|
|
130
|
+
{ r: rect(500, 280), h: '/top' },
|
|
131
|
+
{ r: rect(500, 320), h: '/middle' },
|
|
132
|
+
{ r: rect(500, 360), h: '/bottom' },
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
// moving right toward middle link
|
|
136
|
+
intent.move(100, 340, 0, 0)
|
|
137
|
+
intent.move(150, 340, 50, 0)
|
|
138
|
+
|
|
139
|
+
// should only get the closest/best aimed one
|
|
140
|
+
expect(prefetched.length).toBe(1)
|
|
141
|
+
expect(prefetched[0]).toBe('/middle')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('handles dense nav with 20 links', () => {
|
|
145
|
+
// simulate a nav bar with 20 links spread horizontally
|
|
146
|
+
const links = Array.from({ length: 20 }, (_, i) => ({
|
|
147
|
+
r: rect(100 + i * 60, 50, 50, 30),
|
|
148
|
+
h: `/nav-${i}`,
|
|
149
|
+
}))
|
|
150
|
+
intent.setRects(links)
|
|
151
|
+
|
|
152
|
+
// move toward roughly link 10 (x=700)
|
|
153
|
+
intent.move(700, 200, 0, 0)
|
|
154
|
+
intent.move(700, 150, 0, -50)
|
|
155
|
+
intent.move(700, 100, 0, -50)
|
|
156
|
+
|
|
157
|
+
// should only prefetch one, the closest
|
|
158
|
+
expect(prefetched.length).toBe(1)
|
|
159
|
+
expect(prefetched[0]).toBe('/nav-10')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('picks closer link when two are roughly aligned', () => {
|
|
163
|
+
intent.setRects([
|
|
164
|
+
{ r: rect(300, 300), h: '/near' },
|
|
165
|
+
{ r: rect(600, 300), h: '/far' },
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
// moving right from origin
|
|
169
|
+
intent.move(100, 320, 0, 0)
|
|
170
|
+
intent.move(150, 320, 50, 0)
|
|
171
|
+
|
|
172
|
+
expect(prefetched).toEqual(['/near'])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('picks better-aimed link over closer link', () => {
|
|
176
|
+
intent.setRects([
|
|
177
|
+
{ r: rect(200, 400), h: '/close-but-off' }, // closer but significantly off-axis
|
|
178
|
+
{ r: rect(400, 300), h: '/far-but-aimed' }, // farther but dead-on
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
// moving right from 100,300 - /far-but-aimed is at y=320 (20px off), /close-but-off is at y=420 (120px off)
|
|
182
|
+
intent.move(100, 300, 0, 0)
|
|
183
|
+
intent.move(150, 300, 50, 0)
|
|
184
|
+
|
|
185
|
+
// the far-but-aimed should win because much better aim
|
|
186
|
+
expect(prefetched).toEqual(['/far-but-aimed'])
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('velocity and smoothing', () => {
|
|
191
|
+
it('does not prefetch when mouse is stationary', () => {
|
|
192
|
+
intent.setRects([{ r: rect(500, 300), h: '/about' }])
|
|
193
|
+
|
|
194
|
+
intent.move(400, 300, 0, 0)
|
|
195
|
+
intent.move(400, 300, 0, 0)
|
|
196
|
+
intent.move(400, 300, 0, 0)
|
|
197
|
+
|
|
198
|
+
expect(prefetched).toEqual([])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('does not prefetch when mouse is moving slowly', () => {
|
|
202
|
+
intent.setRects([{ r: rect(500, 300), h: '/about' }])
|
|
203
|
+
|
|
204
|
+
intent.move(400, 300, 0, 0)
|
|
205
|
+
intent.move(401, 300, 1, 0)
|
|
206
|
+
intent.move(402, 300, 1, 0)
|
|
207
|
+
|
|
208
|
+
expect(prefetched).toEqual([])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('smooths velocity to avoid jitter false positives', () => {
|
|
212
|
+
intent.setRects([{ r: rect(500, 300), h: '/about' }])
|
|
213
|
+
|
|
214
|
+
// overall moving right but with some jitter
|
|
215
|
+
intent.move(100, 300, 0, 0)
|
|
216
|
+
intent.move(130, 305, 30, 5)
|
|
217
|
+
intent.move(160, 298, 30, -7) // slight jitter up
|
|
218
|
+
intent.move(195, 303, 35, 5) // back down
|
|
219
|
+
|
|
220
|
+
// should still prefetch due to smoothed velocity
|
|
221
|
+
expect(prefetched).toEqual(['/about'])
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('diagonal movement', () => {
|
|
226
|
+
it('prefetches with diagonal approach', () => {
|
|
227
|
+
intent.setRects([{ r: rect(500, 500), h: '/corner' }])
|
|
228
|
+
|
|
229
|
+
// moving diagonally toward 500,500
|
|
230
|
+
intent.move(200, 200, 0, 0)
|
|
231
|
+
intent.move(240, 240, 40, 40)
|
|
232
|
+
intent.move(290, 290, 50, 50)
|
|
233
|
+
|
|
234
|
+
expect(prefetched).toEqual(['/corner'])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('handles angled approach to horizontal nav', () => {
|
|
238
|
+
intent.setRects([
|
|
239
|
+
{ r: rect(400, 50), h: '/link1' },
|
|
240
|
+
{ r: rect(500, 50), h: '/link2' },
|
|
241
|
+
{ r: rect(600, 50), h: '/link3' },
|
|
242
|
+
])
|
|
243
|
+
|
|
244
|
+
// coming from bottom-left, angling toward link2
|
|
245
|
+
intent.move(300, 300, 0, 0)
|
|
246
|
+
intent.move(340, 260, 40, -40)
|
|
247
|
+
intent.move(390, 210, 50, -50)
|
|
248
|
+
|
|
249
|
+
// should hit link1 or link2 depending on exact trajectory
|
|
250
|
+
expect(prefetched.length).toBe(1)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe('edge cases', () => {
|
|
255
|
+
it('handles empty rect list', () => {
|
|
256
|
+
intent.setRects([])
|
|
257
|
+
intent.move(100, 100, 0, 0)
|
|
258
|
+
intent.move(150, 100, 50, 0)
|
|
259
|
+
expect(prefetched).toEqual([])
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('handles link at cursor position', () => {
|
|
263
|
+
intent.setRects([{ r: rect(100, 100), h: '/here' }])
|
|
264
|
+
intent.move(150, 120, 0, 0) // cursor inside link
|
|
265
|
+
intent.move(160, 120, 10, 0)
|
|
266
|
+
// should not crash, may or may not prefetch
|
|
267
|
+
expect(prefetched.length).toBeLessThanOrEqual(1)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('cleans up when observe returns cleanup function', () => {
|
|
271
|
+
const cleanup = intent.observe(
|
|
272
|
+
{ getBoundingClientRect: () => rect(500, 300) } as HTMLElement,
|
|
273
|
+
'/test'
|
|
274
|
+
)
|
|
275
|
+
cleanup()
|
|
276
|
+
// after cleanup, should not be in the system
|
|
277
|
+
intent.move(100, 300, 0, 0)
|
|
278
|
+
intent.move(150, 300, 50, 0)
|
|
279
|
+
expect(prefetched).toEqual([])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('re-enables prefetch for href after cleanup', () => {
|
|
283
|
+
const el = { getBoundingClientRect: () => rect(500, 300) } as HTMLElement
|
|
284
|
+
const cleanup = intent.observe(el, '/test')
|
|
285
|
+
|
|
286
|
+
// need larger movements to exceed minSpeed of 8
|
|
287
|
+
intent.setRects([{ r: rect(500, 300), h: '/test' }])
|
|
288
|
+
intent.move(100, 300, 0, 0)
|
|
289
|
+
intent.move(140, 300, 40, 0)
|
|
290
|
+
intent.move(200, 300, 60, 0)
|
|
291
|
+
expect(prefetched).toEqual(['/test'])
|
|
292
|
+
|
|
293
|
+
// cleanup and re-observe
|
|
294
|
+
cleanup()
|
|
295
|
+
prefetched.length = 0
|
|
296
|
+
intent.observe(el, '/test')
|
|
297
|
+
intent.setRects([{ r: rect(500, 300), h: '/test' }])
|
|
298
|
+
|
|
299
|
+
intent.move(100, 300, 0, 0)
|
|
300
|
+
intent.move(140, 300, 40, 0)
|
|
301
|
+
intent.move(200, 300, 60, 0)
|
|
302
|
+
expect(prefetched).toEqual(['/test'])
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
describe('reach configuration', () => {
|
|
307
|
+
it('respects maxReach option', () => {
|
|
308
|
+
const shortReach = createPrefetchIntent({
|
|
309
|
+
onPrefetch: (href) => prefetched.push(href),
|
|
310
|
+
maxReach: 200,
|
|
311
|
+
})
|
|
312
|
+
shortReach.setRects([{ r: rect(500, 300), h: '/far' }])
|
|
313
|
+
|
|
314
|
+
// 400px away, outside maxReach of 200
|
|
315
|
+
shortReach.move(100, 300, 0, 0)
|
|
316
|
+
shortReach.move(150, 300, 50, 0)
|
|
317
|
+
|
|
318
|
+
expect(prefetched).toEqual([])
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('respects perpWeight option for aim strictness', () => {
|
|
322
|
+
const strictAim = createPrefetchIntent({
|
|
323
|
+
onPrefetch: (href) => prefetched.push(href),
|
|
324
|
+
perpWeight: 10, // very strict
|
|
325
|
+
})
|
|
326
|
+
strictAim.setRects([{ r: rect(400, 350), h: '/off' }])
|
|
327
|
+
|
|
328
|
+
// 50px off-axis at 300px distance
|
|
329
|
+
strictAim.move(100, 300, 0, 0)
|
|
330
|
+
strictAim.move(150, 300, 50, 0)
|
|
331
|
+
|
|
332
|
+
expect(prefetched).toEqual([]) // too strict, won't fire
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
describe('memory and performance', () => {
|
|
337
|
+
it('does not leak elements after cleanup', () => {
|
|
338
|
+
const cleanups: (() => void)[] = []
|
|
339
|
+
|
|
340
|
+
// add 100 elements
|
|
341
|
+
for (let i = 0; i < 100; i++) {
|
|
342
|
+
const el = {} as HTMLElement
|
|
343
|
+
cleanups.push(intent.observe(el, `/page-${i}`))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
expect(intent.nodes.size).toBe(100)
|
|
347
|
+
|
|
348
|
+
// cleanup all
|
|
349
|
+
cleanups.forEach((c) => c())
|
|
350
|
+
|
|
351
|
+
expect(intent.nodes.size).toBe(0)
|
|
352
|
+
expect(intent.done.size).toBe(0)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('handles rapid observe/unobserve cycles', () => {
|
|
356
|
+
const el = {} as HTMLElement
|
|
357
|
+
|
|
358
|
+
for (let i = 0; i < 100; i++) {
|
|
359
|
+
const cleanup = intent.observe(el, '/test')
|
|
360
|
+
cleanup()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// should not leak
|
|
364
|
+
expect(intent.nodes.size).toBe(0)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('processes 100 links efficiently', () => {
|
|
368
|
+
// create 100 links in a grid
|
|
369
|
+
const links: { r: DOMRectReadOnly; h: string }[] = []
|
|
370
|
+
for (let row = 0; row < 10; row++) {
|
|
371
|
+
for (let col = 0; col < 10; col++) {
|
|
372
|
+
links.push({
|
|
373
|
+
r: rect(100 + col * 80, 100 + row * 50, 60, 30),
|
|
374
|
+
h: `/link-${row}-${col}`,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
intent.setRects(links)
|
|
379
|
+
|
|
380
|
+
const start = performance.now()
|
|
381
|
+
// simulate 100 mouse movements
|
|
382
|
+
for (let i = 0; i < 100; i++) {
|
|
383
|
+
intent.move(50 + i * 5, 300, 5, 0)
|
|
384
|
+
}
|
|
385
|
+
const elapsed = performance.now() - start
|
|
386
|
+
|
|
387
|
+
// should complete in under 10ms for 100 moves * 100 links
|
|
388
|
+
expect(elapsed).toBeLessThan(50)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('removes prefetched links from rects to speed up future checks', () => {
|
|
392
|
+
const links = Array.from({ length: 10 }, (_, i) => ({
|
|
393
|
+
r: rect(200 + i * 100, 300),
|
|
394
|
+
h: `/link-${i}`,
|
|
395
|
+
}))
|
|
396
|
+
intent.setRects(links)
|
|
397
|
+
|
|
398
|
+
// prefetch first link
|
|
399
|
+
intent.move(100, 320, 0, 0)
|
|
400
|
+
intent.move(150, 320, 50, 0)
|
|
401
|
+
|
|
402
|
+
expect(prefetched.length).toBe(1)
|
|
403
|
+
|
|
404
|
+
// internal rects should have one less
|
|
405
|
+
// (we can't directly access rects, but we can verify behavior)
|
|
406
|
+
// move toward same area again - should not re-prefetch
|
|
407
|
+
intent.move(100, 320, -50, 0)
|
|
408
|
+
intent.move(150, 320, 50, 0)
|
|
409
|
+
intent.move(200, 320, 50, 0)
|
|
410
|
+
|
|
411
|
+
// should have moved on to next link, not re-fetched first
|
|
412
|
+
expect(prefetched.length).toBe(2)
|
|
413
|
+
expect(prefetched[0]).not.toBe(prefetched[1])
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
})
|