@pyreon/router 0.12.14 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ import {
2
+ renderApiReferenceEntries,
3
+ renderLlmsFullSection,
4
+ renderLlmsTxtLine,
5
+ } from '@pyreon/manifest'
6
+ import routerManifest from '../manifest'
7
+
8
+ describe('gen-docs — router snapshot', () => {
9
+ it('renders @pyreon/router to its expected llms.txt bullet', () => {
10
+ expect(renderLlmsTxtLine(routerManifest)).toMatchInlineSnapshot(`"- @pyreon/router — hash+history+SSR, context-based, prefetching, guards, loaders, useIsActive, View Transitions, middleware, typed search params. \`await router.push()\` resolves after \`updateCallbackDone\` (DOM commit), NOT after animation finishes. It does NOT wait for \`.finished\` (~200-300ms). \`.ready\` and \`.finished\` get empty \`.catch()\` handlers so \`AbortError: Transition was skipped\` rejections (from interrupted transitions) do not leak as unhandled promise rejections."`)
11
+ })
12
+
13
+ it('renders @pyreon/router to its expected llms-full.txt section — full body snapshot', () => {
14
+ expect(renderLlmsFullSection(routerManifest)).toMatchInlineSnapshot(`
15
+ "## @pyreon/router — Router
16
+
17
+ Type-safe client-side router for Pyreon with nested routes, per-route and global navigation guards, data loaders, middleware chain, View Transitions API integration, and typed search params. Context-based (\`RouterContext\`) with hash and history mode support. Route params are inferred from path strings (\`"/user/:id"\` yields \`{ id: string }\`). Named routes enable typed programmatic navigation. SSR-compatible with server-side route resolution. Hash mode uses \`history.pushState\` (not \`window.location.hash\`) to avoid double-update. \`await router.push()\` resolves after the View Transition \`updateCallbackDone\` (DOM commit), not after animation completion.
18
+
19
+ \`\`\`typescript
20
+ import { createRouter, RouterProvider, RouterView, RouterLink, useRouter, useRoute, useIsActive, useTypedSearchParams, useTransition, useLoaderData, useMiddlewareData } from "@pyreon/router"
21
+ import { mount } from "@pyreon/runtime-dom"
22
+
23
+ // Define routes with typed params, guards, loaders, and middleware
24
+ const router = createRouter({
25
+ routes: [
26
+ { path: "/", component: Home, name: "home" },
27
+ { path: "/user/:id", component: User, name: "user",
28
+ loader: ({ params }) => fetchUser(params.id),
29
+ meta: { title: "User Profile" } },
30
+ { path: "/admin", component: AdminLayout,
31
+ beforeEnter: (to, from) => isAdmin() || "/login",
32
+ children: [
33
+ { path: "users", component: AdminUsers },
34
+ { path: "settings", component: AdminSettings },
35
+ ] },
36
+ { path: "/settings", redirect: "/admin/settings" },
37
+ { path: "(.*)", component: NotFound },
38
+ ],
39
+ middleware: [authMiddleware, loggerMiddleware],
40
+ })
41
+
42
+ // Mount with RouterProvider
43
+ mount(
44
+ <RouterProvider router={router}>
45
+ <nav>
46
+ <RouterLink to="/" activeClass="nav-active">Home</RouterLink>
47
+ <RouterLink to={{ name: "user", params: { id: "42" } }}>Profile</RouterLink>
48
+ </nav>
49
+ <RouterView />
50
+ </RouterProvider>,
51
+ document.getElementById("app")!
52
+ )
53
+
54
+ // Inside a component — hooks
55
+ const User = () => {
56
+ const route = useRoute<"/user/:id">()
57
+ const data = useLoaderData<UserData>()
58
+ const router = useRouter()
59
+ const isAdmin = useIsActive("/admin")
60
+ const { isTransitioning } = useTransition()
61
+ const params = useTypedSearchParams({ tab: "string", page: "number" })
62
+
63
+ return (
64
+ <div>
65
+ <h1>{data.name} (ID: {route().params.id})</h1>
66
+ <Show when={isTransitioning()}>
67
+ <ProgressBar />
68
+ </Show>
69
+ <button onClick={() => router.push("/")}>Go Home</button>
70
+ </div>
71
+ )
72
+ }
73
+ \`\`\`
74
+
75
+ > **View Transitions — what push() awaits**: \`await router.push()\` resolves after \`updateCallbackDone\` (DOM commit), NOT after animation finishes. It does NOT wait for \`.finished\` (~200-300ms). \`.ready\` and \`.finished\` get empty \`.catch()\` handlers so \`AbortError: Transition was skipped\` rejections (from interrupted transitions) do not leak as unhandled promise rejections.
76
+ >
77
+ > **Hash mode uses pushState**: Hash mode uses \`history.pushState\` — NOT \`window.location.hash\` assignment — to avoid double-update from the hashchange event. Reading \`location.hash\` directly will not reflect router state; use \`useRoute()\` instead.
78
+ >
79
+ > **Imperative navigation in render body**: \`router.push()\` or \`navigate()\` called synchronously in the component function body causes an infinite render loop. Wrap in \`onMount\`, event handlers, \`effect\`, or any deferred execution context. The \`pyreon/no-imperative-navigate-in-render\` lint rule catches this.
80
+ >
81
+ > **Hook ordering with View Transitions**: \`afterEach\` hooks and scroll restoration fire AFTER the View Transition callback completes — not before. This means hooks see the NEW route state, which is the correct per-spec behavior but a subtle change from pre-VT versions.
82
+ >
83
+ > **For uses by, not key**: \`<For>\` in route lists uses \`by\` not \`key\`. \`<For each={routes()} key={r => r.path}>\` silently passes the key to VNode reconciliation instead of the list reconciler. Use \`by={r => r.path}\`.
84
+ "
85
+ `)
86
+ })
87
+
88
+ it('renders @pyreon/router to MCP api-reference entries — one per api[] item', () => {
89
+ const record = renderApiReferenceEntries(routerManifest)
90
+ expect(Object.keys(record).length).toBe(15)
91
+ expect(Object.keys(record)).toContain('router/createRouter')
92
+ // Spot-check the flagship API — createRouter is the factory
93
+ const createRouter = record['router/createRouter']!
94
+ expect(createRouter.notes).toContain('routes')
95
+ expect(createRouter.mistakes?.split('\n').length).toBeGreaterThan(2)
96
+ })
97
+ })
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { ScrollManager } from '../scroll'
3
+
4
+ describe('ScrollManager — LRU bound', () => {
5
+ test('evicts oldest entry when cap (100) is exceeded', () => {
6
+ const mgr = new ScrollManager('top')
7
+ // Fake window.scrollY for each save — happy-dom provides window.
8
+ Object.defineProperty(window, 'scrollY', { value: 42, configurable: true })
9
+ // Save 150 distinct paths.
10
+ for (let i = 0; i < 150; i++) mgr.save(`/path-${i}`)
11
+ // Oldest 50 evicted; newest 100 remain.
12
+ expect(mgr.getSavedPosition('/path-0')).toBeNull()
13
+ expect(mgr.getSavedPosition('/path-49')).toBeNull()
14
+ expect(mgr.getSavedPosition('/path-50')).toBe(42)
15
+ expect(mgr.getSavedPosition('/path-149')).toBe(42)
16
+ })
17
+
18
+ test('re-saving an existing path bumps it to newest (LRU, not FIFO)', () => {
19
+ const mgr = new ScrollManager('top')
20
+ Object.defineProperty(window, 'scrollY', { value: 42, configurable: true })
21
+ for (let i = 0; i < 100; i++) mgr.save(`/path-${i}`)
22
+ // Touch the oldest entry — should move to newest.
23
+ Object.defineProperty(window, 'scrollY', { value: 99, configurable: true })
24
+ mgr.save('/path-0')
25
+ // Now add one more to push out the new-oldest (/path-1).
26
+ mgr.save('/new-path')
27
+ expect(mgr.getSavedPosition('/path-1')).toBeNull()
28
+ expect(mgr.getSavedPosition('/path-0')).toBe(99)
29
+ expect(mgr.getSavedPosition('/new-path')).toBe(99)
30
+ })
31
+ })