@buzola/router 0.0.3 → 0.0.4
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/define/create-page.d.ts +17 -5
- package/dist/define/create-page.d.ts.map +1 -1
- package/dist/define/create-page.js +78 -38
- package/dist/define/create-page.js.map +1 -1
- package/dist/engine/loader-cache.d.ts +18 -0
- package/dist/engine/loader-cache.d.ts.map +1 -0
- package/dist/engine/loader-cache.js +48 -0
- package/dist/engine/loader-cache.js.map +1 -0
- package/dist/engine/matcher.d.ts +1 -1
- package/dist/engine/matcher.d.ts.map +1 -1
- package/dist/engine/navigation-adapter.d.ts +1 -1
- package/dist/engine/navigation-adapter.d.ts.map +1 -1
- package/dist/engine/route-tree.d.ts +1 -2
- package/dist/engine/route-tree.d.ts.map +1 -1
- package/dist/engine/route-tree.js +5 -4
- package/dist/engine/route-tree.js.map +1 -1
- package/dist/engine/router.d.ts +7 -10
- package/dist/engine/router.d.ts.map +1 -1
- package/dist/engine/router.js +23 -67
- package/dist/engine/router.js.map +1 -1
- package/dist/engine/schema.d.ts +5 -1
- package/dist/engine/schema.d.ts.map +1 -1
- package/dist/engine/schema.js +11 -1
- package/dist/engine/schema.js.map +1 -1
- package/dist/engine/types.d.ts +2 -15
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/engine/utils.d.ts +8 -0
- package/dist/engine/utils.d.ts.map +1 -1
- package/dist/engine/utils.js +10 -3
- package/dist/engine/utils.js.map +1 -1
- package/dist/index.d.ts +14 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/react/context.d.ts +2 -2
- package/dist/react/context.d.ts.map +1 -1
- package/dist/react/hooks.d.ts +3 -3
- package/dist/react/hooks.d.ts.map +1 -1
- package/dist/react/hooks.js +11 -9
- package/dist/react/hooks.js.map +1 -1
- package/dist/react/index.d.ts +11 -11
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +6 -6
- package/dist/react/index.js.map +1 -1
- package/dist/react/link.d.ts +5 -0
- package/dist/react/link.d.ts.map +1 -1
- package/dist/react/link.js +10 -1
- package/dist/react/link.js.map +1 -1
- package/dist/react/outlet.d.ts +3 -1
- package/dist/react/outlet.d.ts.map +1 -1
- package/dist/react/outlet.js +25 -1
- package/dist/react/outlet.js.map +1 -1
- package/dist/react/provider.d.ts +15 -2
- package/dist/react/provider.d.ts.map +1 -1
- package/dist/react/provider.js +15 -4
- package/dist/react/provider.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/create-page.test.ts +42 -6
- package/src/__tests__/loader-cache.test.ts +115 -0
- package/src/__tests__/matcher.test.ts +3 -3
- package/src/__tests__/react.test.tsx +1020 -0
- package/src/__tests__/router.test.ts +4 -214
- package/src/__tests__/setup-dom.ts +21 -0
- package/src/define/create-page.tsx +94 -46
- package/src/engine/loader-cache.ts +55 -0
- package/src/engine/matcher.ts +1 -1
- package/src/engine/navigation-adapter.ts +1 -1
- package/src/engine/route-tree.ts +4 -6
- package/src/engine/router.ts +27 -73
- package/src/engine/schema.ts +13 -2
- package/src/engine/types.ts +2 -18
- package/src/engine/utils.ts +11 -3
- package/src/index.ts +15 -17
- package/src/react/context.ts +2 -2
- package/src/react/hooks.ts +14 -13
- package/src/react/index.ts +21 -11
- package/src/react/link.tsx +16 -0
- package/src/react/outlet.tsx +32 -1
- package/src/react/provider.tsx +33 -5
- package/dist/__tests__/create-page.test.d.ts +0 -2
- package/dist/__tests__/create-page.test.d.ts.map +0 -1
- package/dist/__tests__/create-page.test.js +0 -457
- package/dist/__tests__/create-page.test.js.map +0 -1
- package/dist/__tests__/matcher.test.d.ts +0 -2
- package/dist/__tests__/matcher.test.d.ts.map +0 -1
- package/dist/__tests__/matcher.test.js +0 -108
- package/dist/__tests__/matcher.test.js.map +0 -1
- package/dist/__tests__/router.test.d.ts +0 -2
- package/dist/__tests__/router.test.d.ts.map +0 -1
- package/dist/__tests__/router.test.js +0 -327
- package/dist/__tests__/router.test.js.map +0 -1
- package/dist/__tests__/type-safety.test.d.ts +0 -15
- package/dist/__tests__/type-safety.test.d.ts.map +0 -1
- package/dist/__tests__/type-safety.test.js +0 -163
- package/dist/__tests__/type-safety.test.js.map +0 -1
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
import './setup-dom.js'
|
|
2
|
+
import { act, cleanup, fireEvent, render, renderHook } from '@testing-library/react'
|
|
3
|
+
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { createMemoryNavigationAdapter } from '../engine/navigation-adapter.js'
|
|
6
|
+
import { buildRouteTree } from '../engine/route-tree.js'
|
|
7
|
+
import { Router } from '../engine/router.js'
|
|
8
|
+
import type { RouteConfig, StandardSchema } from '../engine/types.js'
|
|
9
|
+
import {
|
|
10
|
+
BuzolaProvider,
|
|
11
|
+
ErrorBoundary,
|
|
12
|
+
Link,
|
|
13
|
+
Outlet,
|
|
14
|
+
useBlocker,
|
|
15
|
+
useInvalidate,
|
|
16
|
+
useNavigate,
|
|
17
|
+
useNavigationState,
|
|
18
|
+
useParams,
|
|
19
|
+
useRoute,
|
|
20
|
+
useRouter,
|
|
21
|
+
useRouterState,
|
|
22
|
+
useSearchParams,
|
|
23
|
+
} from '../react/index.js'
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup)
|
|
26
|
+
|
|
27
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function createTestRouter(
|
|
30
|
+
configs: RouteConfig[],
|
|
31
|
+
initialURL = 'http://localhost/',
|
|
32
|
+
options?: Partial<{ basePath: string; pageRegistry: Record<string, string>; persistentParams: () => Record<string, string> }>,
|
|
33
|
+
) {
|
|
34
|
+
const routes = buildRouteTree(configs)
|
|
35
|
+
const adapter = createMemoryNavigationAdapter({ initialURL })
|
|
36
|
+
const router = new Router({ routes, adapter, ...options })
|
|
37
|
+
return { router, adapter }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderWithRouter(
|
|
41
|
+
ui: React.ReactNode,
|
|
42
|
+
configs: RouteConfig[],
|
|
43
|
+
initialURL = 'http://localhost/',
|
|
44
|
+
options?: Partial<{ basePath: string; pageRegistry: Record<string, string>; persistentParams: () => Record<string, string> }>,
|
|
45
|
+
) {
|
|
46
|
+
const { router, adapter } = createTestRouter(configs, initialURL, options)
|
|
47
|
+
const result = render(<BuzolaProvider router={router}>{ui}</BuzolaProvider>)
|
|
48
|
+
return { ...result, router, adapter }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Flush async intercept handler chain in the memory adapter */
|
|
52
|
+
async function flushNav() {
|
|
53
|
+
await act(async () => {
|
|
54
|
+
await new Promise(r => setTimeout(r, 0))
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Home() {
|
|
59
|
+
return <div>Home</div>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function About() {
|
|
63
|
+
return <div>About</div>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function UserProfile() {
|
|
67
|
+
const params = useParams<{ userId: string }>()
|
|
68
|
+
return <div>User {params.userId}</div>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function Layout({ children }: { children?: React.ReactNode }) {
|
|
72
|
+
return (
|
|
73
|
+
<div>
|
|
74
|
+
<header>Layout</header>
|
|
75
|
+
<Outlet />
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── BuzolaProvider ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe('BuzolaProvider', () => {
|
|
83
|
+
it('renders children', () => {
|
|
84
|
+
const { getByText } = renderWithRouter(
|
|
85
|
+
<div>Hello</div>,
|
|
86
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
87
|
+
)
|
|
88
|
+
expect(getByText('Hello')).toBeTruthy()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('provides router context so useRouter works', () => {
|
|
92
|
+
function Child() {
|
|
93
|
+
const router = useRouter()
|
|
94
|
+
return <div>{router ? 'has-router' : 'no-router'}</div>
|
|
95
|
+
}
|
|
96
|
+
const { getByText } = renderWithRouter(
|
|
97
|
+
<Child />,
|
|
98
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
99
|
+
)
|
|
100
|
+
expect(getByText('has-router')).toBeTruthy()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('re-renders on navigation', async () => {
|
|
104
|
+
function PathDisplay() {
|
|
105
|
+
const route = useRoute()
|
|
106
|
+
return <div data-testid="path">{route.pathname}</div>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { getByTestId, router } = renderWithRouter(
|
|
110
|
+
<PathDisplay />,
|
|
111
|
+
[
|
|
112
|
+
{ path: '/', component: Home, isIndex: true },
|
|
113
|
+
{ path: '/about', component: About },
|
|
114
|
+
],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
expect(getByTestId('path').textContent).toBe('/')
|
|
118
|
+
|
|
119
|
+
router.navigate('/about')
|
|
120
|
+
await flushNav()
|
|
121
|
+
|
|
122
|
+
expect(getByTestId('path').textContent).toBe('/about')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('renders default Outlet when no children provided', () => {
|
|
126
|
+
const { router } = createTestRouter(
|
|
127
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
128
|
+
)
|
|
129
|
+
const { getByText } = render(<BuzolaProvider router={router} />)
|
|
130
|
+
expect(getByText('Home')).toBeTruthy()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('wraps content with middleware', () => {
|
|
134
|
+
function Middleware({ children }: { params: Record<string, string>; children: React.ReactNode }) {
|
|
135
|
+
return <div data-testid="middleware">{children}</div>
|
|
136
|
+
}
|
|
137
|
+
const { router } = createTestRouter(
|
|
138
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
139
|
+
)
|
|
140
|
+
const { getByTestId, getByText } = render(
|
|
141
|
+
<BuzolaProvider router={router} middleware={Middleware} />,
|
|
142
|
+
)
|
|
143
|
+
expect(getByTestId('middleware')).toBeTruthy()
|
|
144
|
+
expect(getByText('Home')).toBeTruthy()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('passes merged params from all matches to middleware', () => {
|
|
148
|
+
function UserPage() {
|
|
149
|
+
return <div>User Page</div>
|
|
150
|
+
}
|
|
151
|
+
function Layout() {
|
|
152
|
+
return <Outlet />
|
|
153
|
+
}
|
|
154
|
+
function Middleware({ params, children }: { params: Record<string, string>; children: React.ReactNode }) {
|
|
155
|
+
return <div data-testid="params">{JSON.stringify(params)}{children}</div>
|
|
156
|
+
}
|
|
157
|
+
const { router } = createTestRouter(
|
|
158
|
+
[{
|
|
159
|
+
path: '/org/:orgId',
|
|
160
|
+
component: Layout,
|
|
161
|
+
isLayout: true,
|
|
162
|
+
children: [{ path: '/user/:userId', component: UserPage }],
|
|
163
|
+
}],
|
|
164
|
+
'http://localhost/org/abc/user/123',
|
|
165
|
+
)
|
|
166
|
+
const { getByTestId } = render(
|
|
167
|
+
<BuzolaProvider router={router} middleware={Middleware} />,
|
|
168
|
+
)
|
|
169
|
+
const paramsText = getByTestId('params').textContent!
|
|
170
|
+
expect(paramsText).toContain('"orgId":"abc"')
|
|
171
|
+
expect(paramsText).toContain('"userId":"123"')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('passes matches array to middleware', () => {
|
|
175
|
+
function Middleware({ matches, children }: { matches: unknown[]; children: React.ReactNode }) {
|
|
176
|
+
return <div data-testid="count">{matches.length}{children}</div>
|
|
177
|
+
}
|
|
178
|
+
const { router } = createTestRouter(
|
|
179
|
+
[{
|
|
180
|
+
path: '/a',
|
|
181
|
+
component: () => <Outlet />,
|
|
182
|
+
isLayout: true,
|
|
183
|
+
children: [{ path: '/b', component: () => <div>B</div> }],
|
|
184
|
+
}],
|
|
185
|
+
'http://localhost/a/b',
|
|
186
|
+
)
|
|
187
|
+
const { getByTestId } = render(
|
|
188
|
+
<BuzolaProvider router={router} middleware={Middleware} />,
|
|
189
|
+
)
|
|
190
|
+
expect(getByTestId('count').textContent).toStartWith('2')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('updates middleware params on navigation', async () => {
|
|
194
|
+
function Page() {
|
|
195
|
+
return <div>Page</div>
|
|
196
|
+
}
|
|
197
|
+
function Middleware({ params, children }: { params: Record<string, string>; children: React.ReactNode }) {
|
|
198
|
+
return <div data-testid="mid-params">{params.id}{children}</div>
|
|
199
|
+
}
|
|
200
|
+
const { router } = createTestRouter(
|
|
201
|
+
[
|
|
202
|
+
{ path: '/:id', component: Page },
|
|
203
|
+
],
|
|
204
|
+
'http://localhost/first',
|
|
205
|
+
)
|
|
206
|
+
const { getByTestId } = render(
|
|
207
|
+
<BuzolaProvider router={router} middleware={Middleware} />,
|
|
208
|
+
)
|
|
209
|
+
expect(getByTestId('mid-params').textContent).toStartWith('first')
|
|
210
|
+
|
|
211
|
+
router.navigate('/second')
|
|
212
|
+
await flushNav()
|
|
213
|
+
expect(getByTestId('mid-params').textContent).toStartWith('second')
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// ─── useRouter ───────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
describe('useRouter', () => {
|
|
220
|
+
it('returns the router instance', () => {
|
|
221
|
+
const { router } = createTestRouter([{ path: '/', component: Home, isIndex: true }])
|
|
222
|
+
const { result } = renderHook(() => useRouter(), {
|
|
223
|
+
wrapper: ({ children }) => <BuzolaProvider router={router}>{children}</BuzolaProvider>,
|
|
224
|
+
})
|
|
225
|
+
expect(result.current).toBe(router)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('throws when used outside provider', () => {
|
|
229
|
+
expect(() => {
|
|
230
|
+
renderHook(() => useRouter())
|
|
231
|
+
}).toThrow('useRouter must be used within a <BuzolaProvider>')
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// ─── useParams ───────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe('useParams', () => {
|
|
238
|
+
it('returns route params from context', () => {
|
|
239
|
+
function ParamReader() {
|
|
240
|
+
const params = useParams<{ userId: string }>()
|
|
241
|
+
return <div data-testid="params">{params.userId}</div>
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { getByTestId } = renderWithRouter(
|
|
245
|
+
<div />,
|
|
246
|
+
[
|
|
247
|
+
{
|
|
248
|
+
path: '/',
|
|
249
|
+
isLayout: true,
|
|
250
|
+
children: [
|
|
251
|
+
{ path: '/users/:userId', component: ParamReader },
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
'http://localhost/users/42',
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
// Params are on the RouteContext, but our ParamReader is a direct child, not rendered via Outlet.
|
|
259
|
+
// We need to render via Outlet to get the params in context.
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('returns merged params via Outlet rendering', async () => {
|
|
263
|
+
function ParamDisplay() {
|
|
264
|
+
const params = useParams<{ userId: string }>()
|
|
265
|
+
return <div data-testid="uid">{params.userId}</div>
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const configs: RouteConfig[] = [
|
|
269
|
+
{ path: '/users/:userId', component: ParamDisplay },
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
const { router } = createTestRouter(configs, 'http://localhost/users/42')
|
|
273
|
+
const { getByTestId } = render(<BuzolaProvider router={router} />)
|
|
274
|
+
|
|
275
|
+
expect(getByTestId('uid').textContent).toBe('42')
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// ─── useSearchParams ─────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe('useSearchParams', () => {
|
|
282
|
+
it('unwraps single-value to string', () => {
|
|
283
|
+
function SearchDisplay() {
|
|
284
|
+
const sp = useSearchParams()
|
|
285
|
+
return <div data-testid="sp">{JSON.stringify(sp)}</div>
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const { getByTestId } = renderWithRouter(
|
|
289
|
+
<SearchDisplay />,
|
|
290
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
291
|
+
'http://localhost/?foo=bar',
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const parsed = JSON.parse(getByTestId('sp').textContent!)
|
|
295
|
+
expect(parsed.foo).toBe('bar')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('keeps multi-value as array', () => {
|
|
299
|
+
function SearchDisplay() {
|
|
300
|
+
const sp = useSearchParams()
|
|
301
|
+
return <div data-testid="sp">{JSON.stringify(sp)}</div>
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const { getByTestId } = renderWithRouter(
|
|
305
|
+
<SearchDisplay />,
|
|
306
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
307
|
+
'http://localhost/?tag=a&tag=b',
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
const parsed = JSON.parse(getByTestId('sp').textContent!)
|
|
311
|
+
expect(parsed.tag).toEqual(['a', 'b'])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('validates with schema', () => {
|
|
315
|
+
const schema: StandardSchema<{ q: string }> = {
|
|
316
|
+
'~standard': {
|
|
317
|
+
version: 1,
|
|
318
|
+
vendor: 'test',
|
|
319
|
+
validate: (value: unknown) => {
|
|
320
|
+
const v = value as Record<string, unknown>
|
|
321
|
+
if (typeof v.q === 'string') return { value: v as { q: string } }
|
|
322
|
+
return { issues: [{ message: 'q is required' }] }
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function SearchDisplay() {
|
|
328
|
+
const sp = useSearchParams(schema)
|
|
329
|
+
return <div data-testid="sp">{sp.q}</div>
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const { getByTestId } = renderWithRouter(
|
|
333
|
+
<SearchDisplay />,
|
|
334
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
335
|
+
'http://localhost/?q=hello',
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
expect(getByTestId('sp').textContent).toBe('hello')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('throws on invalid schema', () => {
|
|
342
|
+
const schema: StandardSchema<{ q: string }> = {
|
|
343
|
+
'~standard': {
|
|
344
|
+
version: 1,
|
|
345
|
+
vendor: 'test',
|
|
346
|
+
validate: () => ({ issues: [{ message: 'invalid' }] }),
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function SearchDisplay() {
|
|
351
|
+
const sp = useSearchParams(schema)
|
|
352
|
+
return <div>{sp.q}</div>
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Suppress console.error from error boundary
|
|
356
|
+
const spy = spyOn(console, 'error').mockImplementation(() => {})
|
|
357
|
+
expect(() => {
|
|
358
|
+
renderWithRouter(
|
|
359
|
+
<SearchDisplay />,
|
|
360
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
361
|
+
)
|
|
362
|
+
}).toThrow('Search params validation failed')
|
|
363
|
+
spy.mockRestore()
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
// ─── useRoute ────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
describe('useRoute', () => {
|
|
370
|
+
it('returns route info', () => {
|
|
371
|
+
function RouteDisplay() {
|
|
372
|
+
const route = useRoute()
|
|
373
|
+
return (
|
|
374
|
+
<div>
|
|
375
|
+
<span data-testid="pathname">{route.pathname}</span>
|
|
376
|
+
<span data-testid="depth">{route.depth}</span>
|
|
377
|
+
<span data-testid="pending">{String(route.isPending)}</span>
|
|
378
|
+
<span data-testid="matches">{route.matches.length}</span>
|
|
379
|
+
</div>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const { getByTestId } = renderWithRouter(
|
|
384
|
+
<RouteDisplay />,
|
|
385
|
+
[{ path: '/about', component: About }],
|
|
386
|
+
'http://localhost/about',
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
expect(getByTestId('pathname').textContent).toBe('/about')
|
|
390
|
+
expect(getByTestId('depth').textContent).toBe('0')
|
|
391
|
+
expect(getByTestId('pending').textContent).toBe('false')
|
|
392
|
+
expect(getByTestId('matches').textContent).toBe('1')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('strips basePath from pathname', () => {
|
|
396
|
+
function RouteDisplay() {
|
|
397
|
+
const route = useRoute()
|
|
398
|
+
return <div data-testid="pathname">{route.pathname}</div>
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const { getByTestId } = renderWithRouter(
|
|
402
|
+
<RouteDisplay />,
|
|
403
|
+
[{ path: '/about', component: About }],
|
|
404
|
+
'http://localhost/app/about',
|
|
405
|
+
{ basePath: '/app' },
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
expect(getByTestId('pathname').textContent).toBe('/about')
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// ─── useRouterState / useNavigationState ─────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
describe('useRouterState', () => {
|
|
415
|
+
it('returns router state', () => {
|
|
416
|
+
function StateDisplay() {
|
|
417
|
+
const state = useRouterState()
|
|
418
|
+
return <div data-testid="path">{state.location.pathname}</div>
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const { getByTestId } = renderWithRouter(
|
|
422
|
+
<StateDisplay />,
|
|
423
|
+
[{ path: '/about', component: About }],
|
|
424
|
+
'http://localhost/about',
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
expect(getByTestId('path').textContent).toBe('/about')
|
|
428
|
+
})
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
describe('useNavigationState', () => {
|
|
432
|
+
it('returns undefined when no state set', () => {
|
|
433
|
+
function StateDisplay() {
|
|
434
|
+
const navState = useNavigationState()
|
|
435
|
+
return <div data-testid="ns">{navState === undefined ? 'none' : 'has'}</div>
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const { getByTestId } = renderWithRouter(
|
|
439
|
+
<StateDisplay />,
|
|
440
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
expect(getByTestId('ns').textContent).toBe('none')
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// ─── useNavigate ─────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
describe('useNavigate', () => {
|
|
450
|
+
it('navigates to a page by ID', async () => {
|
|
451
|
+
function NavButton() {
|
|
452
|
+
const navigate = useNavigate()
|
|
453
|
+
return <button onClick={() => navigate('about' as any)}>Go</button>
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function PathDisplay() {
|
|
457
|
+
const route = useRoute()
|
|
458
|
+
return <div data-testid="path">{route.pathname}</div>
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const { getByText, getByTestId, router } = renderWithRouter(
|
|
462
|
+
<div>
|
|
463
|
+
<NavButton />
|
|
464
|
+
<PathDisplay />
|
|
465
|
+
</div>,
|
|
466
|
+
[
|
|
467
|
+
{ path: '/', component: Home, isIndex: true },
|
|
468
|
+
{ path: '/about', component: About },
|
|
469
|
+
],
|
|
470
|
+
'http://localhost/',
|
|
471
|
+
{ pageRegistry: { about: '/about' } },
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
fireEvent.click(getByText('Go'))
|
|
475
|
+
await flushNav()
|
|
476
|
+
|
|
477
|
+
expect(getByTestId('path').textContent).toBe('/about')
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
// ─── useInvalidate ───────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
describe('useInvalidate', () => {
|
|
484
|
+
it('calls router.invalidateAll()', () => {
|
|
485
|
+
function InvalidateButton() {
|
|
486
|
+
const invalidate = useInvalidate()
|
|
487
|
+
return <button onClick={invalidate}>Invalidate</button>
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const { getByText, router } = renderWithRouter(
|
|
491
|
+
<InvalidateButton />,
|
|
492
|
+
[{ path: '/', component: Home, isIndex: true }],
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
const invalidateSpy = spyOn(router, 'invalidateAll')
|
|
496
|
+
fireEvent.click(getByText('Invalidate'))
|
|
497
|
+
|
|
498
|
+
expect(invalidateSpy).toHaveBeenCalledTimes(1)
|
|
499
|
+
invalidateSpy.mockRestore()
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// ─── Outlet ──────────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
describe('Outlet', () => {
|
|
506
|
+
it('renders component at current depth', () => {
|
|
507
|
+
const configs: RouteConfig[] = [
|
|
508
|
+
{ path: '/', component: Home, isIndex: true },
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
const { router } = createTestRouter(configs)
|
|
512
|
+
const { getByText } = render(<BuzolaProvider router={router} />)
|
|
513
|
+
expect(getByText('Home')).toBeTruthy()
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('shows default 404 when no match', () => {
|
|
517
|
+
const configs: RouteConfig[] = [
|
|
518
|
+
{ path: '/about', component: About },
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
const { router } = createTestRouter(configs, 'http://localhost/missing')
|
|
522
|
+
const { container } = render(<BuzolaProvider router={router} />)
|
|
523
|
+
expect(container.textContent).toContain('404')
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('shows custom notFound when no match', () => {
|
|
527
|
+
const configs: RouteConfig[] = [
|
|
528
|
+
{ path: '/about', component: About },
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
const { router } = createTestRouter(configs, 'http://localhost/missing')
|
|
532
|
+
const { container } = render(
|
|
533
|
+
<BuzolaProvider router={router}>
|
|
534
|
+
<Outlet notFound={<div>Custom 404</div>} />
|
|
535
|
+
</BuzolaProvider>,
|
|
536
|
+
)
|
|
537
|
+
expect(container.textContent).toBe('Custom 404')
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('renders nothing when notFound is null', () => {
|
|
541
|
+
const configs: RouteConfig[] = [
|
|
542
|
+
{ path: '/about', component: About },
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
const { router } = createTestRouter(configs, 'http://localhost/missing')
|
|
546
|
+
const { container } = render(
|
|
547
|
+
<BuzolaProvider router={router}>
|
|
548
|
+
<Outlet notFound={null} />
|
|
549
|
+
</BuzolaProvider>,
|
|
550
|
+
)
|
|
551
|
+
expect(container.textContent).toBe('')
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('skips componentless nodes', () => {
|
|
555
|
+
const configs: RouteConfig[] = [
|
|
556
|
+
{
|
|
557
|
+
path: '/admin',
|
|
558
|
+
isLayout: true,
|
|
559
|
+
// No component — should be skipped
|
|
560
|
+
children: [
|
|
561
|
+
{ path: '/dashboard', component: () => <div>Dashboard</div> },
|
|
562
|
+
],
|
|
563
|
+
},
|
|
564
|
+
]
|
|
565
|
+
|
|
566
|
+
const { router } = createTestRouter(configs, 'http://localhost/admin/dashboard')
|
|
567
|
+
const { getByText } = render(<BuzolaProvider router={router} />)
|
|
568
|
+
expect(getByText('Dashboard')).toBeTruthy()
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('merges params across nested layouts', () => {
|
|
572
|
+
function Inner() {
|
|
573
|
+
const params = useParams<{ orgId: string; userId: string }>()
|
|
574
|
+
return <div data-testid="params">{params.orgId}-{params.userId}</div>
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const configs: RouteConfig[] = [
|
|
578
|
+
{
|
|
579
|
+
path: '/org/:orgId',
|
|
580
|
+
component: Layout,
|
|
581
|
+
isLayout: true,
|
|
582
|
+
children: [
|
|
583
|
+
{ path: '/users/:userId', component: Inner },
|
|
584
|
+
],
|
|
585
|
+
},
|
|
586
|
+
]
|
|
587
|
+
|
|
588
|
+
const { router } = createTestRouter(configs, 'http://localhost/org/acme/users/42')
|
|
589
|
+
const { getByTestId } = render(<BuzolaProvider router={router} />)
|
|
590
|
+
expect(getByTestId('params').textContent).toBe('acme-42')
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('renders nested layouts', () => {
|
|
594
|
+
function OuterLayout() {
|
|
595
|
+
return (
|
|
596
|
+
<div>
|
|
597
|
+
<div>Outer</div>
|
|
598
|
+
<Outlet />
|
|
599
|
+
</div>
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function InnerLayout() {
|
|
604
|
+
return (
|
|
605
|
+
<div>
|
|
606
|
+
<div>Inner</div>
|
|
607
|
+
<Outlet />
|
|
608
|
+
</div>
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function Page() {
|
|
613
|
+
return <div>Page Content</div>
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const configs: RouteConfig[] = [
|
|
617
|
+
{
|
|
618
|
+
path: '/',
|
|
619
|
+
component: OuterLayout,
|
|
620
|
+
isLayout: true,
|
|
621
|
+
children: [
|
|
622
|
+
{
|
|
623
|
+
path: '/section',
|
|
624
|
+
component: InnerLayout,
|
|
625
|
+
isLayout: true,
|
|
626
|
+
children: [
|
|
627
|
+
{ path: '/page', component: Page },
|
|
628
|
+
],
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
},
|
|
632
|
+
]
|
|
633
|
+
|
|
634
|
+
const { router } = createTestRouter(configs, 'http://localhost/section/page')
|
|
635
|
+
const { getByText } = render(<BuzolaProvider router={router} />)
|
|
636
|
+
expect(getByText('Outer')).toBeTruthy()
|
|
637
|
+
expect(getByText('Inner')).toBeTruthy()
|
|
638
|
+
expect(getByText('Page Content')).toBeTruthy()
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
// ─── Link ────────────────────────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
describe('Link', () => {
|
|
645
|
+
const configs: RouteConfig[] = [
|
|
646
|
+
{ path: '/', component: Home, isIndex: true },
|
|
647
|
+
{ path: '/about', component: About },
|
|
648
|
+
]
|
|
649
|
+
const registry = { home: '/', about: '/about' }
|
|
650
|
+
|
|
651
|
+
it('renders correct href', () => {
|
|
652
|
+
const { getByText } = renderWithRouter(
|
|
653
|
+
<Link to={'about' as any}>Go</Link>,
|
|
654
|
+
configs,
|
|
655
|
+
'http://localhost/',
|
|
656
|
+
{ pageRegistry: registry },
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
const anchor = getByText('Go') as HTMLAnchorElement
|
|
660
|
+
expect(anchor.getAttribute('href')).toBe('/about')
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it('sets data-active on exact match', () => {
|
|
664
|
+
const { getByText } = renderWithRouter(
|
|
665
|
+
<Link to={'about' as any}>Go</Link>,
|
|
666
|
+
configs,
|
|
667
|
+
'http://localhost/about',
|
|
668
|
+
{ pageRegistry: registry },
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
const anchor = getByText('Go')
|
|
672
|
+
expect(anchor.hasAttribute('data-active')).toBe(true)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('does not set data-active when not matching', () => {
|
|
676
|
+
const { getByText } = renderWithRouter(
|
|
677
|
+
<Link to={'about' as any}>Go</Link>,
|
|
678
|
+
configs,
|
|
679
|
+
'http://localhost/',
|
|
680
|
+
{ pageRegistry: registry },
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
const anchor = getByText('Go')
|
|
684
|
+
expect(anchor.hasAttribute('data-active')).toBe(false)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('sets data-active on prefix match when activeExact=false', () => {
|
|
688
|
+
const sectionConfigs: RouteConfig[] = [
|
|
689
|
+
{ path: '/docs', component: Home },
|
|
690
|
+
{ path: '/docs/intro', component: About },
|
|
691
|
+
]
|
|
692
|
+
const sectionRegistry = { docs: '/docs', docsIntro: '/docs/intro' }
|
|
693
|
+
|
|
694
|
+
const { getByText } = renderWithRouter(
|
|
695
|
+
<Link to={'docs' as any} activeExact={false}>Docs</Link>,
|
|
696
|
+
sectionConfigs,
|
|
697
|
+
'http://localhost/docs/intro',
|
|
698
|
+
{ pageRegistry: sectionRegistry },
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
const anchor = getByText('Docs')
|
|
702
|
+
expect(anchor.hasAttribute('data-active')).toBe(true)
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('ignores clicks with modifier keys', () => {
|
|
706
|
+
const navigateSpy = mock()
|
|
707
|
+
|
|
708
|
+
function TestLink() {
|
|
709
|
+
const router = useRouter()
|
|
710
|
+
spyOn(router, 'navigateToPage').mockImplementation(navigateSpy)
|
|
711
|
+
return <Link to={'about' as any} replace>Go</Link>
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const { getByText } = renderWithRouter(
|
|
715
|
+
<TestLink />,
|
|
716
|
+
configs,
|
|
717
|
+
'http://localhost/',
|
|
718
|
+
{ pageRegistry: registry },
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
const anchor = getByText('Go')
|
|
722
|
+
fireEvent.click(anchor, { metaKey: true })
|
|
723
|
+
|
|
724
|
+
expect(navigateSpy).not.toHaveBeenCalled()
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('uses programmatic navigation for replace', async () => {
|
|
728
|
+
function TestLink() {
|
|
729
|
+
return <Link to={'about' as any} replace>Go</Link>
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const { getByText, router } = renderWithRouter(
|
|
733
|
+
<TestLink />,
|
|
734
|
+
configs,
|
|
735
|
+
'http://localhost/',
|
|
736
|
+
{ pageRegistry: registry },
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
const navigateSpy = spyOn(router, 'navigateToPage')
|
|
740
|
+
fireEvent.click(getByText('Go'))
|
|
741
|
+
|
|
742
|
+
expect(navigateSpy).toHaveBeenCalled()
|
|
743
|
+
navigateSpy.mockRestore()
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it('renders child element with asChild', () => {
|
|
747
|
+
const { container } = renderWithRouter(
|
|
748
|
+
<Link to={'about' as any} asChild>
|
|
749
|
+
<button type="button">Go</button>
|
|
750
|
+
</Link>,
|
|
751
|
+
configs,
|
|
752
|
+
'http://localhost/',
|
|
753
|
+
{ pageRegistry: registry },
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
// Should render a <button>, not an <a>
|
|
757
|
+
const button = container.querySelector('button')!
|
|
758
|
+
expect(button).toBeTruthy()
|
|
759
|
+
expect(button.getAttribute('href')).toBe('/about')
|
|
760
|
+
expect(container.querySelector('a')).toBeNull()
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('sets data-active on asChild element', () => {
|
|
764
|
+
const { container } = renderWithRouter(
|
|
765
|
+
<Link to={'about' as any} asChild>
|
|
766
|
+
<button type="button">Go</button>
|
|
767
|
+
</Link>,
|
|
768
|
+
configs,
|
|
769
|
+
'http://localhost/about',
|
|
770
|
+
{ pageRegistry: registry },
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
const button = container.querySelector('button')!
|
|
774
|
+
expect(button.hasAttribute('data-active')).toBe(true)
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
it('uses programmatic navigation for state', () => {
|
|
778
|
+
function TestLink() {
|
|
779
|
+
return <Link to={'about' as any} state={{ from: 'test' }}>Go</Link>
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const { getByText, router } = renderWithRouter(
|
|
783
|
+
<TestLink />,
|
|
784
|
+
configs,
|
|
785
|
+
'http://localhost/',
|
|
786
|
+
{ pageRegistry: registry },
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
const navigateSpy = spyOn(router, 'navigateToPage')
|
|
790
|
+
fireEvent.click(getByText('Go'))
|
|
791
|
+
|
|
792
|
+
expect(navigateSpy).toHaveBeenCalledWith(
|
|
793
|
+
'about',
|
|
794
|
+
undefined,
|
|
795
|
+
expect.objectContaining({ state: { from: 'test' } }),
|
|
796
|
+
)
|
|
797
|
+
navigateSpy.mockRestore()
|
|
798
|
+
})
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
// ─── useBlocker ──────────────────────────────────────────────────────────────
|
|
802
|
+
|
|
803
|
+
describe('useBlocker', () => {
|
|
804
|
+
const configs: RouteConfig[] = [
|
|
805
|
+
{ path: '/', component: Home, isIndex: true },
|
|
806
|
+
{ path: '/about', component: About },
|
|
807
|
+
]
|
|
808
|
+
|
|
809
|
+
it('is idle initially', () => {
|
|
810
|
+
const { router } = createTestRouter(configs)
|
|
811
|
+
const { result } = renderHook(() => useBlocker(true), {
|
|
812
|
+
wrapper: ({ children }) => <BuzolaProvider router={router}>{children}</BuzolaProvider>,
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
expect(result.current.state).toBe('idle')
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
it('blocks navigation', async () => {
|
|
819
|
+
function BlockerComponent() {
|
|
820
|
+
const blocker = useBlocker(true)
|
|
821
|
+
const route = useRoute()
|
|
822
|
+
return (
|
|
823
|
+
<div>
|
|
824
|
+
<div data-testid="state">{blocker.state}</div>
|
|
825
|
+
<div data-testid="path">{route.pathname}</div>
|
|
826
|
+
<button data-testid="proceed" onClick={blocker.proceed}>Proceed</button>
|
|
827
|
+
<button data-testid="reset" onClick={blocker.reset}>Reset</button>
|
|
828
|
+
</div>
|
|
829
|
+
)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const { getByTestId, router } = renderWithRouter(
|
|
833
|
+
<BlockerComponent />,
|
|
834
|
+
configs,
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
expect(getByTestId('state').textContent).toBe('idle')
|
|
838
|
+
|
|
839
|
+
// Try to navigate — should be blocked
|
|
840
|
+
router.navigate('/about')
|
|
841
|
+
await flushNav()
|
|
842
|
+
|
|
843
|
+
expect(getByTestId('state').textContent).toBe('blocked')
|
|
844
|
+
expect(getByTestId('path').textContent).toBe('/')
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('proceed() allows blocked navigation', async () => {
|
|
848
|
+
function BlockerComponent() {
|
|
849
|
+
const blocker = useBlocker(true)
|
|
850
|
+
const route = useRoute()
|
|
851
|
+
return (
|
|
852
|
+
<div>
|
|
853
|
+
<div data-testid="state">{blocker.state}</div>
|
|
854
|
+
<div data-testid="path">{route.pathname}</div>
|
|
855
|
+
<button data-testid="proceed" onClick={blocker.proceed}>Proceed</button>
|
|
856
|
+
</div>
|
|
857
|
+
)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const { getByTestId, router } = renderWithRouter(
|
|
861
|
+
<BlockerComponent />,
|
|
862
|
+
configs,
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
router.navigate('/about')
|
|
866
|
+
await flushNav()
|
|
867
|
+
|
|
868
|
+
expect(getByTestId('state').textContent).toBe('blocked')
|
|
869
|
+
|
|
870
|
+
// Proceed with navigation
|
|
871
|
+
fireEvent.click(getByTestId('proceed'))
|
|
872
|
+
await flushNav()
|
|
873
|
+
|
|
874
|
+
expect(getByTestId('state').textContent).toBe('idle')
|
|
875
|
+
expect(getByTestId('path').textContent).toBe('/about')
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
it('reset() cancels blocked navigation', async () => {
|
|
879
|
+
function BlockerComponent() {
|
|
880
|
+
const blocker = useBlocker(true)
|
|
881
|
+
const route = useRoute()
|
|
882
|
+
return (
|
|
883
|
+
<div>
|
|
884
|
+
<div data-testid="state">{blocker.state}</div>
|
|
885
|
+
<div data-testid="path">{route.pathname}</div>
|
|
886
|
+
<button data-testid="reset" onClick={blocker.reset}>Reset</button>
|
|
887
|
+
</div>
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const { getByTestId, router } = renderWithRouter(
|
|
892
|
+
<BlockerComponent />,
|
|
893
|
+
configs,
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
router.navigate('/about')
|
|
897
|
+
await flushNav()
|
|
898
|
+
|
|
899
|
+
expect(getByTestId('state').textContent).toBe('blocked')
|
|
900
|
+
|
|
901
|
+
// Cancel navigation
|
|
902
|
+
fireEvent.click(getByTestId('reset'))
|
|
903
|
+
await flushNav()
|
|
904
|
+
|
|
905
|
+
expect(getByTestId('state').textContent).toBe('idle')
|
|
906
|
+
expect(getByTestId('path').textContent).toBe('/')
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
it('does not block when disabled', async () => {
|
|
910
|
+
function NonBlocker() {
|
|
911
|
+
const blocker = useBlocker(false)
|
|
912
|
+
const route = useRoute()
|
|
913
|
+
return (
|
|
914
|
+
<div>
|
|
915
|
+
<div data-testid="state">{blocker.state}</div>
|
|
916
|
+
<div data-testid="path">{route.pathname}</div>
|
|
917
|
+
</div>
|
|
918
|
+
)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const { getByTestId, router } = renderWithRouter(
|
|
922
|
+
<NonBlocker />,
|
|
923
|
+
configs,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
router.navigate('/about')
|
|
927
|
+
await flushNav()
|
|
928
|
+
|
|
929
|
+
expect(getByTestId('state').textContent).toBe('idle')
|
|
930
|
+
expect(getByTestId('path').textContent).toBe('/about')
|
|
931
|
+
})
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
// ─── ErrorBoundary ───────────────────────────────────────────────────────────
|
|
935
|
+
|
|
936
|
+
describe('ErrorBoundary', () => {
|
|
937
|
+
it('renders children when no error', () => {
|
|
938
|
+
const { getByText } = render(
|
|
939
|
+
<ErrorBoundary>
|
|
940
|
+
<div>OK</div>
|
|
941
|
+
</ErrorBoundary>,
|
|
942
|
+
)
|
|
943
|
+
expect(getByText('OK')).toBeTruthy()
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
it('catches errors and shows default fallback', () => {
|
|
947
|
+
function Throw(): React.ReactNode {
|
|
948
|
+
throw new Error('boom')
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const spy = spyOn(console, 'error').mockImplementation(() => {})
|
|
952
|
+
const { container } = render(
|
|
953
|
+
<ErrorBoundary>
|
|
954
|
+
<Throw />
|
|
955
|
+
</ErrorBoundary>,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
expect(container.textContent).toContain('Route error: boom')
|
|
959
|
+
spy.mockRestore()
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
it('renders custom ReactNode fallback', () => {
|
|
963
|
+
function Throw(): React.ReactNode {
|
|
964
|
+
throw new Error('boom')
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const spy = spyOn(console, 'error').mockImplementation(() => {})
|
|
968
|
+
const { getByText } = render(
|
|
969
|
+
<ErrorBoundary fallback={<div>Custom Error</div>}>
|
|
970
|
+
<Throw />
|
|
971
|
+
</ErrorBoundary>,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
expect(getByText('Custom Error')).toBeTruthy()
|
|
975
|
+
spy.mockRestore()
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
it('renders function fallback with error', () => {
|
|
979
|
+
function Throw(): React.ReactNode {
|
|
980
|
+
throw new Error('kaboom')
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const spy = spyOn(console, 'error').mockImplementation(() => {})
|
|
984
|
+
const { getByText } = render(
|
|
985
|
+
<ErrorBoundary fallback={(err) => <div>Error: {err.message}</div>}>
|
|
986
|
+
<Throw />
|
|
987
|
+
</ErrorBoundary>,
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
expect(getByText('Error: kaboom')).toBeTruthy()
|
|
991
|
+
spy.mockRestore()
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
it('resets on resetKey change', () => {
|
|
995
|
+
function MaybeThrow({ shouldThrow }: { shouldThrow: boolean }) {
|
|
996
|
+
if (shouldThrow) throw new Error('boom')
|
|
997
|
+
return <div>OK</div>
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const spy = spyOn(console, 'error').mockImplementation(() => {})
|
|
1001
|
+
|
|
1002
|
+
const { rerender, getByText, container } = render(
|
|
1003
|
+
<ErrorBoundary resetKey="a">
|
|
1004
|
+
<MaybeThrow shouldThrow={true} />
|
|
1005
|
+
</ErrorBoundary>,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
expect(container.textContent).toContain('Route error: boom')
|
|
1009
|
+
|
|
1010
|
+
// Change resetKey and stop throwing — should reset
|
|
1011
|
+
rerender(
|
|
1012
|
+
<ErrorBoundary resetKey="b">
|
|
1013
|
+
<MaybeThrow shouldThrow={false} />
|
|
1014
|
+
</ErrorBoundary>,
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
expect(getByText('OK')).toBeTruthy()
|
|
1018
|
+
spy.mockRestore()
|
|
1019
|
+
})
|
|
1020
|
+
})
|