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.
Files changed (143) hide show
  1. package/dist/cjs/cli/build.cjs +22 -6
  2. package/dist/cjs/cli/build.js +20 -3
  3. package/dist/cjs/cli/build.js.map +1 -1
  4. package/dist/cjs/cli/build.native.js +144 -93
  5. package/dist/cjs/cli/build.native.js.map +1 -1
  6. package/dist/cjs/cli/buildPage.cjs +43 -11
  7. package/dist/cjs/cli/buildPage.js +37 -5
  8. package/dist/cjs/cli/buildPage.js.map +1 -1
  9. package/dist/cjs/cli/buildPage.native.js +64 -14
  10. package/dist/cjs/cli/buildPage.native.js.map +1 -1
  11. package/dist/cjs/createApp.cjs +8 -4
  12. package/dist/cjs/createApp.js +8 -4
  13. package/dist/cjs/createApp.js.map +1 -1
  14. package/dist/cjs/index.cjs +6 -0
  15. package/dist/cjs/index.js +6 -1
  16. package/dist/cjs/index.js.map +1 -1
  17. package/dist/cjs/index.native.js +6 -0
  18. package/dist/cjs/index.native.js.map +1 -1
  19. package/dist/cjs/router/Route.js.map +1 -1
  20. package/dist/cjs/router/Route.native.js.map +1 -1
  21. package/dist/cjs/router/router.cjs +29 -4
  22. package/dist/cjs/router/router.js +19 -2
  23. package/dist/cjs/router/router.js.map +1 -1
  24. package/dist/cjs/router/router.native.js +8 -1
  25. package/dist/cjs/router/router.native.js.map +1 -1
  26. package/dist/cjs/server/oneServe.cjs +47 -8
  27. package/dist/cjs/server/oneServe.js +46 -9
  28. package/dist/cjs/server/oneServe.js.map +1 -1
  29. package/dist/cjs/server/oneServe.native.js +57 -10
  30. package/dist/cjs/server/oneServe.native.js.map +1 -1
  31. package/dist/cjs/useMatches.cjs +55 -0
  32. package/dist/cjs/useMatches.js +53 -0
  33. package/dist/cjs/useMatches.js.map +6 -0
  34. package/dist/cjs/useMatches.native.js +87 -0
  35. package/dist/cjs/useMatches.native.js.map +1 -0
  36. package/dist/cjs/useMatches.test.cjs +287 -0
  37. package/dist/cjs/useMatches.test.js +208 -0
  38. package/dist/cjs/useMatches.test.js.map +6 -0
  39. package/dist/cjs/useMatches.test.native.js +313 -0
  40. package/dist/cjs/useMatches.test.native.js.map +1 -0
  41. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.cjs +51 -10
  42. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js +39 -12
  43. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js.map +1 -1
  44. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js +95 -52
  45. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  46. package/dist/esm/cli/build.js +20 -3
  47. package/dist/esm/cli/build.js.map +1 -1
  48. package/dist/esm/cli/build.mjs +22 -6
  49. package/dist/esm/cli/build.mjs.map +1 -1
  50. package/dist/esm/cli/build.native.js +144 -93
  51. package/dist/esm/cli/build.native.js.map +1 -1
  52. package/dist/esm/cli/buildPage.js +37 -5
  53. package/dist/esm/cli/buildPage.js.map +1 -1
  54. package/dist/esm/cli/buildPage.mjs +43 -11
  55. package/dist/esm/cli/buildPage.mjs.map +1 -1
  56. package/dist/esm/cli/buildPage.native.js +64 -14
  57. package/dist/esm/cli/buildPage.native.js.map +1 -1
  58. package/dist/esm/createApp.js +8 -3
  59. package/dist/esm/createApp.js.map +1 -1
  60. package/dist/esm/createApp.mjs +8 -4
  61. package/dist/esm/createApp.mjs.map +1 -1
  62. package/dist/esm/index.js +12 -1
  63. package/dist/esm/index.js.map +1 -1
  64. package/dist/esm/index.mjs +3 -2
  65. package/dist/esm/index.mjs.map +1 -1
  66. package/dist/esm/index.native.js +3 -2
  67. package/dist/esm/index.native.js.map +1 -1
  68. package/dist/esm/router/Route.js.map +1 -1
  69. package/dist/esm/router/Route.mjs.map +1 -1
  70. package/dist/esm/router/Route.native.js.map +1 -1
  71. package/dist/esm/router/router.js +19 -1
  72. package/dist/esm/router/router.js.map +1 -1
  73. package/dist/esm/router/router.mjs +28 -4
  74. package/dist/esm/router/router.mjs.map +1 -1
  75. package/dist/esm/router/router.native.js +7 -1
  76. package/dist/esm/router/router.native.js.map +1 -1
  77. package/dist/esm/server/oneServe.js +46 -9
  78. package/dist/esm/server/oneServe.js.map +1 -1
  79. package/dist/esm/server/oneServe.mjs +47 -8
  80. package/dist/esm/server/oneServe.mjs.map +1 -1
  81. package/dist/esm/server/oneServe.native.js +57 -10
  82. package/dist/esm/server/oneServe.native.js.map +1 -1
  83. package/dist/esm/useMatches.js +38 -0
  84. package/dist/esm/useMatches.js.map +6 -0
  85. package/dist/esm/useMatches.mjs +29 -0
  86. package/dist/esm/useMatches.mjs.map +1 -0
  87. package/dist/esm/useMatches.native.js +58 -0
  88. package/dist/esm/useMatches.native.js.map +1 -0
  89. package/dist/esm/useMatches.test.js +209 -0
  90. package/dist/esm/useMatches.test.js.map +6 -0
  91. package/dist/esm/useMatches.test.mjs +288 -0
  92. package/dist/esm/useMatches.test.mjs.map +1 -0
  93. package/dist/esm/useMatches.test.native.js +311 -0
  94. package/dist/esm/useMatches.test.native.js.map +1 -0
  95. package/dist/esm/vite/plugins/fileSystemRouterPlugin.js +39 -12
  96. package/dist/esm/vite/plugins/fileSystemRouterPlugin.js.map +1 -1
  97. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs +51 -10
  98. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs.map +1 -1
  99. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js +95 -52
  100. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  101. package/package.json +9 -9
  102. package/src/cli/build.ts +36 -2
  103. package/src/cli/buildPage.ts +52 -2
  104. package/src/createApp.tsx +8 -0
  105. package/src/index.ts +8 -1
  106. package/src/router/Route.tsx +2 -0
  107. package/src/router/router.ts +57 -0
  108. package/src/server/oneServe.ts +89 -9
  109. package/src/types.ts +5 -0
  110. package/src/useMatches.test.ts +317 -0
  111. package/src/useMatches.ts +125 -0
  112. package/src/vite/plugins/fileSystemRouterPlugin.tsx +75 -19
  113. package/src/vite/types.ts +20 -0
  114. package/types/cli/build.d.ts.map +1 -1
  115. package/types/cli/buildPage.d.ts.map +1 -1
  116. package/types/createApp.d.ts.map +1 -1
  117. package/types/index.d.ts +2 -1
  118. package/types/index.d.ts.map +1 -1
  119. package/types/router/Route.d.ts +2 -0
  120. package/types/router/Route.d.ts.map +1 -1
  121. package/types/router/router.d.ts +6 -0
  122. package/types/router/router.d.ts.map +1 -1
  123. package/types/server/oneServe.d.ts.map +1 -1
  124. package/types/types.d.ts +5 -0
  125. package/types/types.d.ts.map +1 -1
  126. package/types/useMatches.d.ts +75 -0
  127. package/types/useMatches.d.ts.map +1 -0
  128. package/types/useMatches.test.d.ts +2 -0
  129. package/types/useMatches.test.d.ts.map +1 -0
  130. package/types/vite/plugins/fileSystemRouterPlugin.d.ts.map +1 -1
  131. package/types/vite/types.d.ts +19 -0
  132. package/types/vite/types.d.ts.map +1 -1
  133. package/dist/cjs/createRouteConfig.cjs +0 -28
  134. package/dist/cjs/createRouteConfig.js +0 -23
  135. package/dist/cjs/createRouteConfig.js.map +0 -6
  136. package/dist/cjs/createRouteConfig.native.js +0 -31
  137. package/dist/cjs/createRouteConfig.native.js.map +0 -1
  138. package/dist/esm/createRouteConfig.js +0 -7
  139. package/dist/esm/createRouteConfig.js.map +0 -6
  140. package/dist/esm/createRouteConfig.mjs +0 -5
  141. package/dist/esm/createRouteConfig.mjs.map +0 -1
  142. package/dist/esm/createRouteConfig.native.js +0 -5
  143. package/dist/esm/createRouteConfig.native.js.map +0 -1
@@ -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(join('./', 'dist/server', route.file)))
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
- let loaderData
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
- loaderData = await exported.loader?.(loaderProps)
169
- } catch (loaderErr) {
170
- // Handle thrown responses (e.g., redirect) from loader
171
- if (isResponse(loaderErr)) {
172
- return loaderErr
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 loaderErr
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
+ }