one 1.2.75 → 1.2.76
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/cjs/cli/build.cjs +22 -6
- package/dist/cjs/cli/build.js +20 -3
- package/dist/cjs/cli/build.js.map +1 -1
- package/dist/cjs/cli/build.native.js +144 -93
- package/dist/cjs/cli/build.native.js.map +1 -1
- package/dist/cjs/cli/buildPage.cjs +43 -11
- package/dist/cjs/cli/buildPage.js +37 -5
- package/dist/cjs/cli/buildPage.js.map +1 -1
- package/dist/cjs/cli/buildPage.native.js +64 -14
- package/dist/cjs/cli/buildPage.native.js.map +1 -1
- package/dist/cjs/createApp.cjs +8 -4
- package/dist/cjs/createApp.js +8 -4
- package/dist/cjs/createApp.js.map +1 -1
- package/dist/cjs/index.cjs +6 -0
- package/dist/cjs/index.js +6 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/index.native.js +6 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/router/Route.js.map +1 -1
- package/dist/cjs/router/Route.native.js.map +1 -1
- package/dist/cjs/router/router.cjs +29 -4
- package/dist/cjs/router/router.js +19 -2
- package/dist/cjs/router/router.js.map +1 -1
- package/dist/cjs/router/router.native.js +8 -1
- package/dist/cjs/router/router.native.js.map +1 -1
- package/dist/cjs/server/oneServe.cjs +47 -8
- package/dist/cjs/server/oneServe.js +46 -9
- package/dist/cjs/server/oneServe.js.map +1 -1
- package/dist/cjs/server/oneServe.native.js +57 -10
- package/dist/cjs/server/oneServe.native.js.map +1 -1
- package/dist/cjs/useMatches.cjs +55 -0
- package/dist/cjs/useMatches.js +53 -0
- package/dist/cjs/useMatches.js.map +6 -0
- package/dist/cjs/useMatches.native.js +87 -0
- package/dist/cjs/useMatches.native.js.map +1 -0
- package/dist/cjs/useMatches.test.cjs +287 -0
- package/dist/cjs/useMatches.test.js +208 -0
- package/dist/cjs/useMatches.test.js.map +6 -0
- package/dist/cjs/useMatches.test.native.js +313 -0
- package/dist/cjs/useMatches.test.native.js.map +1 -0
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.cjs +51 -10
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js +39 -12
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js.map +1 -1
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js +95 -52
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
- package/dist/esm/cli/build.js +20 -3
- package/dist/esm/cli/build.js.map +1 -1
- package/dist/esm/cli/build.mjs +22 -6
- package/dist/esm/cli/build.mjs.map +1 -1
- package/dist/esm/cli/build.native.js +144 -93
- package/dist/esm/cli/build.native.js.map +1 -1
- package/dist/esm/cli/buildPage.js +37 -5
- package/dist/esm/cli/buildPage.js.map +1 -1
- package/dist/esm/cli/buildPage.mjs +43 -11
- package/dist/esm/cli/buildPage.mjs.map +1 -1
- package/dist/esm/cli/buildPage.native.js +64 -14
- package/dist/esm/cli/buildPage.native.js.map +1 -1
- package/dist/esm/createApp.js +8 -3
- package/dist/esm/createApp.js.map +1 -1
- package/dist/esm/createApp.mjs +8 -4
- package/dist/esm/createApp.mjs.map +1 -1
- package/dist/esm/index.js +12 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +3 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +3 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/router/Route.js.map +1 -1
- package/dist/esm/router/Route.mjs.map +1 -1
- package/dist/esm/router/Route.native.js.map +1 -1
- package/dist/esm/router/router.js +19 -1
- package/dist/esm/router/router.js.map +1 -1
- package/dist/esm/router/router.mjs +28 -4
- package/dist/esm/router/router.mjs.map +1 -1
- package/dist/esm/router/router.native.js +7 -1
- package/dist/esm/router/router.native.js.map +1 -1
- package/dist/esm/server/oneServe.js +46 -9
- package/dist/esm/server/oneServe.js.map +1 -1
- package/dist/esm/server/oneServe.mjs +47 -8
- package/dist/esm/server/oneServe.mjs.map +1 -1
- package/dist/esm/server/oneServe.native.js +57 -10
- package/dist/esm/server/oneServe.native.js.map +1 -1
- package/dist/esm/useMatches.js +38 -0
- package/dist/esm/useMatches.js.map +6 -0
- package/dist/esm/useMatches.mjs +29 -0
- package/dist/esm/useMatches.mjs.map +1 -0
- package/dist/esm/useMatches.native.js +58 -0
- package/dist/esm/useMatches.native.js.map +1 -0
- package/dist/esm/useMatches.test.js +209 -0
- package/dist/esm/useMatches.test.js.map +6 -0
- package/dist/esm/useMatches.test.mjs +288 -0
- package/dist/esm/useMatches.test.mjs.map +1 -0
- package/dist/esm/useMatches.test.native.js +311 -0
- package/dist/esm/useMatches.test.native.js.map +1 -0
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.js +39 -12
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.js.map +1 -1
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs +51 -10
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs.map +1 -1
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js +95 -52
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
- package/package.json +9 -9
- package/src/cli/build.ts +36 -2
- package/src/cli/buildPage.ts +52 -2
- package/src/createApp.tsx +8 -0
- package/src/index.ts +8 -1
- package/src/router/Route.tsx +2 -0
- package/src/router/router.ts +57 -0
- package/src/server/oneServe.ts +89 -9
- package/src/types.ts +5 -0
- package/src/useMatches.test.ts +317 -0
- package/src/useMatches.ts +125 -0
- package/src/vite/plugins/fileSystemRouterPlugin.tsx +75 -19
- package/src/vite/types.ts +20 -0
- package/types/cli/build.d.ts.map +1 -1
- package/types/cli/buildPage.d.ts.map +1 -1
- package/types/createApp.d.ts.map +1 -1
- package/types/index.d.ts +2 -1
- package/types/index.d.ts.map +1 -1
- package/types/router/Route.d.ts +2 -0
- package/types/router/Route.d.ts.map +1 -1
- package/types/router/router.d.ts +6 -0
- package/types/router/router.d.ts.map +1 -1
- package/types/server/oneServe.d.ts.map +1 -1
- package/types/types.d.ts +5 -0
- package/types/types.d.ts.map +1 -1
- package/types/useMatches.d.ts +75 -0
- package/types/useMatches.d.ts.map +1 -0
- package/types/useMatches.test.d.ts +2 -0
- package/types/useMatches.test.d.ts.map +1 -0
- package/types/vite/plugins/fileSystemRouterPlugin.d.ts.map +1 -1
- package/types/vite/types.d.ts +19 -0
- package/types/vite/types.d.ts.map +1 -1
- package/dist/cjs/createRouteConfig.cjs +0 -28
- package/dist/cjs/createRouteConfig.js +0 -23
- package/dist/cjs/createRouteConfig.js.map +0 -6
- package/dist/cjs/createRouteConfig.native.js +0 -31
- package/dist/cjs/createRouteConfig.native.js.map +0 -1
- package/dist/esm/createRouteConfig.js +0 -7
- package/dist/esm/createRouteConfig.js.map +0 -6
- package/dist/esm/createRouteConfig.mjs +0 -5
- package/dist/esm/createRouteConfig.mjs.map +0 -1
- package/dist/esm/createRouteConfig.native.js +0 -5
- package/dist/esm/createRouteConfig.native.js.map +0 -1
package/src/server/oneServe.ts
CHANGED
|
@@ -129,11 +129,15 @@ export async function oneServe(
|
|
|
129
129
|
async handleLoader({ route, loaderProps }) {
|
|
130
130
|
// Use lazy import if available (workers), otherwise dynamic import (Node.js)
|
|
131
131
|
// For workers, look up by routeFile (original file path like "./dynamic/[id]+ssr.tsx")
|
|
132
|
-
// For Node.js, use route.file which may be loaderServerPath
|
|
132
|
+
// For Node.js, use route.file which may be loaderServerPath (already includes dist/server)
|
|
133
133
|
const routeFile = (route as any).routeFile || route.file
|
|
134
|
+
// route.file may already include dist/server if it came from loaderServerPath
|
|
135
|
+
const serverPath = route.file.includes('dist/server')
|
|
136
|
+
? route.file
|
|
137
|
+
: join('./', 'dist/server', route.file)
|
|
134
138
|
const exports = options?.lazyRoutes?.pages?.[routeFile]
|
|
135
139
|
? await options.lazyRoutes.pages[routeFile]()
|
|
136
|
-
: await import(toAbsolute(
|
|
140
|
+
: await import(toAbsolute(serverPath))
|
|
137
141
|
|
|
138
142
|
const { loader } = exports
|
|
139
143
|
|
|
@@ -163,17 +167,92 @@ export async function oneServe(
|
|
|
163
167
|
? await options.lazyRoutes.pages[route.file]()
|
|
164
168
|
: await import(toAbsolute(buildInfo.serverJsPath))
|
|
165
169
|
|
|
166
|
-
|
|
170
|
+
// helper to import and run a single loader
|
|
171
|
+
async function runLoader(
|
|
172
|
+
routeId: string,
|
|
173
|
+
serverPath: string | undefined,
|
|
174
|
+
lazyKey?: string
|
|
175
|
+
): Promise<{ loaderData: unknown; routeId: string }> {
|
|
176
|
+
if (!serverPath && !lazyKey) {
|
|
177
|
+
return { loaderData: undefined, routeId }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
// serverPath may already include dist/server if it came from buildInfo.serverJsPath
|
|
182
|
+
const pathToResolve = serverPath || lazyKey || ''
|
|
183
|
+
const resolvedPath = pathToResolve.includes('dist/server')
|
|
184
|
+
? pathToResolve
|
|
185
|
+
: join('./', 'dist/server', pathToResolve)
|
|
186
|
+
|
|
187
|
+
const routeExported = lazyKey
|
|
188
|
+
? options?.lazyRoutes?.pages?.[lazyKey]
|
|
189
|
+
? await options.lazyRoutes.pages[lazyKey]()
|
|
190
|
+
: await import(toAbsolute(resolvedPath))
|
|
191
|
+
: await import(toAbsolute(serverPath!))
|
|
192
|
+
|
|
193
|
+
const loaderData = await routeExported?.loader?.(loaderProps)
|
|
194
|
+
return { loaderData, routeId }
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// if a loader throws a Response (redirect), re-throw it
|
|
197
|
+
if (isResponse(err)) {
|
|
198
|
+
throw err
|
|
199
|
+
}
|
|
200
|
+
console.error(`[one] Error running loader for ${routeId}:`, err)
|
|
201
|
+
return { loaderData: undefined, routeId }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// collect layout loaders to run in parallel
|
|
206
|
+
const layoutRoutes = route.layouts || []
|
|
207
|
+
const layoutLoaderPromises = layoutRoutes.map((layout: any) => {
|
|
208
|
+
// layouts may have loaderServerPath set from build, or we can try contextKey
|
|
209
|
+
const serverPath = layout.loaderServerPath || layout.contextKey
|
|
210
|
+
return runLoader(layout.contextKey, serverPath, layout.contextKey)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// run page loader
|
|
214
|
+
const pageLoaderPromise = runLoader(
|
|
215
|
+
route.file,
|
|
216
|
+
buildInfo.serverJsPath,
|
|
217
|
+
route.file
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
// wait for all loaders in parallel
|
|
221
|
+
let layoutResults: Array<{ loaderData: unknown; routeId: string }>
|
|
222
|
+
let pageResult: { loaderData: unknown; routeId: string }
|
|
223
|
+
|
|
167
224
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
225
|
+
;[layoutResults, pageResult] = await Promise.all([
|
|
226
|
+
Promise.all(layoutLoaderPromises),
|
|
227
|
+
pageLoaderPromise,
|
|
228
|
+
])
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// Handle thrown responses (e.g., redirect) from any loader
|
|
231
|
+
if (isResponse(err)) {
|
|
232
|
+
return err
|
|
173
233
|
}
|
|
174
|
-
throw
|
|
234
|
+
throw err
|
|
175
235
|
}
|
|
176
236
|
|
|
237
|
+
// build matches array (layouts + page)
|
|
238
|
+
const matches: One.RouteMatch[] = [
|
|
239
|
+
...layoutResults.map((result) => ({
|
|
240
|
+
routeId: result.routeId,
|
|
241
|
+
pathname: loaderProps?.path || '/',
|
|
242
|
+
params: loaderProps?.params || {},
|
|
243
|
+
loaderData: result.loaderData,
|
|
244
|
+
})),
|
|
245
|
+
{
|
|
246
|
+
routeId: pageResult.routeId,
|
|
247
|
+
pathname: loaderProps?.path || '/',
|
|
248
|
+
params: loaderProps?.params || {},
|
|
249
|
+
loaderData: pageResult.loaderData,
|
|
250
|
+
},
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
// for backwards compat, loaderData is still the page's loader data
|
|
254
|
+
const loaderData = pageResult.loaderData
|
|
255
|
+
|
|
177
256
|
const headers = new Headers()
|
|
178
257
|
headers.set('content-type', 'text/html')
|
|
179
258
|
|
|
@@ -194,6 +273,7 @@ export async function oneServe(
|
|
|
194
273
|
deferredPreloads: buildInfo.deferredPreloads,
|
|
195
274
|
css: buildInfo.css,
|
|
196
275
|
cssContents: buildInfo.cssContents,
|
|
276
|
+
matches,
|
|
197
277
|
})
|
|
198
278
|
|
|
199
279
|
return new Response(rendered, {
|
package/src/types.ts
CHANGED
|
@@ -36,4 +36,9 @@ export type RenderAppProps = {
|
|
|
36
36
|
loaderData?: any
|
|
37
37
|
loaderProps?: LoaderProps
|
|
38
38
|
routePreloads?: Record<string, string>
|
|
39
|
+
/**
|
|
40
|
+
* All matched routes with their loader data.
|
|
41
|
+
* Ordered from root layout to leaf page (parent → child).
|
|
42
|
+
*/
|
|
43
|
+
matches?: One.RouteMatch[]
|
|
39
44
|
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { One } from './vite/types'
|
|
3
|
+
import { setClientMatches } from './useMatches'
|
|
4
|
+
|
|
5
|
+
describe('useMatches', () => {
|
|
6
|
+
describe('setClientMatches', () => {
|
|
7
|
+
it('should update client matches without errors', () => {
|
|
8
|
+
const matches: One.RouteMatch[] = [
|
|
9
|
+
{
|
|
10
|
+
routeId: '/_layout',
|
|
11
|
+
pathname: '/',
|
|
12
|
+
params: {},
|
|
13
|
+
loaderData: { title: 'Root' },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
routeId: '/page',
|
|
17
|
+
pathname: '/page',
|
|
18
|
+
params: {},
|
|
19
|
+
loaderData: { content: 'Page content' },
|
|
20
|
+
},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
// should not throw
|
|
24
|
+
expect(() => setClientMatches(matches)).not.toThrow()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should accept empty matches array', () => {
|
|
28
|
+
expect(() => setClientMatches([])).not.toThrow()
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('type safety', () => {
|
|
33
|
+
it('should have correct RouteMatch type', () => {
|
|
34
|
+
const match: One.RouteMatch = {
|
|
35
|
+
routeId: 'test',
|
|
36
|
+
pathname: '/test',
|
|
37
|
+
params: { id: '123' },
|
|
38
|
+
loaderData: { foo: 'bar' },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
expect(match.routeId).toBe('test')
|
|
42
|
+
expect(match.pathname).toBe('/test')
|
|
43
|
+
expect(match.params.id).toBe('123')
|
|
44
|
+
expect(match.loaderData).toEqual({ foo: 'bar' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should allow string[] params', () => {
|
|
48
|
+
const match: One.RouteMatch = {
|
|
49
|
+
routeId: 'test',
|
|
50
|
+
pathname: '/test',
|
|
51
|
+
params: { slugs: ['a', 'b', 'c'] },
|
|
52
|
+
loaderData: null,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
expect(match.params.slugs).toEqual(['a', 'b', 'c'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should allow undefined loaderData', () => {
|
|
59
|
+
const match: One.RouteMatch = {
|
|
60
|
+
routeId: 'test',
|
|
61
|
+
pathname: '/test',
|
|
62
|
+
params: {},
|
|
63
|
+
loaderData: undefined,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect(match.loaderData).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('RouteMatch ordering', () => {
|
|
72
|
+
it('matches should be ordered parent to child (root layout first)', () => {
|
|
73
|
+
const matches: One.RouteMatch[] = [
|
|
74
|
+
{ routeId: '/_layout', pathname: '/', params: {}, loaderData: { level: 'root' } },
|
|
75
|
+
{
|
|
76
|
+
routeId: '/docs/_layout',
|
|
77
|
+
pathname: '/docs',
|
|
78
|
+
params: {},
|
|
79
|
+
loaderData: { level: 'docs' },
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
routeId: '/docs/intro',
|
|
83
|
+
pathname: '/docs/intro',
|
|
84
|
+
params: {},
|
|
85
|
+
loaderData: { level: 'page' },
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
// root layout should be first
|
|
90
|
+
expect(matches[0].routeId).toBe('/_layout')
|
|
91
|
+
expect((matches[0].loaderData as any).level).toBe('root')
|
|
92
|
+
|
|
93
|
+
// docs layout should be second
|
|
94
|
+
expect(matches[1].routeId).toBe('/docs/_layout')
|
|
95
|
+
expect((matches[1].loaderData as any).level).toBe('docs')
|
|
96
|
+
|
|
97
|
+
// page should be last
|
|
98
|
+
expect(matches[2].routeId).toBe('/docs/intro')
|
|
99
|
+
expect((matches[2].loaderData as any).level).toBe('page')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('last match should be the current page', () => {
|
|
103
|
+
const matches: One.RouteMatch[] = [
|
|
104
|
+
{ routeId: '/_layout', pathname: '/', params: {}, loaderData: {} },
|
|
105
|
+
{
|
|
106
|
+
routeId: '/page',
|
|
107
|
+
pathname: '/page',
|
|
108
|
+
params: { id: '123' },
|
|
109
|
+
loaderData: { title: 'Page' },
|
|
110
|
+
},
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
const pageMatch = matches[matches.length - 1]
|
|
114
|
+
expect(pageMatch.routeId).toBe('/page')
|
|
115
|
+
expect((pageMatch.loaderData as any).title).toBe('Page')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('setClientMatches reactivity', () => {
|
|
120
|
+
it('should notify listeners when matches change', () => {
|
|
121
|
+
let notifyCount = 0
|
|
122
|
+
|
|
123
|
+
// simulate a listener (like what useSyncExternalStore would use)
|
|
124
|
+
const listener = () => {
|
|
125
|
+
notifyCount++
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// manually test the subscription mechanism
|
|
129
|
+
const matches1: One.RouteMatch[] = [
|
|
130
|
+
{ routeId: '/page1', pathname: '/page1', params: {}, loaderData: {} },
|
|
131
|
+
]
|
|
132
|
+
const matches2: One.RouteMatch[] = [
|
|
133
|
+
{ routeId: '/page2', pathname: '/page2', params: {}, loaderData: {} },
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
// first call
|
|
137
|
+
setClientMatches(matches1)
|
|
138
|
+
// no listener yet, so notifyCount is still 0
|
|
139
|
+
|
|
140
|
+
// second call
|
|
141
|
+
setClientMatches(matches2)
|
|
142
|
+
// still no listener
|
|
143
|
+
|
|
144
|
+
expect(notifyCount).toBe(0) // we didn't subscribe
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should handle multiple sequential updates', () => {
|
|
148
|
+
const matches1: One.RouteMatch[] = [
|
|
149
|
+
{ routeId: '/a', pathname: '/a', params: {}, loaderData: { n: 1 } },
|
|
150
|
+
]
|
|
151
|
+
const matches2: One.RouteMatch[] = [
|
|
152
|
+
{ routeId: '/b', pathname: '/b', params: {}, loaderData: { n: 2 } },
|
|
153
|
+
]
|
|
154
|
+
const matches3: One.RouteMatch[] = [
|
|
155
|
+
{ routeId: '/c', pathname: '/c', params: {}, loaderData: { n: 3 } },
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
// should not throw on rapid updates
|
|
159
|
+
expect(() => {
|
|
160
|
+
setClientMatches(matches1)
|
|
161
|
+
setClientMatches(matches2)
|
|
162
|
+
setClientMatches(matches3)
|
|
163
|
+
}).not.toThrow()
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('RouteMatch with dynamic params', () => {
|
|
168
|
+
it('should handle single dynamic param', () => {
|
|
169
|
+
const match: One.RouteMatch = {
|
|
170
|
+
routeId: '/users/[id]',
|
|
171
|
+
pathname: '/users/123',
|
|
172
|
+
params: { id: '123' },
|
|
173
|
+
loaderData: { user: { name: 'John' } },
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
expect(match.params.id).toBe('123')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('should handle multiple dynamic params', () => {
|
|
180
|
+
const match: One.RouteMatch = {
|
|
181
|
+
routeId: '/users/[userId]/posts/[postId]',
|
|
182
|
+
pathname: '/users/123/posts/456',
|
|
183
|
+
params: { userId: '123', postId: '456' },
|
|
184
|
+
loaderData: { post: { title: 'Hello' } },
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
expect(match.params.userId).toBe('123')
|
|
188
|
+
expect(match.params.postId).toBe('456')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('should handle catch-all params', () => {
|
|
192
|
+
const match: One.RouteMatch = {
|
|
193
|
+
routeId: '/docs/[...slug]',
|
|
194
|
+
pathname: '/docs/getting-started/intro',
|
|
195
|
+
params: { slug: ['getting-started', 'intro'] },
|
|
196
|
+
loaderData: { doc: { content: 'Hello' } },
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
expect(match.params.slug).toEqual(['getting-started', 'intro'])
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('RouteMatch loaderData scenarios', () => {
|
|
204
|
+
it('should handle complex nested loaderData', () => {
|
|
205
|
+
const match: One.RouteMatch = {
|
|
206
|
+
routeId: '/dashboard',
|
|
207
|
+
pathname: '/dashboard',
|
|
208
|
+
params: {},
|
|
209
|
+
loaderData: {
|
|
210
|
+
user: {
|
|
211
|
+
id: 1,
|
|
212
|
+
profile: {
|
|
213
|
+
name: 'John',
|
|
214
|
+
settings: {
|
|
215
|
+
theme: 'dark',
|
|
216
|
+
notifications: true,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
posts: [
|
|
221
|
+
{ id: 1, title: 'Post 1' },
|
|
222
|
+
{ id: 2, title: 'Post 2' },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const data = match.loaderData as any
|
|
228
|
+
expect(data.user.profile.settings.theme).toBe('dark')
|
|
229
|
+
expect(data.posts).toHaveLength(2)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('should handle null loaderData', () => {
|
|
233
|
+
const match: One.RouteMatch = {
|
|
234
|
+
routeId: '/empty',
|
|
235
|
+
pathname: '/empty',
|
|
236
|
+
params: {},
|
|
237
|
+
loaderData: null,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
expect(match.loaderData).toBeNull()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should handle array loaderData', () => {
|
|
244
|
+
const match: One.RouteMatch = {
|
|
245
|
+
routeId: '/list',
|
|
246
|
+
pathname: '/list',
|
|
247
|
+
params: {},
|
|
248
|
+
loaderData: [1, 2, 3, 4, 5],
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
expect(match.loaderData).toEqual([1, 2, 3, 4, 5])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should handle primitive loaderData', () => {
|
|
255
|
+
const match: One.RouteMatch = {
|
|
256
|
+
routeId: '/count',
|
|
257
|
+
pathname: '/count',
|
|
258
|
+
params: {},
|
|
259
|
+
loaderData: 42,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
expect(match.loaderData).toBe(42)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('finding matches', () => {
|
|
267
|
+
const createMatches = (): One.RouteMatch[] => [
|
|
268
|
+
{ routeId: '/_layout', pathname: '/', params: {}, loaderData: { root: true } },
|
|
269
|
+
{
|
|
270
|
+
routeId: '/docs/_layout',
|
|
271
|
+
pathname: '/docs',
|
|
272
|
+
params: {},
|
|
273
|
+
loaderData: { navItems: ['intro', 'guide'] },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
routeId: '/docs/[slug]',
|
|
277
|
+
pathname: '/docs/intro',
|
|
278
|
+
params: { slug: 'intro' },
|
|
279
|
+
loaderData: { title: 'Introduction', headings: ['h1', 'h2'] },
|
|
280
|
+
},
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
it('should find match by exact routeId', () => {
|
|
284
|
+
const matches = createMatches()
|
|
285
|
+
const found = matches.find((m) => m.routeId === '/docs/_layout')
|
|
286
|
+
|
|
287
|
+
expect(found).toBeDefined()
|
|
288
|
+
expect((found!.loaderData as any).navItems).toEqual(['intro', 'guide'])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('should find match by routeId pattern', () => {
|
|
292
|
+
const matches = createMatches()
|
|
293
|
+
const found = matches.find((m) => m.routeId.includes('_layout'))
|
|
294
|
+
|
|
295
|
+
expect(found).toBeDefined()
|
|
296
|
+
expect(found!.routeId).toBe('/_layout')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should get page match (last in array)', () => {
|
|
300
|
+
const matches = createMatches()
|
|
301
|
+
const pageMatch = matches[matches.length - 1]
|
|
302
|
+
|
|
303
|
+
expect(pageMatch.routeId).toBe('/docs/[slug]')
|
|
304
|
+
expect((pageMatch.loaderData as any).title).toBe('Introduction')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('should get layout match for a page', () => {
|
|
308
|
+
const matches = createMatches()
|
|
309
|
+
// find the layout that contains "docs"
|
|
310
|
+
const layoutMatch = matches.find(
|
|
311
|
+
(m) => m.routeId.includes('/docs/') && m.routeId.includes('_layout')
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
expect(layoutMatch).toBeDefined()
|
|
315
|
+
expect((layoutMatch!.loaderData as any).navItems).toBeDefined()
|
|
316
|
+
})
|
|
317
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { useServerContext } from './vite/one-server-only'
|
|
3
|
+
import type { One } from './vite/types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-export for convenience
|
|
7
|
+
*/
|
|
8
|
+
export type RouteMatch = One.RouteMatch
|
|
9
|
+
|
|
10
|
+
// client-side matches store for navigation updates
|
|
11
|
+
let clientMatches: RouteMatch[] = []
|
|
12
|
+
const clientMatchesListeners = new Set<() => void>()
|
|
13
|
+
|
|
14
|
+
function subscribeToClientMatches(callback: () => void) {
|
|
15
|
+
clientMatchesListeners.add(callback)
|
|
16
|
+
return () => clientMatchesListeners.delete(callback)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getClientMatchesSnapshot() {
|
|
20
|
+
return clientMatches
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Update the client-side matches store.
|
|
25
|
+
* Called after navigation to update the matches with new loader data.
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export function setClientMatches(matches: RouteMatch[]) {
|
|
29
|
+
clientMatches = matches
|
|
30
|
+
for (const listener of clientMatchesListeners) {
|
|
31
|
+
listener()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns an array of all matched routes from root to the current page.
|
|
37
|
+
* Each match contains the route's loader data, params, and route ID.
|
|
38
|
+
*
|
|
39
|
+
* On the server (SSR), this returns the matches computed during the request.
|
|
40
|
+
* On the client, this returns cached matches from hydration or the last navigation.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* // In a layout component
|
|
45
|
+
* function DocsLayout({ children }) {
|
|
46
|
+
* const matches = useMatches()
|
|
47
|
+
* const pageMatch = matches[matches.length - 1]
|
|
48
|
+
* const headings = pageMatch?.loaderData?.headings
|
|
49
|
+
*
|
|
50
|
+
* return (
|
|
51
|
+
* <div>
|
|
52
|
+
* <TableOfContents headings={headings} />
|
|
53
|
+
* {children}
|
|
54
|
+
* </div>
|
|
55
|
+
* )
|
|
56
|
+
* }
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```tsx
|
|
61
|
+
* // Building breadcrumbs
|
|
62
|
+
* function Breadcrumbs() {
|
|
63
|
+
* const matches = useMatches()
|
|
64
|
+
*
|
|
65
|
+
* return (
|
|
66
|
+
* <nav>
|
|
67
|
+
* {matches.map((match) => (
|
|
68
|
+
* <a key={match.routeId} href={match.pathname}>
|
|
69
|
+
* {match.loaderData?.title ?? match.routeId}
|
|
70
|
+
* </a>
|
|
71
|
+
* ))}
|
|
72
|
+
* </nav>
|
|
73
|
+
* )
|
|
74
|
+
* }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function useMatches(): RouteMatch[] {
|
|
78
|
+
const serverContext = useServerContext()
|
|
79
|
+
|
|
80
|
+
// on server, return from context directly
|
|
81
|
+
if (process.env.VITE_ENVIRONMENT === 'ssr') {
|
|
82
|
+
return serverContext?.matches ?? []
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// on client, use sync external store for reactivity
|
|
86
|
+
// the server snapshot (3rd arg) is used during hydration to match SSR output
|
|
87
|
+
const clientStoreMatches = useSyncExternalStore(
|
|
88
|
+
subscribeToClientMatches,
|
|
89
|
+
getClientMatchesSnapshot,
|
|
90
|
+
// server snapshot for hydration - must match what SSR rendered
|
|
91
|
+
() => serverContext?.matches ?? []
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// always return client store on client - it's initialized from server context
|
|
95
|
+
// during hydration via initClientMatches, then updated on navigation
|
|
96
|
+
return clientStoreMatches
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Find a specific match by route ID.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```tsx
|
|
104
|
+
* const docsMatch = useMatch('docs/_layout')
|
|
105
|
+
* const navItems = docsMatch?.loaderData?.navItems
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function useMatch(routeId: string): RouteMatch | undefined {
|
|
109
|
+
const matches = useMatches()
|
|
110
|
+
return matches.find((m) => m.routeId === routeId)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the current page's match (the last/deepest match).
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```tsx
|
|
118
|
+
* const pageMatch = usePageMatch()
|
|
119
|
+
* const { title, description } = pageMatch?.loaderData ?? {}
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function usePageMatch(): RouteMatch | undefined {
|
|
123
|
+
const matches = useMatches()
|
|
124
|
+
return matches[matches.length - 1]
|
|
125
|
+
}
|