@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/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
- })