@buzola/router 0.0.1

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.
Files changed (114) hide show
  1. package/dist/__tests__/create-page.test.d.ts +2 -0
  2. package/dist/__tests__/create-page.test.d.ts.map +1 -0
  3. package/dist/__tests__/create-page.test.js +457 -0
  4. package/dist/__tests__/create-page.test.js.map +1 -0
  5. package/dist/__tests__/define-routes.test.d.ts +2 -0
  6. package/dist/__tests__/define-routes.test.d.ts.map +1 -0
  7. package/dist/__tests__/define-routes.test.js +42 -0
  8. package/dist/__tests__/define-routes.test.js.map +1 -0
  9. package/dist/__tests__/matcher.test.d.ts +2 -0
  10. package/dist/__tests__/matcher.test.d.ts.map +1 -0
  11. package/dist/__tests__/matcher.test.js +108 -0
  12. package/dist/__tests__/matcher.test.js.map +1 -0
  13. package/dist/__tests__/router.test.d.ts +2 -0
  14. package/dist/__tests__/router.test.d.ts.map +1 -0
  15. package/dist/__tests__/router.test.js +327 -0
  16. package/dist/__tests__/router.test.js.map +1 -0
  17. package/dist/__tests__/type-safety.test.d.ts +15 -0
  18. package/dist/__tests__/type-safety.test.d.ts.map +1 -0
  19. package/dist/__tests__/type-safety.test.js +163 -0
  20. package/dist/__tests__/type-safety.test.js.map +1 -0
  21. package/dist/__tests__/types.test.d.ts +2 -0
  22. package/dist/__tests__/types.test.d.ts.map +1 -0
  23. package/dist/__tests__/types.test.js +22 -0
  24. package/dist/__tests__/types.test.js.map +1 -0
  25. package/dist/define/create-page.d.ts +87 -0
  26. package/dist/define/create-page.d.ts.map +1 -0
  27. package/dist/define/create-page.js +305 -0
  28. package/dist/define/create-page.js.map +1 -0
  29. package/dist/define/define-routes.d.ts +34 -0
  30. package/dist/define/define-routes.d.ts.map +1 -0
  31. package/dist/define/define-routes.js +53 -0
  32. package/dist/define/define-routes.js.map +1 -0
  33. package/dist/engine/matcher.d.ts +8 -0
  34. package/dist/engine/matcher.d.ts.map +1 -0
  35. package/dist/engine/matcher.js +79 -0
  36. package/dist/engine/matcher.js.map +1 -0
  37. package/dist/engine/navigation-adapter.d.ts +13 -0
  38. package/dist/engine/navigation-adapter.d.ts.map +1 -0
  39. package/dist/engine/navigation-adapter.js +156 -0
  40. package/dist/engine/navigation-adapter.js.map +1 -0
  41. package/dist/engine/route-tree.d.ts +21 -0
  42. package/dist/engine/route-tree.d.ts.map +1 -0
  43. package/dist/engine/route-tree.js +95 -0
  44. package/dist/engine/route-tree.js.map +1 -0
  45. package/dist/engine/router.d.ts +162 -0
  46. package/dist/engine/router.d.ts.map +1 -0
  47. package/dist/engine/router.js +400 -0
  48. package/dist/engine/router.js.map +1 -0
  49. package/dist/engine/schema.d.ts +46 -0
  50. package/dist/engine/schema.d.ts.map +1 -0
  51. package/dist/engine/schema.js +110 -0
  52. package/dist/engine/schema.js.map +1 -0
  53. package/dist/engine/types.d.ts +183 -0
  54. package/dist/engine/types.d.ts.map +1 -0
  55. package/dist/engine/types.js +2 -0
  56. package/dist/engine/types.js.map +1 -0
  57. package/dist/engine/utils.d.ts +7 -0
  58. package/dist/engine/utils.d.ts.map +1 -0
  59. package/dist/engine/utils.js +17 -0
  60. package/dist/engine/utils.js.map +1 -0
  61. package/dist/index.d.ts +14 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +12 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/react/context.d.ts +21 -0
  66. package/dist/react/context.d.ts.map +1 -0
  67. package/dist/react/context.js +7 -0
  68. package/dist/react/context.js.map +1 -0
  69. package/dist/react/error-boundary.d.ts +24 -0
  70. package/dist/react/error-boundary.d.ts.map +1 -0
  71. package/dist/react/error-boundary.js +37 -0
  72. package/dist/react/error-boundary.js.map +1 -0
  73. package/dist/react/hooks.d.ts +85 -0
  74. package/dist/react/hooks.d.ts.map +1 -0
  75. package/dist/react/hooks.js +161 -0
  76. package/dist/react/hooks.js.map +1 -0
  77. package/dist/react/index.d.ts +12 -0
  78. package/dist/react/index.d.ts.map +1 -0
  79. package/dist/react/index.js +7 -0
  80. package/dist/react/index.js.map +1 -0
  81. package/dist/react/link.d.ts +42 -0
  82. package/dist/react/link.d.ts.map +1 -0
  83. package/dist/react/link.js +38 -0
  84. package/dist/react/link.js.map +1 -0
  85. package/dist/react/outlet.d.ts +13 -0
  86. package/dist/react/outlet.d.ts.map +1 -0
  87. package/dist/react/outlet.js +46 -0
  88. package/dist/react/outlet.js.map +1 -0
  89. package/dist/react/provider.d.ts +22 -0
  90. package/dist/react/provider.d.ts.map +1 -0
  91. package/dist/react/provider.js +37 -0
  92. package/dist/react/provider.js.map +1 -0
  93. package/package.json +29 -0
  94. package/src/__tests__/create-page.test.ts +588 -0
  95. package/src/__tests__/matcher.test.ts +127 -0
  96. package/src/__tests__/router.test.ts +424 -0
  97. package/src/__tests__/type-safety.test.tsx +207 -0
  98. package/src/define/create-page.tsx +472 -0
  99. package/src/engine/matcher.ts +88 -0
  100. package/src/engine/navigation-adapter.ts +191 -0
  101. package/src/engine/route-tree.ts +129 -0
  102. package/src/engine/router.ts +471 -0
  103. package/src/engine/schema.ts +130 -0
  104. package/src/engine/types.ts +203 -0
  105. package/src/engine/utils.ts +16 -0
  106. package/src/env.d.ts +120 -0
  107. package/src/index.ts +61 -0
  108. package/src/react/context.ts +24 -0
  109. package/src/react/error-boundary.tsx +56 -0
  110. package/src/react/hooks.ts +211 -0
  111. package/src/react/index.ts +16 -0
  112. package/src/react/link.tsx +93 -0
  113. package/src/react/outlet.tsx +67 -0
  114. package/src/react/provider.tsx +60 -0
@@ -0,0 +1,203 @@
1
+ import type { ComponentType, LazyExoticComponent } from 'react'
2
+
3
+ // ─── Module augmentation interface ──────────────────────────────────────────
4
+
5
+ /**
6
+ * Module augmentation point for type-safe page-centric routing.
7
+ * Vite plugin generates declarations that extend this interface.
8
+ * Maps page IDs to their params types.
9
+ */
10
+ // biome-ignore lint/suspicious/noEmptyInterface: declaration merging
11
+ export interface BuzolaPageMap {}
12
+
13
+ /**
14
+ * Module augmentation point for persistent parameters.
15
+ * Vite plugin generates declarations that extend this interface.
16
+ * Keys listed here are automatically carried over between navigations.
17
+ */
18
+ // biome-ignore lint/suspicious/noEmptyInterface: declaration merging
19
+ export interface BuzolaPersistentParams {}
20
+
21
+ /**
22
+ * Registered page IDs from BuzolaPageMap.
23
+ */
24
+ export type RegisteredPage = keyof BuzolaPageMap & string
25
+
26
+ /**
27
+ * Get the params type for a registered page.
28
+ */
29
+ export type PageParams<P extends keyof BuzolaPageMap> = BuzolaPageMap[P]
30
+
31
+ /**
32
+ * Effective params for a page — persistent params become optional.
33
+ * When BuzolaPersistentParams is empty (default), collapses to BuzolaPageMap[P].
34
+ */
35
+ export type EffectivePageParams<P extends keyof BuzolaPageMap> =
36
+ & Omit<BuzolaPageMap[P], keyof BuzolaPersistentParams & string>
37
+ & Partial<Pick<BuzolaPageMap[P], keyof BuzolaPersistentParams & string & keyof BuzolaPageMap[P]>>
38
+
39
+ // ─── Route configuration ────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Standard Schema compatible interface (Zod 4, Valibot, ArkType).
43
+ * See: https://github.com/standard-schema/standard-schema
44
+ */
45
+ export interface StandardSchema<T = unknown> {
46
+ '~standard': {
47
+ version: 1
48
+ vendor: string
49
+ validate: (value: unknown) => { value: T } | { issues: readonly { message: string }[] }
50
+ }
51
+ }
52
+
53
+ /** Result returned by a route guard to redirect navigation. */
54
+ export interface GuardRedirect {
55
+ redirect: string
56
+ }
57
+
58
+ /** Context passed to route guards. */
59
+ export interface GuardContext {
60
+ signal: AbortSignal
61
+ }
62
+
63
+ /** Guard function — return false to block, a GuardRedirect to redirect, or true/void to allow. */
64
+ export type RouteGuardResult = boolean | void | GuardRedirect
65
+ export type RouteGuard = (match: RouteMatch, context: GuardContext) => RouteGuardResult | Promise<RouteGuardResult>
66
+
67
+ /** Route component — either a regular component or a lazy-loaded one. */
68
+ export type RouteComponent = ComponentType<Record<string, never>> | LazyExoticComponent<ComponentType<Record<string, never>>>
69
+
70
+ /** Configuration for a single route. */
71
+ export interface RouteConfig {
72
+ /** The path segment for this route. */
73
+ path: string
74
+ /** Component to render for this route. */
75
+ component?: RouteComponent
76
+ /** Schema for validating search params (Standard Schema compatible). */
77
+ searchSchema?: StandardSchema
78
+ /** Guard function called before entering this route. */
79
+ beforeEnter?: RouteGuard
80
+ /** Child routes. */
81
+ children?: RouteConfig[]
82
+ /** Whether this is a layout route (has outlet but no own path segment). */
83
+ isLayout?: boolean
84
+ /** Whether this is an index route (renders at parent's exact path). */
85
+ isIndex?: boolean
86
+ /** Whether this is a pathless group (e.g., (auth) — no URL segment). */
87
+ isPathlessGroup?: boolean
88
+ }
89
+
90
+ // ─── Route matching ─────────────────────────────────────────────────────────
91
+
92
+ /** A matched route with extracted params. */
93
+ export interface RouteMatch {
94
+ /** The route node that matched. */
95
+ node: RouteNode
96
+ /** Extracted path parameters. */
97
+ params: Record<string, string>
98
+ /** The full URL pathname that was matched. */
99
+ pathname: string
100
+ }
101
+
102
+ // ─── Route tree ─────────────────────────────────────────────────────────────
103
+
104
+ /** A node in the route tree. */
105
+ export interface RouteNode {
106
+ /** Unique ID for this node. */
107
+ id: string
108
+ /** The path segment pattern (e.g., "/users/:userId"). */
109
+ path: string
110
+ /** Full path from root (e.g., "/users/:userId"). */
111
+ fullPath: string
112
+ /** Compiled URLPattern for matching (leaf/index routes). */
113
+ pattern?: URLPattern
114
+ /** Compiled prefix URLPattern for param extraction (layout routes). */
115
+ prefixPattern?: URLPattern
116
+ /** Component to render. */
117
+ component?: RouteComponent
118
+ /** Search params schema. */
119
+ searchSchema?: StandardSchema
120
+ /** Route guard. */
121
+ beforeEnter?: RouteGuard
122
+ /** Child nodes. */
123
+ children: RouteNode[]
124
+ /** Parent node reference. */
125
+ parent?: RouteNode
126
+ /** Whether this is a layout node (renders children via Outlet). */
127
+ isLayout: boolean
128
+ /** Whether this is an index route. */
129
+ isIndex: boolean
130
+ }
131
+
132
+ // ─── Router state ───────────────────────────────────────────────────────────
133
+
134
+ /** The current state of the router. */
135
+ export interface RouterState {
136
+ /** Current URL location. */
137
+ location: URL
138
+ /** Array of matched routes from root to leaf. */
139
+ matches: RouteMatch[]
140
+ /** Whether a navigation is in progress. */
141
+ isPending: boolean
142
+ /** Navigation state from Navigation API. */
143
+ navigationState?: unknown
144
+ }
145
+
146
+ // ─── Navigation adapter ─────────────────────────────────────────────────────
147
+
148
+ /** Options for programmatic navigation. */
149
+ export interface NavigateOptions {
150
+ /** Replace the current history entry instead of pushing. */
151
+ replace?: boolean
152
+ /** State to associate with the navigation entry. */
153
+ state?: unknown
154
+ /** Use View Transitions API for the navigation. */
155
+ viewTransition?: boolean
156
+ /**
157
+ * Whether to reset scroll position after navigation.
158
+ * Default is `true` (browser handles it: scroll to top on push, restore on traverse).
159
+ * Set to `false` to preserve current scroll position.
160
+ */
161
+ resetScroll?: boolean
162
+ }
163
+
164
+ /**
165
+ * Blocker function registered with the router.
166
+ * Called when a navigation is about to happen.
167
+ * Must return a Promise that resolves to `true` to allow navigation, `false` to block.
168
+ */
169
+ export type BlockerFn = () => Promise<boolean>
170
+
171
+ /** Navigate event from the Navigation API (simplified). */
172
+ export interface BuzolaNavigateEvent {
173
+ /** The destination URL. */
174
+ destination: { url: string }
175
+ /** Whether the navigation can be intercepted. */
176
+ canIntercept: boolean
177
+ /** The type of navigation. */
178
+ navigationType: 'push' | 'replace' | 'reload' | 'traverse'
179
+ /** Whether the user initiated the navigation (e.g., clicking a link). */
180
+ userInitiated: boolean
181
+ /** Whether this navigation should use View Transitions API. */
182
+ viewTransition?: boolean
183
+ /** Intercept the navigation and handle it client-side. */
184
+ intercept(options: { handler: () => Promise<void>; scroll?: 'after-transition' | 'manual' }): void
185
+ }
186
+
187
+ /** Abstraction over the Navigation API for testability. */
188
+ export interface NavigationAdapter {
189
+ /** Get the current URL. */
190
+ getCurrentURL(): URL
191
+ /** Navigate to a URL. */
192
+ navigate(url: string, options?: NavigateOptions): void
193
+ /** Go back. */
194
+ back(): void
195
+ /** Go forward. */
196
+ forward(): void
197
+ /** Add a navigate event listener. */
198
+ addEventListener(type: 'navigate', handler: (event: BuzolaNavigateEvent) => void): void
199
+ /** Remove a navigate event listener. */
200
+ removeEventListener(type: 'navigate', handler: (event: BuzolaNavigateEvent) => void): void
201
+ /** Get the current navigation state. */
202
+ getState(): unknown
203
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Extract parameter names from a route pattern.
3
+ * "/users/:userId/posts/:postId" → ["userId", "postId"]
4
+ * "/:lang/users/:userId+" → ["lang", "userId"]
5
+ */
6
+ export function extractParamNames(pattern: string): string[] {
7
+ const names: string[] = []
8
+ for (const segment of pattern.split('/')) {
9
+ if (segment.startsWith(':')) {
10
+ // Strip catch-all suffix (+)
11
+ const name = segment.endsWith('+') ? segment.slice(1, -1) : segment.slice(1)
12
+ names.push(name)
13
+ }
14
+ }
15
+ return names
16
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,120 @@
1
+ // Type declarations for Navigation API and URLPattern
2
+ // These are Baseline Newly Available (Jan 2026) but not yet in TypeScript's lib.
3
+
4
+ interface URLPatternInit {
5
+ protocol?: string
6
+ username?: string
7
+ password?: string
8
+ hostname?: string
9
+ port?: string
10
+ pathname?: string
11
+ search?: string
12
+ hash?: string
13
+ baseURL?: string
14
+ }
15
+
16
+ interface URLPatternComponentResult {
17
+ input: string
18
+ groups: Record<string, string | undefined>
19
+ }
20
+
21
+ interface URLPatternResult {
22
+ inputs: [URLPatternInit] | [URLPatternInit, string]
23
+ protocol: URLPatternComponentResult
24
+ username: URLPatternComponentResult
25
+ password: URLPatternComponentResult
26
+ hostname: URLPatternComponentResult
27
+ port: URLPatternComponentResult
28
+ pathname: URLPatternComponentResult
29
+ search: URLPatternComponentResult
30
+ hash: URLPatternComponentResult
31
+ }
32
+
33
+ declare class URLPattern {
34
+ constructor(init: URLPatternInit)
35
+ constructor(pattern: string, baseURL?: string)
36
+ test(input?: URLPatternInit | string): boolean
37
+ exec(input?: URLPatternInit | string): URLPatternResult | null
38
+ readonly protocol: string
39
+ readonly username: string
40
+ readonly password: string
41
+ readonly hostname: string
42
+ readonly port: string
43
+ readonly pathname: string
44
+ readonly search: string
45
+ readonly hash: string
46
+ }
47
+
48
+ interface NavigationDestination {
49
+ url: string
50
+ key: string
51
+ id: string
52
+ index: number
53
+ sameDocument: boolean
54
+ getState(): unknown
55
+ }
56
+
57
+ interface NavigationHistoryEntry {
58
+ url: string | null
59
+ key: string
60
+ id: string
61
+ index: number
62
+ sameDocument: boolean
63
+ getState(): unknown
64
+ }
65
+
66
+ interface NavigateEvent extends Event {
67
+ navigationType: 'push' | 'replace' | 'reload' | 'traverse'
68
+ destination: NavigationDestination
69
+ canIntercept: boolean
70
+ userInitiated: boolean
71
+ hashChange: boolean
72
+ signal: AbortSignal
73
+ formData: FormData | null
74
+ downloadRequest: string | null
75
+ info: unknown
76
+ intercept(options?: {
77
+ handler?: () => Promise<void>
78
+ focusReset?: 'after-transition' | 'manual'
79
+ scroll?: 'after-transition' | 'manual'
80
+ }): void
81
+ scroll(): void
82
+ }
83
+
84
+ interface NavigationNavigateOptions {
85
+ state?: unknown
86
+ info?: unknown
87
+ history?: 'auto' | 'push' | 'replace'
88
+ }
89
+
90
+ interface NavigationResult {
91
+ committed: Promise<NavigationHistoryEntry>
92
+ finished: Promise<NavigationHistoryEntry>
93
+ }
94
+
95
+ interface Navigation extends EventTarget {
96
+ entries(): NavigationHistoryEntry[]
97
+ readonly currentEntry: NavigationHistoryEntry | null
98
+ readonly transition: NavigationTransition | null
99
+ readonly canGoBack: boolean
100
+ readonly canGoForward: boolean
101
+ navigate(url: string, options?: NavigationNavigateOptions): NavigationResult
102
+ reload(options?: { state?: unknown; info?: unknown }): NavigationResult
103
+ traverseTo(key: string, options?: { info?: unknown }): NavigationResult
104
+ back(options?: { info?: unknown }): NavigationResult
105
+ forward(options?: { info?: unknown }): NavigationResult
106
+ addEventListener(type: 'navigate', listener: (event: NavigateEvent) => void): void
107
+ addEventListener(type: string, listener: EventListener): void
108
+ removeEventListener(type: 'navigate', listener: (event: NavigateEvent) => void): void
109
+ removeEventListener(type: string, listener: EventListener): void
110
+ }
111
+
112
+ interface NavigationTransition {
113
+ navigationType: 'push' | 'replace' | 'reload' | 'traverse'
114
+ from: NavigationHistoryEntry
115
+ finished: Promise<void>
116
+ }
117
+
118
+ interface Window {
119
+ navigation: Navigation
120
+ }
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ // ─── Engine ─────────────────────────────────────────────────────────────────
2
+ export { NavigationAbortedError, Router } from './engine/router'
3
+ export type { RouterOptions, RouterSubscriber } from './engine/router'
4
+
5
+ export { buildRouteTree, createRouteNode } from './engine/route-tree'
6
+ export type { CreateRouteNodeOptions } from './engine/route-tree'
7
+
8
+ export { matchRoutes } from './engine/matcher'
9
+
10
+ export { createBrowserNavigationAdapter, createMemoryNavigationAdapter } from './engine/navigation-adapter'
11
+ export type { MemoryNavigationAdapterOptions } from './engine/navigation-adapter'
12
+
13
+ // ─── Types ──────────────────────────────────────────────────────────────────
14
+ export type {
15
+ BlockerFn,
16
+ BuzolaNavigateEvent,
17
+ BuzolaPageMap,
18
+ BuzolaPersistentParams,
19
+ EffectivePageParams,
20
+ GuardContext,
21
+ GuardRedirect,
22
+ NavigateOptions,
23
+ NavigationAdapter,
24
+ PageParams,
25
+ RegisteredPage,
26
+ RouteComponent,
27
+ RouteConfig,
28
+ RouteGuard,
29
+ RouteGuardResult,
30
+ RouteMatch,
31
+ RouteNode,
32
+ RouterState,
33
+ StandardSchema,
34
+ } from './engine/types'
35
+
36
+ // ─── Schema ──────────────────────────────────────────────────────────────────
37
+ export { s } from './engine/schema'
38
+
39
+ // ─── Route definition ───────────────────────────────────────────────────────
40
+ export { createPage } from './define/create-page'
41
+ export type { CatchContext, PageDefinition, PageProps } from './define/create-page'
42
+
43
+ // ─── React (re-export from subpath) ─────────────────────────────────────────
44
+ export {
45
+ BuzolaProvider,
46
+ ErrorBoundary,
47
+ Link,
48
+ Outlet,
49
+ RouteContext,
50
+ RouterContext,
51
+ useBlocker,
52
+ useInvalidate,
53
+ useNavigate,
54
+ useNavigationState,
55
+ useParams,
56
+ useRoute,
57
+ useRouter,
58
+ useRouterState,
59
+ useSearchParams,
60
+ } from './react/index'
61
+ export type { BlockerState, BuzolaProviderProps, LinkProps, OutletProps, RouteContextValue } from './react/index'
@@ -0,0 +1,24 @@
1
+ import { createContext } from 'react'
2
+ import type { Router } from '../engine/router'
3
+ import type { RouteMatch, RouterState } from '../engine/types'
4
+
5
+ /**
6
+ * Context providing the Router instance.
7
+ */
8
+ export const RouterContext = createContext<Router | null>(null)
9
+
10
+ /**
11
+ * Context for the current route matching state.
12
+ */
13
+ export interface RouteContextValue {
14
+ /** Current router state. */
15
+ state: RouterState
16
+ /** Current depth in the route match chain. */
17
+ depth: number
18
+ /** All matches from root to leaf. */
19
+ matches: RouteMatch[]
20
+ /** Merged params from root to current depth. */
21
+ params: Record<string, string>
22
+ }
23
+
24
+ export const RouteContext = createContext<RouteContextValue | null>(null)
@@ -0,0 +1,56 @@
1
+ import React, { Component } from 'react'
2
+
3
+ interface ErrorBoundaryProps {
4
+ fallback?: React.ReactNode | ((error: Error) => React.ReactNode)
5
+ /** When this key changes, the error state is reset. */
6
+ resetKey?: string
7
+ children: React.ReactNode
8
+ }
9
+
10
+ interface ErrorBoundaryState {
11
+ error: Error | null
12
+ prevResetKey: string | undefined
13
+ }
14
+
15
+ /**
16
+ * Error boundary for catching rendering errors in route components.
17
+ * Resets automatically when `resetKey` changes (e.g., on navigation to a different route).
18
+ */
19
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
20
+ state: ErrorBoundaryState = { error: null, prevResetKey: undefined }
21
+
22
+ static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
23
+ return { error }
24
+ }
25
+
26
+ static getDerivedStateFromProps(
27
+ props: ErrorBoundaryProps,
28
+ state: ErrorBoundaryState,
29
+ ): Partial<ErrorBoundaryState> | null {
30
+ // Reset error state when resetKey changes (i.e., route changed)
31
+ if (state.error && props.resetKey !== state.prevResetKey) {
32
+ return { error: null, prevResetKey: props.resetKey }
33
+ }
34
+ if (props.resetKey !== state.prevResetKey) {
35
+ return { prevResetKey: props.resetKey }
36
+ }
37
+ return null
38
+ }
39
+
40
+ componentDidCatch(error: Error, info: React.ErrorInfo): void {
41
+ console.error('Buzola route error:', error, info)
42
+ }
43
+
44
+ render(): React.ReactNode {
45
+ if (this.state.error) {
46
+ if (typeof this.props.fallback === 'function') {
47
+ return this.props.fallback(this.state.error)
48
+ }
49
+ if (this.props.fallback) {
50
+ return this.props.fallback
51
+ }
52
+ return React.createElement('div', { style: { color: 'red' } }, `Route error: ${this.state.error.message}`)
53
+ }
54
+ return this.props.children
55
+ }
56
+ }
@@ -0,0 +1,211 @@
1
+ import { use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { Router } from '../engine/router'
3
+ import type { EffectivePageParams, NavigateOptions, RegisteredPage, RouterState, StandardSchema } from '../engine/types'
4
+ import { RouteContext, RouterContext } from './context'
5
+
6
+ /**
7
+ * Get the Router instance.
8
+ */
9
+ export function useRouter(): Router {
10
+ const router = use(RouterContext)
11
+ if (!router) {
12
+ throw new Error('useRouter must be used within a <BuzolaProvider>')
13
+ }
14
+ return router
15
+ }
16
+
17
+ /**
18
+ * Get the current route context.
19
+ */
20
+ function useRouteContext() {
21
+ const context = use(RouteContext)
22
+ if (!context) {
23
+ throw new Error('Route hooks must be used within a <BuzolaProvider>')
24
+ }
25
+ return context
26
+ }
27
+
28
+ /**
29
+ * Type-safe navigation function.
30
+ * Uses page IDs instead of URL patterns.
31
+ * Persistent params are optional — resolved from current URL or fallback.
32
+ */
33
+ type NavigateFn = <P extends RegisteredPage>(
34
+ to: P,
35
+ ...args: [keyof EffectivePageParams<P>] extends [never] ? [options?: NavigateOptions]
36
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
37
+ : {} extends EffectivePageParams<P> ? [options?: NavigateOptions & { params?: EffectivePageParams<P> }]
38
+ : [options: NavigateOptions & { params: EffectivePageParams<P> }]
39
+ ) => void
40
+
41
+ /**
42
+ * Hook to get a type-safe navigate function.
43
+ * Navigates using page IDs — the router looks up the route pattern from the page registry.
44
+ */
45
+ export function useNavigate(): NavigateFn {
46
+ const router = useRouter()
47
+
48
+ return useCallback(
49
+ (to: string, options?: NavigateOptions & { params?: Record<string, string> }) => {
50
+ router.navigateToPage(to, options?.params, options)
51
+ },
52
+ [router],
53
+ ) as NavigateFn
54
+ }
55
+
56
+ /**
57
+ * Hook to get typed route params.
58
+ * Params are merged from all matched routes (root to current depth).
59
+ */
60
+ export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
61
+ const context = useRouteContext()
62
+ return context.params as T
63
+ }
64
+
65
+ /**
66
+ * Hook to get and validate search params.
67
+ * Optionally validates with a Standard Schema compatible schema.
68
+ */
69
+ export function useSearchParams<T = Record<string, string>>(
70
+ schema?: StandardSchema<T>,
71
+ ): T {
72
+ const context = useRouteContext()
73
+
74
+ return useMemo(() => {
75
+ const searchParams = context.state.location.searchParams
76
+ const raw: Record<string, string[]> = {}
77
+ searchParams.forEach((value, key) => {
78
+ const existing = raw[key]
79
+ if (existing) {
80
+ existing.push(value)
81
+ } else {
82
+ raw[key] = [value]
83
+ }
84
+ })
85
+
86
+ if (schema) {
87
+ const result = schema['~standard'].validate(raw)
88
+ if ('issues' in result) {
89
+ throw new Error(
90
+ `Search params validation failed: ${result.issues.map(i => i.message).join(', ')}`,
91
+ )
92
+ }
93
+ return result.value
94
+ }
95
+
96
+ return raw as T
97
+ }, [context.state.location.searchParams, schema])
98
+ }
99
+
100
+ /**
101
+ * Hook to get information about the current route.
102
+ */
103
+ export function useRoute() {
104
+ const context = useRouteContext()
105
+ const router = useRouter()
106
+ const { state, matches, params, depth } = context
107
+
108
+ return useMemo(() => ({
109
+ /** Current route's params. */
110
+ params,
111
+ /** All route matches from root to leaf. */
112
+ matches,
113
+ /** Current match depth. */
114
+ depth,
115
+ /** Whether a navigation is pending. */
116
+ isPending: state.isPending,
117
+ /** Current pathname (with base path stripped). */
118
+ pathname: router.stripBasePath(state.location.pathname),
119
+ }), [params, matches, depth, state.isPending, state.location.pathname, router])
120
+ }
121
+
122
+ /**
123
+ * Hook to get the Navigation API state for the current entry.
124
+ */
125
+ export function useNavigationState<T = unknown>(): T | undefined {
126
+ const context = useRouteContext()
127
+ return context.state.navigationState as T | undefined
128
+ }
129
+
130
+ /**
131
+ * Hook to get the current RouterState.
132
+ */
133
+ export function useRouterState(): RouterState {
134
+ const context = useRouteContext()
135
+ return context.state
136
+ }
137
+
138
+ /**
139
+ * Hook to get a function that invalidates all page loaders.
140
+ * Loaders re-run in the background while stale data is still shown.
141
+ */
142
+ export function useInvalidate(): () => void {
143
+ const router = useRouter()
144
+ return useCallback(() => router.invalidateAll(), [router])
145
+ }
146
+
147
+ // ─── Blocker ─────────────────────────────────────────────────────────────────
148
+
149
+ export interface BlockerState {
150
+ /** Whether a navigation is currently blocked. */
151
+ state: 'idle' | 'blocked'
152
+ /** Allow the blocked navigation to proceed. */
153
+ proceed: () => void
154
+ /** Cancel the blocked navigation and stay on the current page. */
155
+ reset: () => void
156
+ }
157
+
158
+ /**
159
+ * Hook to block navigations (e.g., to warn about unsaved changes).
160
+ *
161
+ * @param when - Whether blocking is active. Pass `true` to block, `false` to allow.
162
+ * @returns An object with the blocker state and proceed/reset functions.
163
+ *
164
+ * @example
165
+ * ```tsx
166
+ * const blocker = useBlocker(formIsDirty);
167
+ *
168
+ * if (blocker.state === 'blocked') {
169
+ * return <Dialog onConfirm={blocker.proceed} onCancel={blocker.reset} />;
170
+ * }
171
+ * ```
172
+ */
173
+ export function useBlocker(when: boolean): BlockerState {
174
+ const router = useRouter()
175
+ const [state, setState] = useState<'idle' | 'blocked'>('idle')
176
+ const resolverRef = useRef<((proceed: boolean) => void) | null>(null)
177
+
178
+ const blockerFn = useCallback((): Promise<boolean> => {
179
+ return new Promise((resolve) => {
180
+ resolverRef.current = resolve
181
+ setState('blocked')
182
+ })
183
+ }, [])
184
+
185
+ useEffect(() => {
186
+ if (!when) return
187
+ const cleanup = router.addBlocker(blockerFn)
188
+ return () => {
189
+ cleanup()
190
+ // If there's a pending blocker when unmounting, auto-proceed
191
+ if (resolverRef.current) {
192
+ resolverRef.current(true)
193
+ resolverRef.current = null
194
+ }
195
+ }
196
+ }, [router, when, blockerFn])
197
+
198
+ const proceed = useCallback(() => {
199
+ resolverRef.current?.(true)
200
+ resolverRef.current = null
201
+ setState('idle')
202
+ }, [])
203
+
204
+ const reset = useCallback(() => {
205
+ resolverRef.current?.(false)
206
+ resolverRef.current = null
207
+ setState('idle')
208
+ }, [])
209
+
210
+ return { state, proceed, reset }
211
+ }