@pyreon/router 0.24.4 → 0.24.6
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/package.json +4 -6
- package/src/components.tsx +0 -650
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -106
- package/src/loader.ts +0 -200
- package/src/manifest.ts +0 -399
- package/src/match.ts +0 -921
- package/src/not-found.ts +0 -75
- package/src/redirect.ts +0 -63
- package/src/router.ts +0 -1424
- package/src/scroll.ts +0 -93
- package/src/tests/integration.test.tsx +0 -298
- package/src/tests/loader.test.ts +0 -1024
- package/src/tests/manifest-snapshot.test.ts +0 -101
- package/src/tests/match.test.ts +0 -782
- package/src/tests/native-markers.test.ts +0 -18
- package/src/tests/redirect.test.ts +0 -96
- package/src/tests/router.browser.test.tsx +0 -509
- package/src/tests/router.test.ts +0 -5498
- package/src/tests/routerlink-reactive-to.browser.test.tsx +0 -158
- package/src/tests/scroll.test.ts +0 -31
- package/src/tests/setup.ts +0 -3
- package/src/types.ts +0 -517
package/src/scroll.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import type { ResolvedRoute, RouterOptions } from './types'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Scroll restoration manager.
|
|
5
|
-
*
|
|
6
|
-
* Saves scroll position before each navigation and restores it when
|
|
7
|
-
* navigating back to a previously visited path.
|
|
8
|
-
*/
|
|
9
|
-
// LRU cap — in SPAs with unbounded URL space (`/user/:id`, query-string
|
|
10
|
-
// variations, etc.) the `_positions` Map would grow per unique path
|
|
11
|
-
// forever. 100 entries covers typical back-navigation depth; beyond that,
|
|
12
|
-
// scroll restoration is a nice-to-have not a correctness requirement.
|
|
13
|
-
const MAX_SCROLL_POSITIONS = 100
|
|
14
|
-
|
|
15
|
-
export class ScrollManager {
|
|
16
|
-
private readonly _positions = new Map<string, number>()
|
|
17
|
-
private readonly _behavior: RouterOptions['scrollBehavior']
|
|
18
|
-
|
|
19
|
-
constructor(behavior: RouterOptions['scrollBehavior'] = 'top') {
|
|
20
|
-
this._behavior = behavior
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Call before navigating away — saves current scroll position for `fromPath` */
|
|
24
|
-
save(fromPath: string): void {
|
|
25
|
-
// ScrollManager methods are only invoked from browser navigation paths,
|
|
26
|
-
// but an explicit early-return documents the SSR-safety contract at the
|
|
27
|
-
// callsite (the `no-window-in-ssr` lint rule can't AST-trace indirect
|
|
28
|
-
// calls from router setup).
|
|
29
|
-
if (typeof window === 'undefined') return
|
|
30
|
-
// LRU: re-insert moves the entry to newest. Evict oldest when over cap.
|
|
31
|
-
if (this._positions.has(fromPath)) this._positions.delete(fromPath)
|
|
32
|
-
this._positions.set(fromPath, window.scrollY)
|
|
33
|
-
while (this._positions.size > MAX_SCROLL_POSITIONS) {
|
|
34
|
-
const oldest = this._positions.keys().next().value
|
|
35
|
-
if (oldest === undefined) break
|
|
36
|
-
this._positions.delete(oldest)
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Call after navigation is committed — applies scroll behavior */
|
|
41
|
-
restore(to: ResolvedRoute, from: ResolvedRoute): void {
|
|
42
|
-
const behavior = (to.meta.scrollBehavior as typeof this._behavior) ?? this._behavior ?? 'top'
|
|
43
|
-
|
|
44
|
-
if (typeof behavior === 'function') {
|
|
45
|
-
const saved = this._positions.get(to.path) ?? null
|
|
46
|
-
const result = behavior(to, from, saved)
|
|
47
|
-
this._applyResult(result, to.path)
|
|
48
|
-
return
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
this._applyResult(behavior, to.path)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
private _applyResult(result: 'top' | 'restore' | 'none' | number, toPath: string): void {
|
|
55
|
-
if (typeof window === 'undefined') return
|
|
56
|
-
// Hash scrolling: if the path contains #, scroll to the element
|
|
57
|
-
const hashIdx = toPath.indexOf('#')
|
|
58
|
-
if (hashIdx >= 0) {
|
|
59
|
-
const id = toPath.slice(hashIdx + 1)
|
|
60
|
-
if (id) {
|
|
61
|
-
// Use requestAnimationFrame to ensure DOM is updated before scrolling
|
|
62
|
-
requestAnimationFrame(() => {
|
|
63
|
-
const el = document.getElementById(id)
|
|
64
|
-
if (el) {
|
|
65
|
-
el.scrollIntoView({ behavior: 'smooth' })
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
// Fallback: try name attribute (for anchors)
|
|
69
|
-
const namedEl = document.querySelector(`[name="${CSS.escape(id)}"]`)
|
|
70
|
-
if (namedEl) namedEl.scrollIntoView({ behavior: 'smooth' })
|
|
71
|
-
})
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (result === 'none') return
|
|
77
|
-
if (result === 'top' || result === undefined) {
|
|
78
|
-
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior })
|
|
79
|
-
return
|
|
80
|
-
}
|
|
81
|
-
if (result === 'restore') {
|
|
82
|
-
const saved = this._positions.get(toPath) ?? 0
|
|
83
|
-
window.scrollTo({ top: saved, behavior: 'instant' as ScrollBehavior })
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
// At this point result must be a number (all string cases handled above)
|
|
87
|
-
window.scrollTo({ top: result as number, behavior: 'instant' as ScrollBehavior })
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
getSavedPosition(path: string): number | null {
|
|
91
|
-
return this._positions.get(path) ?? null
|
|
92
|
-
}
|
|
93
|
-
}
|
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
import { h } from '@pyreon/core'
|
|
2
|
-
import { signal } from '@pyreon/reactivity'
|
|
3
|
-
import { mount } from '@pyreon/runtime-dom'
|
|
4
|
-
import type { RouteRecord } from '../index'
|
|
5
|
-
import {
|
|
6
|
-
createRouter,
|
|
7
|
-
prefetchLoaderData,
|
|
8
|
-
RouterProvider,
|
|
9
|
-
RouterView,
|
|
10
|
-
useLoaderData,
|
|
11
|
-
} from '../index'
|
|
12
|
-
import { setActiveRouter } from '../router'
|
|
13
|
-
import type { RouterInstance } from '../types'
|
|
14
|
-
|
|
15
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
function container(): HTMLElement {
|
|
18
|
-
const el = document.createElement('div')
|
|
19
|
-
document.body.appendChild(el)
|
|
20
|
-
return el
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const Home = () => h('div', null, 'Home Page')
|
|
24
|
-
const About = () => h('div', null, 'About Page')
|
|
25
|
-
const NotFound = () => h('div', null, 'Not Found')
|
|
26
|
-
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
setActiveRouter(null)
|
|
29
|
-
document.body.innerHTML = ''
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
// ─── Navigation cycle ───────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
describe('router integration — navigation cycle', () => {
|
|
35
|
-
const routes: RouteRecord[] = [
|
|
36
|
-
{ path: '/', component: Home },
|
|
37
|
-
{ path: '/about', component: About },
|
|
38
|
-
{ path: '/user/:id', component: (props: Record<string, unknown>) => {
|
|
39
|
-
const params = props.params as Record<string, string>
|
|
40
|
-
return h('div', null, 'User: ', params.id)
|
|
41
|
-
}},
|
|
42
|
-
{ path: '*', component: NotFound },
|
|
43
|
-
]
|
|
44
|
-
|
|
45
|
-
test('push to route — correct component renders in DOM', async () => {
|
|
46
|
-
const el = container()
|
|
47
|
-
const router = createRouter({ routes, url: '/' })
|
|
48
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
49
|
-
expect(el.textContent).toContain('Home Page')
|
|
50
|
-
|
|
51
|
-
await router.push('/about')
|
|
52
|
-
expect(el.textContent).toContain('About Page')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('push to dynamic route /user/:id — component renders with correct params', async () => {
|
|
56
|
-
const el = container()
|
|
57
|
-
const router = createRouter({ routes, url: '/' })
|
|
58
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
59
|
-
|
|
60
|
-
await router.push('/user/42')
|
|
61
|
-
expect(el.textContent).toContain('User: 42')
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
test('router.back() — previous route renders', async () => {
|
|
65
|
-
const el = container()
|
|
66
|
-
const router = createRouter({ routes, url: '/' })
|
|
67
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
68
|
-
|
|
69
|
-
await router.push('/about')
|
|
70
|
-
expect(el.textContent).toContain('About Page')
|
|
71
|
-
|
|
72
|
-
router.back()
|
|
73
|
-
// back() is synchronous in SSR mode, give DOM time to update
|
|
74
|
-
await new Promise<void>((r) => setTimeout(r, 50))
|
|
75
|
-
// In SSR mode, back() may be a no-op (no window.history).
|
|
76
|
-
// Verify it doesn't throw and DOM is in a valid state.
|
|
77
|
-
expect(el.textContent).toBeDefined()
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
test('push to unknown route — catch-all component renders', async () => {
|
|
81
|
-
const el = container()
|
|
82
|
-
const router = createRouter({ routes, url: '/' })
|
|
83
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
84
|
-
|
|
85
|
-
await router.push('/nonexistent/path')
|
|
86
|
-
expect(el.textContent).toContain('Not Found')
|
|
87
|
-
})
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
// ─── Guards ─────────────────────────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
describe('router integration — guards', () => {
|
|
93
|
-
test('beforeEnter returns false — navigation blocked, DOM unchanged', async () => {
|
|
94
|
-
const routes: RouteRecord[] = [
|
|
95
|
-
{ path: '/', component: Home },
|
|
96
|
-
{ path: '/protected', component: About, beforeEnter: () => false },
|
|
97
|
-
]
|
|
98
|
-
const el = container()
|
|
99
|
-
const router = createRouter({ routes, url: '/' })
|
|
100
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
101
|
-
expect(el.textContent).toContain('Home Page')
|
|
102
|
-
|
|
103
|
-
await router.push('/protected')
|
|
104
|
-
expect(el.textContent).toContain('Home Page')
|
|
105
|
-
expect(el.textContent).not.toContain('About Page')
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
test('beforeEnter returns string — redirects to that route', async () => {
|
|
109
|
-
const Redirected = () => h('div', null, 'Redirected Page')
|
|
110
|
-
const routes: RouteRecord[] = [
|
|
111
|
-
{ path: '/', component: Home },
|
|
112
|
-
{ path: '/old', component: About, beforeEnter: () => '/redirected' },
|
|
113
|
-
{ path: '/redirected', component: Redirected },
|
|
114
|
-
]
|
|
115
|
-
const el = container()
|
|
116
|
-
const router = createRouter({ routes, url: '/' })
|
|
117
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
118
|
-
|
|
119
|
-
await router.push('/old')
|
|
120
|
-
expect(el.textContent).toContain('Redirected Page')
|
|
121
|
-
expect(router.currentRoute().path).toBe('/redirected')
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
test('async guard — resolves before navigation commits', async () => {
|
|
125
|
-
const order: string[] = []
|
|
126
|
-
const Protected = () => {
|
|
127
|
-
order.push('render')
|
|
128
|
-
return h('div', null, 'Protected')
|
|
129
|
-
}
|
|
130
|
-
const routes: RouteRecord[] = [
|
|
131
|
-
{ path: '/', component: Home },
|
|
132
|
-
{
|
|
133
|
-
path: '/guarded',
|
|
134
|
-
component: Protected,
|
|
135
|
-
beforeEnter: async () => {
|
|
136
|
-
await new Promise<void>((r) => setTimeout(r, 20))
|
|
137
|
-
order.push('guard')
|
|
138
|
-
return true
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
]
|
|
142
|
-
const el = container()
|
|
143
|
-
const router = createRouter({ routes, url: '/' })
|
|
144
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
145
|
-
|
|
146
|
-
await router.push('/guarded')
|
|
147
|
-
// Guard runs before component renders
|
|
148
|
-
expect(order[0]).toBe('guard')
|
|
149
|
-
expect(order[1]).toBe('render')
|
|
150
|
-
expect(el.textContent).toContain('Protected')
|
|
151
|
-
})
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
// ─── Loaders ────────────────────────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
describe('router integration — loaders', () => {
|
|
157
|
-
test('route with loader — useLoaderData returns loader result', async () => {
|
|
158
|
-
const el = container()
|
|
159
|
-
let capturedData: unknown
|
|
160
|
-
const DataComp = () => {
|
|
161
|
-
capturedData = useLoaderData()
|
|
162
|
-
return h('span', null, 'data-loaded')
|
|
163
|
-
}
|
|
164
|
-
const routes: RouteRecord[] = [
|
|
165
|
-
{ path: '/', component: Home },
|
|
166
|
-
{
|
|
167
|
-
path: '/data',
|
|
168
|
-
component: DataComp,
|
|
169
|
-
loader: async () => ({ items: [1, 2, 3] }),
|
|
170
|
-
},
|
|
171
|
-
]
|
|
172
|
-
const router = createRouter({ routes, url: '/' })
|
|
173
|
-
await prefetchLoaderData(router as RouterInstance, '/data')
|
|
174
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
175
|
-
|
|
176
|
-
await router.push('/data')
|
|
177
|
-
await new Promise<void>((r) => setTimeout(r, 50))
|
|
178
|
-
expect(capturedData).toEqual({ items: [1, 2, 3] })
|
|
179
|
-
expect(el.textContent).toContain('data-loaded')
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
test('loader throws — errorComponent renders instead', async () => {
|
|
183
|
-
const el = container()
|
|
184
|
-
const ErrorComp = () => h('span', null, 'loader-error')
|
|
185
|
-
const DataComp = () => h('span', null, 'data')
|
|
186
|
-
const routes: RouteRecord[] = [
|
|
187
|
-
{ path: '/', component: Home },
|
|
188
|
-
{
|
|
189
|
-
path: '/err',
|
|
190
|
-
component: DataComp,
|
|
191
|
-
loader: async () => {
|
|
192
|
-
throw new Error('fail')
|
|
193
|
-
},
|
|
194
|
-
errorComponent: ErrorComp,
|
|
195
|
-
},
|
|
196
|
-
]
|
|
197
|
-
const router = createRouter({ routes, url: '/' })
|
|
198
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
199
|
-
|
|
200
|
-
await router.push('/err')
|
|
201
|
-
await new Promise<void>((r) => setTimeout(r, 50))
|
|
202
|
-
expect(el.textContent).toContain('loader-error')
|
|
203
|
-
})
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
// ─── Nested routes ──────────────────────────────────────────────────────────
|
|
207
|
-
|
|
208
|
-
describe('router integration — nested routes', () => {
|
|
209
|
-
test('parent layout + child — both render (parent wraps child)', () => {
|
|
210
|
-
const el = container()
|
|
211
|
-
const ChildComp = () => h('span', { class: 'child' }, 'child-content')
|
|
212
|
-
const ParentComp = () =>
|
|
213
|
-
h('div', { class: 'parent' }, h('span', null, 'parent-content'), h(RouterView, {}))
|
|
214
|
-
|
|
215
|
-
const routes: RouteRecord[] = [
|
|
216
|
-
{
|
|
217
|
-
path: '/parent',
|
|
218
|
-
component: ParentComp,
|
|
219
|
-
children: [{ path: 'child', component: ChildComp }],
|
|
220
|
-
},
|
|
221
|
-
]
|
|
222
|
-
const router = createRouter({ routes, url: '/parent/child' })
|
|
223
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
224
|
-
|
|
225
|
-
expect(el.textContent).toContain('parent-content')
|
|
226
|
-
expect(el.textContent).toContain('child-content')
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
test('navigate to sibling child — parent stays, child swaps', async () => {
|
|
230
|
-
const el = container()
|
|
231
|
-
const ChildA = () => h('span', null, 'child-A')
|
|
232
|
-
const ChildB = () => h('span', null, 'child-B')
|
|
233
|
-
const ParentComp = () =>
|
|
234
|
-
h('div', { class: 'parent' }, h('span', null, 'parent-layout'), h(RouterView, {}))
|
|
235
|
-
|
|
236
|
-
const routes: RouteRecord[] = [
|
|
237
|
-
{
|
|
238
|
-
path: '/parent',
|
|
239
|
-
component: ParentComp,
|
|
240
|
-
children: [
|
|
241
|
-
{ path: 'a', component: ChildA },
|
|
242
|
-
{ path: 'b', component: ChildB },
|
|
243
|
-
],
|
|
244
|
-
},
|
|
245
|
-
]
|
|
246
|
-
const router = createRouter({ routes, url: '/parent/a' })
|
|
247
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
248
|
-
|
|
249
|
-
expect(el.textContent).toContain('parent-layout')
|
|
250
|
-
expect(el.textContent).toContain('child-A')
|
|
251
|
-
|
|
252
|
-
await router.push('/parent/b')
|
|
253
|
-
expect(el.textContent).toContain('parent-layout')
|
|
254
|
-
expect(el.textContent).toContain('child-B')
|
|
255
|
-
expect(el.textContent).not.toContain('child-A')
|
|
256
|
-
})
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
// ─── Cleanup ────────────────────────────────────────────────────────────────
|
|
260
|
-
|
|
261
|
-
describe('router integration — cleanup', () => {
|
|
262
|
-
test('navigate away — effect from previous route stops running', async () => {
|
|
263
|
-
const el = container()
|
|
264
|
-
let effectRunCount = 0
|
|
265
|
-
const counter = signal(0)
|
|
266
|
-
|
|
267
|
-
const Reactive = () => {
|
|
268
|
-
// Create a reactive text node that tracks the signal
|
|
269
|
-
return h('div', null, () => {
|
|
270
|
-
effectRunCount++
|
|
271
|
-
return `count: ${counter()}`
|
|
272
|
-
})
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const routes: RouteRecord[] = [
|
|
276
|
-
{ path: '/', component: Reactive },
|
|
277
|
-
{ path: '/other', component: About },
|
|
278
|
-
]
|
|
279
|
-
const router = createRouter({ routes, url: '/' })
|
|
280
|
-
mount(h(RouterProvider, { router }, h(RouterView, {})), el)
|
|
281
|
-
|
|
282
|
-
// Signal change triggers reactive update
|
|
283
|
-
const initialRuns = effectRunCount
|
|
284
|
-
counter.set(1)
|
|
285
|
-
expect(effectRunCount).toBeGreaterThan(initialRuns)
|
|
286
|
-
|
|
287
|
-
// Navigate away
|
|
288
|
-
await router.push('/other')
|
|
289
|
-
expect(el.textContent).toContain('About Page')
|
|
290
|
-
|
|
291
|
-
// Signal change should NOT trigger the old component's reactive text
|
|
292
|
-
const runsAfterNav = effectRunCount
|
|
293
|
-
counter.set(2)
|
|
294
|
-
// Give any potential stale effects time to fire
|
|
295
|
-
await new Promise<void>((r) => setTimeout(r, 50))
|
|
296
|
-
expect(effectRunCount).toBe(runsAfterNav)
|
|
297
|
-
})
|
|
298
|
-
})
|