one 1.9.7 → 1.9.8

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 (169) hide show
  1. package/dist/cjs/cli/build.cjs +25 -19
  2. package/dist/cjs/cli/build.js +36 -24
  3. package/dist/cjs/cli/build.js.map +2 -2
  4. package/dist/cjs/cli/build.native.js +153 -117
  5. package/dist/cjs/cli/build.native.js.map +1 -1
  6. package/dist/cjs/createApp.cjs +12 -1
  7. package/dist/cjs/createApp.js +11 -1
  8. package/dist/cjs/createApp.js.map +1 -1
  9. package/dist/cjs/createHandleRequest.cjs +1 -1
  10. package/dist/cjs/createHandleRequest.js +1 -1
  11. package/dist/cjs/createHandleRequest.js.map +1 -1
  12. package/dist/cjs/createHandleRequest.native.js +1 -1
  13. package/dist/cjs/createHandleRequest.native.js.map +1 -1
  14. package/dist/cjs/createHandleRequest.test.cjs +13 -7
  15. package/dist/cjs/createHandleRequest.test.js +6 -3
  16. package/dist/cjs/createHandleRequest.test.js.map +1 -1
  17. package/dist/cjs/createHandleRequest.test.native.js +13 -7
  18. package/dist/cjs/createHandleRequest.test.native.js.map +1 -1
  19. package/dist/cjs/notFoundState.cjs +103 -0
  20. package/dist/cjs/notFoundState.js +99 -0
  21. package/dist/cjs/notFoundState.js.map +6 -0
  22. package/dist/cjs/notFoundState.native.js +176 -0
  23. package/dist/cjs/notFoundState.native.js.map +1 -0
  24. package/dist/cjs/router/router.cjs +10 -3
  25. package/dist/cjs/router/router.js +9 -3
  26. package/dist/cjs/router/router.js.map +1 -1
  27. package/dist/cjs/router/router.native.js +10 -3
  28. package/dist/cjs/router/router.native.js.map +1 -1
  29. package/dist/cjs/server/oneServe.cjs +21 -16
  30. package/dist/cjs/server/oneServe.js +24 -15
  31. package/dist/cjs/server/oneServe.js.map +1 -1
  32. package/dist/cjs/server/oneServe.native.js +28 -26
  33. package/dist/cjs/server/oneServe.native.js.map +1 -1
  34. package/dist/cjs/useLoader.cjs +26 -9
  35. package/dist/cjs/useLoader.js +26 -11
  36. package/dist/cjs/useLoader.js.map +1 -1
  37. package/dist/cjs/useLoader.native.js +44 -17
  38. package/dist/cjs/useLoader.native.js.map +1 -1
  39. package/dist/cjs/utils/cleanUrl.cjs +2 -2
  40. package/dist/cjs/utils/cleanUrl.js +2 -2
  41. package/dist/cjs/utils/cleanUrl.js.map +1 -1
  42. package/dist/cjs/utils/cleanUrl.native.js +4 -2
  43. package/dist/cjs/utils/cleanUrl.native.js.map +1 -1
  44. package/dist/cjs/utils/cleanUrl.test.cjs +64 -0
  45. package/dist/cjs/utils/cleanUrl.test.js +72 -0
  46. package/dist/cjs/utils/cleanUrl.test.js.map +6 -0
  47. package/dist/cjs/utils/cleanUrl.test.native.js +67 -0
  48. package/dist/cjs/utils/cleanUrl.test.native.js.map +1 -0
  49. package/dist/cjs/utils/getPathnameFromFilePath.test.cjs +60 -0
  50. package/dist/cjs/utils/getPathnameFromFilePath.test.js +72 -0
  51. package/dist/cjs/utils/getPathnameFromFilePath.test.js.map +6 -0
  52. package/dist/cjs/utils/getPathnameFromFilePath.test.native.js +65 -0
  53. package/dist/cjs/utils/getPathnameFromFilePath.test.native.js.map +1 -0
  54. package/dist/cjs/views/Navigator.cjs +18 -3
  55. package/dist/cjs/views/Navigator.js +13 -4
  56. package/dist/cjs/views/Navigator.js.map +2 -2
  57. package/dist/cjs/views/Navigator.native.js +21 -6
  58. package/dist/cjs/views/Navigator.native.js.map +1 -1
  59. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.cjs +23 -36
  60. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js +28 -34
  61. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js.map +1 -1
  62. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js +30 -40
  63. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  64. package/dist/esm/cli/build.js +36 -24
  65. package/dist/esm/cli/build.js.map +2 -2
  66. package/dist/esm/cli/build.mjs +25 -19
  67. package/dist/esm/cli/build.mjs.map +1 -1
  68. package/dist/esm/cli/build.native.js +153 -117
  69. package/dist/esm/cli/build.native.js.map +1 -1
  70. package/dist/esm/createApp.js +11 -1
  71. package/dist/esm/createApp.js.map +1 -1
  72. package/dist/esm/createApp.mjs +12 -1
  73. package/dist/esm/createApp.mjs.map +1 -1
  74. package/dist/esm/createHandleRequest.js +1 -1
  75. package/dist/esm/createHandleRequest.js.map +1 -1
  76. package/dist/esm/createHandleRequest.mjs +1 -1
  77. package/dist/esm/createHandleRequest.mjs.map +1 -1
  78. package/dist/esm/createHandleRequest.native.js +1 -1
  79. package/dist/esm/createHandleRequest.native.js.map +1 -1
  80. package/dist/esm/createHandleRequest.test.js +6 -3
  81. package/dist/esm/createHandleRequest.test.js.map +1 -1
  82. package/dist/esm/createHandleRequest.test.mjs +13 -7
  83. package/dist/esm/createHandleRequest.test.mjs.map +1 -1
  84. package/dist/esm/createHandleRequest.test.native.js +13 -7
  85. package/dist/esm/createHandleRequest.test.native.js.map +1 -1
  86. package/dist/esm/notFoundState.js +75 -0
  87. package/dist/esm/notFoundState.js.map +6 -0
  88. package/dist/esm/notFoundState.mjs +64 -0
  89. package/dist/esm/notFoundState.mjs.map +1 -0
  90. package/dist/esm/notFoundState.native.js +134 -0
  91. package/dist/esm/notFoundState.native.js.map +1 -0
  92. package/dist/esm/router/router.js +13 -2
  93. package/dist/esm/router/router.js.map +1 -1
  94. package/dist/esm/router/router.mjs +9 -2
  95. package/dist/esm/router/router.mjs.map +1 -1
  96. package/dist/esm/router/router.native.js +9 -2
  97. package/dist/esm/router/router.native.js.map +1 -1
  98. package/dist/esm/server/oneServe.js +24 -15
  99. package/dist/esm/server/oneServe.js.map +1 -1
  100. package/dist/esm/server/oneServe.mjs +21 -16
  101. package/dist/esm/server/oneServe.mjs.map +1 -1
  102. package/dist/esm/server/oneServe.native.js +28 -26
  103. package/dist/esm/server/oneServe.native.js.map +1 -1
  104. package/dist/esm/useLoader.js +27 -11
  105. package/dist/esm/useLoader.js.map +1 -1
  106. package/dist/esm/useLoader.mjs +27 -10
  107. package/dist/esm/useLoader.mjs.map +1 -1
  108. package/dist/esm/useLoader.native.js +45 -18
  109. package/dist/esm/useLoader.native.js.map +1 -1
  110. package/dist/esm/utils/cleanUrl.js +2 -2
  111. package/dist/esm/utils/cleanUrl.js.map +1 -1
  112. package/dist/esm/utils/cleanUrl.mjs +2 -2
  113. package/dist/esm/utils/cleanUrl.mjs.map +1 -1
  114. package/dist/esm/utils/cleanUrl.native.js +4 -2
  115. package/dist/esm/utils/cleanUrl.native.js.map +1 -1
  116. package/dist/esm/utils/cleanUrl.test.js +73 -0
  117. package/dist/esm/utils/cleanUrl.test.js.map +6 -0
  118. package/dist/esm/utils/cleanUrl.test.mjs +65 -0
  119. package/dist/esm/utils/cleanUrl.test.mjs.map +1 -0
  120. package/dist/esm/utils/cleanUrl.test.native.js +65 -0
  121. package/dist/esm/utils/cleanUrl.test.native.js.map +1 -0
  122. package/dist/esm/utils/getPathnameFromFilePath.test.js +73 -0
  123. package/dist/esm/utils/getPathnameFromFilePath.test.js.map +6 -0
  124. package/dist/esm/utils/getPathnameFromFilePath.test.mjs +61 -0
  125. package/dist/esm/utils/getPathnameFromFilePath.test.mjs.map +1 -0
  126. package/dist/esm/utils/getPathnameFromFilePath.test.native.js +63 -0
  127. package/dist/esm/utils/getPathnameFromFilePath.test.native.js.map +1 -0
  128. package/dist/esm/views/Navigator.js +16 -1
  129. package/dist/esm/views/Navigator.js.map +1 -1
  130. package/dist/esm/views/Navigator.mjs +16 -1
  131. package/dist/esm/views/Navigator.mjs.map +1 -1
  132. package/dist/esm/views/Navigator.native.js +18 -3
  133. package/dist/esm/views/Navigator.native.js.map +1 -1
  134. package/dist/esm/vite/plugins/fileSystemRouterPlugin.js +28 -34
  135. package/dist/esm/vite/plugins/fileSystemRouterPlugin.js.map +1 -1
  136. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs +23 -36
  137. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs.map +1 -1
  138. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js +30 -40
  139. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  140. package/package.json +9 -9
  141. package/src/cli/build.ts +43 -30
  142. package/src/createApp.tsx +22 -0
  143. package/src/createHandleRequest.test.ts +11 -3
  144. package/src/createHandleRequest.ts +3 -1
  145. package/src/notFoundState.ts +158 -0
  146. package/src/router/router.ts +15 -1
  147. package/src/server/oneServe.ts +37 -14
  148. package/src/useLoader.ts +25 -12
  149. package/src/utils/cleanUrl.test.ts +120 -0
  150. package/src/utils/cleanUrl.ts +12 -11
  151. package/src/utils/getPathnameFromFilePath.test.ts +118 -0
  152. package/src/views/Navigator.tsx +28 -0
  153. package/src/vite/plugins/fileSystemRouterPlugin.tsx +31 -45
  154. package/types/cli/build.d.ts.map +1 -1
  155. package/types/createApp.d.ts.map +1 -1
  156. package/types/createHandleRequest.d.ts.map +1 -1
  157. package/types/notFoundState.d.ts +13 -0
  158. package/types/notFoundState.d.ts.map +1 -0
  159. package/types/router/router.d.ts.map +1 -1
  160. package/types/server/oneServe.d.ts.map +1 -1
  161. package/types/useLoader.d.ts.map +1 -1
  162. package/types/utils/cleanUrl.d.ts.map +1 -1
  163. package/types/utils/cleanUrl.test.d.ts +2 -0
  164. package/types/utils/cleanUrl.test.d.ts.map +1 -0
  165. package/types/utils/getPathnameFromFilePath.test.d.ts +2 -0
  166. package/types/utils/getPathnameFromFilePath.test.d.ts.map +1 -0
  167. package/types/views/Navigator.d.ts +2 -2
  168. package/types/views/Navigator.d.ts.map +1 -1
  169. package/types/vite/plugins/fileSystemRouterPlugin.d.ts.map +1 -1
package/src/createApp.tsx CHANGED
@@ -164,6 +164,12 @@ export function createApp(options: CreateAppProps) {
164
164
  initClientMatches(serverContext.matches)
165
165
  }
166
166
 
167
+ // NOTE: for SSG 404 pages, we DON'T set notFoundState before initial render
168
+ // because the server rendered the +not-found page through normal routing
169
+ // setting notFoundState would cause useSlot to skip the layout hierarchy,
170
+ // leading to hydration mismatch
171
+ // notFoundState is only set for client-side navigations that result in 404
172
+
167
173
  // Wait for setup file to complete first (if provided)
168
174
  // This ensures setup code (error handlers, analytics, etc.) runs before the app
169
175
  // The function is called here at runtime, not at module evaluation time during build
@@ -181,6 +187,22 @@ export function createApp(options: CreateAppProps) {
181
187
  })
182
188
  : [options.routes[`/${options.routerRoot}/_layout.tsx`]?.()]
183
189
 
190
+ // for 404 pages, use history.state.__tempLocation to route to notFoundPath
191
+ // without changing the browser URL. the router checks __tempLocation and uses
192
+ // that path for routing instead of the URL. this ensures hydration matches
193
+ // the server-rendered +not-found page while keeping the original URL intact
194
+ const one404Marker = (window as any).__one404
195
+ if (one404Marker?.notFoundPath) {
196
+ const currentState = window.history.state || {}
197
+ window.history.replaceState(
198
+ {
199
+ ...currentState,
200
+ __tempLocation: { pathname: one404Marker.notFoundPath, search: '' },
201
+ },
202
+ ''
203
+ )
204
+ }
205
+
184
206
  return setupComplete
185
207
  .then(() => Promise.all(preloadPromises))
186
208
  .then(() => {
@@ -140,10 +140,18 @@ describe('createHandleRequest', () => {
140
140
  expect(result).toBeNull()
141
141
  })
142
142
 
143
- it('should skip paths starting with /@', async () => {
143
+ it('should skip vite internal paths like /@fs/', async () => {
144
144
  const { handler } = createHandleRequest(mockHandlers, { routerRoot: '/app' })
145
- const result = await handler(createRequest('/@fs/some/path'))
146
- expect(result).toBeNull()
145
+ expect(await handler(createRequest('/@fs/some/path'))).toBeNull()
146
+ expect(await handler(createRequest('/@vite/client'))).toBeNull()
147
+ expect(await handler(createRequest('/@id/__x00__virtual:one-entry'))).toBeNull()
148
+ })
149
+
150
+ it('should NOT skip user routes that start with @', async () => {
151
+ const { handler } = createHandleRequest(mockHandlers, { routerRoot: '/app' })
152
+ // routes like /@admin should be matched, not skipped
153
+ await handler(createRequest('/@admin'))
154
+ expect(mockHandlers.handlePage).toHaveBeenCalled()
147
155
  })
148
156
  })
149
157
  })
@@ -302,7 +302,9 @@ export function createHandleRequest(
302
302
  // skip paths handled by vite internals or react native dev middleware
303
303
  if (
304
304
  pathname === '/__vxrnhmr' ||
305
- pathname.startsWith('/@') ||
305
+ pathname.startsWith('/@vite/') ||
306
+ pathname.startsWith('/@fs/') ||
307
+ pathname.startsWith('/@id/') ||
306
308
  pathname.startsWith('/debugger-frontend') ||
307
309
  pathname.startsWith('/inspector')
308
310
  ) {
@@ -0,0 +1,158 @@
1
+ import * as React from 'react'
2
+ import type { RouteNode } from './router/Route'
3
+
4
+ // state for inline not-found rendering
5
+ export interface NotFoundState {
6
+ // path to the +not-found route to render
7
+ notFoundPath: string
8
+ // the route node to render
9
+ notFoundRouteNode?: RouteNode
10
+ // original path that triggered the 404
11
+ originalPath: string
12
+ }
13
+
14
+ // global not-found state
15
+ let currentNotFoundState: NotFoundState | null = null
16
+ const notFoundListeners = new Set<() => void>()
17
+
18
+ export function getNotFoundState(): NotFoundState | null {
19
+ return currentNotFoundState
20
+ }
21
+
22
+ export function setNotFoundState(state: NotFoundState | null) {
23
+ currentNotFoundState = state
24
+ notFoundListeners.forEach((listener) => listener())
25
+ }
26
+
27
+ export function clearNotFoundState() {
28
+ if (currentNotFoundState !== null) {
29
+ currentNotFoundState = null
30
+ notFoundListeners.forEach((listener) => listener())
31
+ }
32
+ }
33
+
34
+ // hook to subscribe to not-found state changes
35
+ export function useNotFoundState(): NotFoundState | null {
36
+ const [, forceUpdate] = React.useReducer((x) => x + 1, 0)
37
+
38
+ React.useEffect(() => {
39
+ notFoundListeners.add(forceUpdate)
40
+ return () => {
41
+ notFoundListeners.delete(forceUpdate)
42
+ }
43
+ }, [])
44
+
45
+ return currentNotFoundState
46
+ }
47
+
48
+ // find nearest +not-found route by walking up the route tree from a path
49
+ export function findNearestNotFoundRoute(
50
+ pathname: string,
51
+ rootNode: RouteNode | null
52
+ ): RouteNode | null {
53
+ if (!rootNode) return null
54
+
55
+ // normalize pathname
56
+ const pathParts = pathname.split('/').filter(Boolean)
57
+
58
+ // recursively search for +not-found at each level
59
+ function findNotFoundInNode(node: RouteNode): RouteNode | null {
60
+ // check if this node itself is a +not-found route
61
+ if (node.route === '+not-found') {
62
+ return node
63
+ }
64
+ // check children for +not-found
65
+ for (const child of node.children || []) {
66
+ if (child.route === '+not-found') {
67
+ return child
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ // traverse tree to find route node matching path, collecting +not-found candidates
74
+ function traverse(
75
+ node: RouteNode,
76
+ depth: number,
77
+ notFoundStack: RouteNode[]
78
+ ): RouteNode | null {
79
+ // check for +not-found at this level
80
+ const notFoundAtLevel = findNotFoundInNode(node)
81
+ if (notFoundAtLevel) {
82
+ notFoundStack.push(notFoundAtLevel)
83
+ }
84
+
85
+ // if we've consumed all path parts, return the deepest +not-found found
86
+ if (depth >= pathParts.length) {
87
+ return notFoundStack.length > 0 ? notFoundStack[notFoundStack.length - 1] : null
88
+ }
89
+
90
+ const segment = pathParts[depth]
91
+
92
+ // find matching child
93
+ for (const child of node.children || []) {
94
+ // skip +not-found routes when matching
95
+ if (child.route === '+not-found') continue
96
+
97
+ // check for direct match or dynamic route
98
+ const childRoute = child.route || ''
99
+ const isDynamic = childRoute.startsWith('[')
100
+ const isMatch = childRoute === segment || isDynamic
101
+
102
+ if (isMatch) {
103
+ const result = traverse(child, depth + 1, [...notFoundStack])
104
+ if (result) return result
105
+ }
106
+ }
107
+
108
+ // no matching child, return deepest +not-found
109
+ return notFoundStack.length > 0 ? notFoundStack[notFoundStack.length - 1] : null
110
+ }
111
+
112
+ return traverse(rootNode, 0, [])
113
+ }
114
+
115
+ // find a route node by its path (e.g., "/ssg-not-found/+not-found")
116
+ export function findRouteNodeByPath(
117
+ notFoundPath: string,
118
+ rootNode: RouteNode | null
119
+ ): RouteNode | null {
120
+ if (!rootNode) return null
121
+
122
+ // normalize path - remove leading slashes, ./, and trailing slashes
123
+ const normalizedPath = notFoundPath.replace(/^(\.?\/)+|\/+$/g, '')
124
+
125
+ // recursive search through all children
126
+ function searchNode(node: RouteNode): RouteNode | null {
127
+ // check if this node's contextKey matches (without extension and prefix)
128
+ const nodeContextKey = node.contextKey || ''
129
+ // strip leading ./ or /, and file extension
130
+ const contextKeyNormalized = nodeContextKey
131
+ .replace(/^(\.?\/)+/, '')
132
+ .replace(/\.[^.]+$/, '')
133
+
134
+ if (contextKeyNormalized === normalizedPath) {
135
+ return node
136
+ }
137
+
138
+ // check children
139
+ for (const child of node.children || []) {
140
+ const found = searchNode(child)
141
+ if (found) return found
142
+ }
143
+
144
+ return null
145
+ }
146
+
147
+ // search from root
148
+ const found = searchNode(rootNode)
149
+ if (found) return found
150
+
151
+ // also search root's children directly
152
+ for (const child of rootNode.children || []) {
153
+ const found = searchNode(child)
154
+ if (found) return found
155
+ }
156
+
157
+ return null
158
+ }
@@ -58,6 +58,11 @@ import {
58
58
  storeInterceptState,
59
59
  } from './interceptRoutes'
60
60
  import { setSlotState } from '../views/Navigator'
61
+ import {
62
+ clearNotFoundState,
63
+ findNearestNotFoundRoute,
64
+ setNotFoundState,
65
+ } from '../notFoundState'
61
66
 
62
67
  // Module-scoped variables
63
68
  export let routeNode: RouteNode | null = null
@@ -882,6 +887,9 @@ export async function linkTo(
882
887
  // This enables intercepting routes to activate
883
888
  setNavigationType('soft')
884
889
 
890
+ // clear any active not-found state on new navigation
891
+ clearNotFoundState()
892
+
885
893
  if (href[0] === '#') {
886
894
  // this is just linking to a section of the current page on web
887
895
  return
@@ -1026,7 +1034,13 @@ export async function linkTo(
1026
1034
  delete preloadedLoaderData[href]
1027
1035
  delete preloadingLoader[href]
1028
1036
  setLoadingState('loaded')
1029
- linkTo(preloadResult.__oneNotFoundPath || '/+not-found', 'REPLACE')
1037
+ // render 404 inline at current URL instead of navigating
1038
+ const notFoundRoute = findNearestNotFoundRoute(href, routeNode)
1039
+ setNotFoundState({
1040
+ notFoundPath: preloadResult.__oneNotFoundPath || '/+not-found',
1041
+ notFoundRouteNode: notFoundRoute || undefined,
1042
+ originalPath: href,
1043
+ })
1030
1044
  return
1031
1045
  }
1032
1046
 
@@ -86,6 +86,15 @@ export async function oneServe(
86
86
  return '/+not-found'
87
87
  }
88
88
 
89
+ // generate a 404 loader response that triggers client-side not-found navigation
90
+ function make404LoaderJs(path: string, logReason?: string): string {
91
+ const nfPath = findNearestNotFoundPath(path)
92
+ if (logReason) {
93
+ console.error(`[one] 404 loader for ${path}: ${logReason}`)
94
+ }
95
+ return `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`
96
+ }
97
+
89
98
  const serverOptions = {
90
99
  ...oneOptions,
91
100
  root: '.',
@@ -165,7 +174,20 @@ export async function oneServe(
165
174
  return null
166
175
  }
167
176
 
168
- const json = await loader(loaderProps)
177
+ let json
178
+ try {
179
+ json = await loader(loaderProps)
180
+ } catch (err) {
181
+ // for file-not-found errors (e.g., missing MDX for non-existent slug),
182
+ // return a 404 signal so the client navigates to +not-found
183
+ if ((err as any)?.code === 'ENOENT') {
184
+ return make404LoaderJs(
185
+ loaderProps?.path || '/',
186
+ `ENOENT ${(err as any)?.path || err}`
187
+ )
188
+ }
189
+ throw err
190
+ }
169
191
 
170
192
  // if the loader returned a Response (e.g. redirect()), throw it
171
193
  // so it bubbles up through resolveResponse and can be transformed
@@ -189,11 +211,6 @@ export async function oneServe(
189
211
  }
190
212
 
191
213
  try {
192
- // Use lazy import if available (workers), otherwise dynamic import (Node.js)
193
- const exported = options?.lazyRoutes?.pages?.[route.file]
194
- ? await options.lazyRoutes.pages[route.file]()
195
- : await import(toAbsolute(buildInfo.serverJsPath))
196
-
197
214
  // helper to import and run a single loader
198
215
  async function runLoader(
199
216
  routeId: string,
@@ -487,9 +504,17 @@ url: ${url}`)
487
504
  }
488
505
 
489
506
  if (notFoundHtml) {
507
+ // inject 404 marker so client knows this is a 404 response
508
+ // this prevents hydration mismatch when the URL matches a dynamic route
509
+ const notFoundMarker = `<script>window.__one404={originalPath:"${url.pathname}",notFoundPath:"${notFoundRoute}"}</script>`
510
+ // inject before </head> or at start of <body>
511
+ const injectedHtml = notFoundHtml.includes('</head>')
512
+ ? notFoundHtml.replace('</head>', `${notFoundMarker}</head>`)
513
+ : notFoundHtml.replace('<body', `${notFoundMarker}<body`)
514
+
490
515
  const headers = new Headers()
491
516
  headers.set('content-type', 'text/html')
492
- return new Response(notFoundHtml, {
517
+ return new Response(injectedHtml, {
493
518
  headers,
494
519
  status: 404,
495
520
  })
@@ -551,10 +576,11 @@ url: ${url}`)
551
576
  // if not in routeMap, the slug wasn't in generateStaticParams - return 404
552
577
  if (route.type === 'ssg' && Object.keys(route.routeKeys).length > 0) {
553
578
  if (!routeMap[originalUrl]) {
554
- const nfPath = findNearestNotFoundPath(originalUrl)
555
579
  return new Response(
556
- `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`,
557
- { headers: { 'Content-Type': 'text/javascript' } }
580
+ make404LoaderJs(originalUrl, 'ssg route not in routeMap'),
581
+ {
582
+ headers: { 'Content-Type': 'text/javascript' },
583
+ }
558
584
  )
559
585
  }
560
586
  }
@@ -710,12 +736,9 @@ url: ${url}`)
710
736
  Object.keys(route.routeKeys).length > 0 &&
711
737
  !routeMap[originalUrl]
712
738
  ) {
713
- const nfPath = findNearestNotFoundPath(originalUrl)
714
739
  c.header('Content-Type', 'text/javascript')
715
740
  c.status(200)
716
- return c.body(
717
- `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`
718
- )
741
+ return c.body(make404LoaderJs(originalUrl, 'ssg route not in routeMap'))
719
742
  }
720
743
 
721
744
  // for now just change this
package/src/useLoader.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { useCallback, useSyncExternalStore } from 'react'
2
2
  import { registerDevtoolsFunction } from './devtools/registry'
3
3
  import { useParams, usePathname } from './hooks'
4
+ import { findNearestNotFoundRoute, setNotFoundState } from './notFoundState'
4
5
  import { router } from './router/imperative-api'
5
- import { preloadedLoaderData, preloadingLoader } from './router/router'
6
+ import { preloadedLoaderData, preloadingLoader, routeNode } from './router/router'
6
7
  import { getLoaderPath } from './utils/cleanUrl'
7
8
  import { dynamicImport } from './utils/dynamicImport'
8
9
  import { weakKey } from './utils/weakKey'
@@ -165,7 +166,7 @@ export async function refetchLoader(pathname: string): Promise<void> {
165
166
  }
166
167
 
167
168
  // detect 404 error signal during refetch
168
- // don't clear data - keep existing data while navigating to not-found
169
+ // render 404 inline at current URL instead of navigating
169
170
  if (result?.__oneError === 404) {
170
171
  recordLoaderTiming?.({
171
172
  path: pathname,
@@ -175,7 +176,12 @@ export async function refetchLoader(pathname: string): Promise<void> {
175
176
  totalTime,
176
177
  source: 'refetch',
177
178
  })
178
- router.replace(result.__oneNotFoundPath || '/+not-found')
179
+ const notFoundRoute = findNearestNotFoundRoute(pathname, routeNode)
180
+ setNotFoundState({
181
+ notFoundPath: result.__oneNotFoundPath || '/+not-found',
182
+ notFoundRouteNode: notFoundRoute || undefined,
183
+ originalPath: pathname,
184
+ })
179
185
  return
180
186
  }
181
187
 
@@ -394,7 +400,7 @@ export function useLoaderState<
394
400
  }
395
401
 
396
402
  // detect 404 error signal on native
397
- // don't update state so the component stays suspended while navigating
403
+ // render 404 inline at current URL instead of navigating
398
404
  if (data?.__oneError === 404) {
399
405
  recordLoaderTiming?.({
400
406
  path: currentPath,
@@ -404,9 +410,13 @@ export function useLoaderState<
404
410
  totalTime,
405
411
  source: 'initial',
406
412
  })
407
- router.replace(data.__oneNotFoundPath || '/+not-found')
408
- // keep component suspended until navigation unmounts it
409
- await new Promise(() => {})
413
+ const notFoundRoute = findNearestNotFoundRoute(currentPath, routeNode)
414
+ setNotFoundState({
415
+ notFoundPath: data.__oneNotFoundPath || '/+not-found',
416
+ notFoundRouteNode: notFoundRoute || undefined,
417
+ originalPath: currentPath,
418
+ })
419
+ return
410
420
  }
411
421
 
412
422
  updateState(currentPath, {
@@ -483,8 +493,7 @@ export function useLoaderState<
483
493
  return
484
494
  }
485
495
 
486
- // detect 404 error signal - navigate to not-found page
487
- // don't update state so the component stays suspended while navigating
496
+ // detect 404 error signal - render 404 inline at current URL
488
497
  if (result?.__oneError === 404) {
489
498
  recordLoaderTiming?.({
490
499
  path: currentPath,
@@ -494,9 +503,13 @@ export function useLoaderState<
494
503
  totalTime,
495
504
  source: 'initial',
496
505
  })
497
- router.replace(result.__oneNotFoundPath || '/+not-found')
498
- // keep component suspended until navigation unmounts it
499
- await new Promise(() => {})
506
+ const notFoundRoute = findNearestNotFoundRoute(currentPath, routeNode)
507
+ setNotFoundState({
508
+ notFoundPath: result.__oneNotFoundPath || '/+not-found',
509
+ notFoundRouteNode: notFoundRoute || undefined,
510
+ originalPath: currentPath,
511
+ })
512
+ return
500
513
  }
501
514
 
502
515
  updateState(currentPath, {
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getLoaderPath, getPathFromLoaderPath } from './cleanUrl'
3
+
4
+ /**
5
+ * tests the cleanUrl encode/decode roundtrip used for loader URLs.
6
+ *
7
+ * cleanUrl encodes a URL path into a flat filename segment:
8
+ * - escapes underscores: _ → __
9
+ * - replaces slashes: / → _
10
+ *
11
+ * getPathFromLoaderPath reverses it:
12
+ * - __ → _ (escaped underscore)
13
+ * - _ → / (path separator)
14
+ */
15
+
16
+ function roundtrip(path: string): string {
17
+ const loaderPath = getLoaderPath(path, false)
18
+ return getPathFromLoaderPath(loaderPath)
19
+ }
20
+
21
+ describe('cleanUrl roundtrip', () => {
22
+ it('simple path', () => {
23
+ expect(roundtrip('/docs/getting-started')).toBe('/docs/getting-started')
24
+ })
25
+
26
+ it('path with underscore prefix (/_/docs)', () => {
27
+ expect(roundtrip('/_/docs/getting-started')).toBe('/_/docs/getting-started')
28
+ })
29
+
30
+ it('path with underscore prefix (/_/terms)', () => {
31
+ expect(roundtrip('/_/terms')).toBe('/_/terms')
32
+ })
33
+
34
+ it('path with underscore in segment name', () => {
35
+ expect(roundtrip('/my_page/test')).toBe('/my_page/test')
36
+ })
37
+
38
+ it('deeply nested path', () => {
39
+ expect(roundtrip('/deep/nested/path/here')).toBe('/deep/nested/path/here')
40
+ })
41
+
42
+ it('root path', () => {
43
+ expect(roundtrip('/')).toBe('/')
44
+ })
45
+
46
+ it('single segment', () => {
47
+ expect(roundtrip('/about')).toBe('/about')
48
+ })
49
+
50
+ it('path with query string is stripped', () => {
51
+ const loaderPath = getLoaderPath('/docs/intro?foo=bar', false)
52
+ expect(getPathFromLoaderPath(loaderPath)).toBe('/docs/intro')
53
+ })
54
+
55
+ it('path with hash is stripped', () => {
56
+ const loaderPath = getLoaderPath('/docs/intro#section', false)
57
+ expect(getPathFromLoaderPath(loaderPath)).toBe('/docs/intro')
58
+ })
59
+
60
+ it('path with trailing slash', () => {
61
+ const loaderPath = getLoaderPath('/docs/intro/', false)
62
+ expect(getPathFromLoaderPath(loaderPath)).toBe('/docs/intro')
63
+ })
64
+ })
65
+
66
+ describe('getLoaderPath format', () => {
67
+ it('includes /assets/ prefix', () => {
68
+ const result = getLoaderPath('/docs/intro', false)
69
+ expect(result).toMatch(/^\/assets\//)
70
+ })
71
+
72
+ it('ends with loader postfix', () => {
73
+ const result = getLoaderPath('/docs/intro', false)
74
+ expect(result).toMatch(/_\d+_vxrn_loader\.js$/)
75
+ })
76
+
77
+ it('includes /_one prefix in dev mode', () => {
78
+ const originalEnv = process.env.NODE_ENV
79
+ process.env.NODE_ENV = 'development'
80
+ try {
81
+ const result = getLoaderPath('/docs/intro', false)
82
+ expect(result).toMatch(/^\/_one\/assets\//)
83
+ } finally {
84
+ process.env.NODE_ENV = originalEnv
85
+ }
86
+ })
87
+
88
+ it('includes cache bust segment', () => {
89
+ const result = getLoaderPath('/docs/intro', false, '12345')
90
+ expect(result).toContain('_refetch_12345_')
91
+ })
92
+ })
93
+
94
+ describe('getPathFromLoaderPath', () => {
95
+ it('strips /_one/assets prefix', () => {
96
+ expect(getPathFromLoaderPath('/_one/assets/docs_intro_999_vxrn_loader.js')).toBe(
97
+ '/docs/intro'
98
+ )
99
+ })
100
+
101
+ it('strips /assets prefix', () => {
102
+ expect(getPathFromLoaderPath('/assets/docs_intro_999_vxrn_loader.js')).toBe(
103
+ '/docs/intro'
104
+ )
105
+ })
106
+
107
+ it('strips refetch cache bust', () => {
108
+ expect(
109
+ getPathFromLoaderPath('/assets/docs_intro_refetch_12345__999_vxrn_loader.js')
110
+ ).toBe('/docs/intro')
111
+ })
112
+
113
+ it('decodes escaped underscores back to literal underscores', () => {
114
+ // path /_/docs/intro → cleanUrl("_/docs/intro") → "___docs_intro"
115
+ // ___ decodes as: __ → "_", _ → "/" → "/_/docs/intro"
116
+ expect(getPathFromLoaderPath('/assets/___docs_intro_999_vxrn_loader.js')).toBe(
117
+ '/_/docs/intro'
118
+ )
119
+ })
120
+ })
@@ -9,12 +9,10 @@ import { getURL } from '../getURL'
9
9
  import { removeSearch } from './removeSearch'
10
10
 
11
11
  function cleanUrl(path: string) {
12
- return (
13
- removeSearch(path)
14
- .replaceAll('/', '_')
15
- // remove trailing _
16
- .replace(/_$/, '')
17
- )
12
+ return removeSearch(path)
13
+ .replace(/\/$/, '') // remove trailing slash before encoding
14
+ .replaceAll('_', '__') // escape existing underscores
15
+ .replaceAll('/', '_') // use underscore as path separator
18
16
  }
19
17
 
20
18
  const isClient = typeof window !== 'undefined'
@@ -45,9 +43,12 @@ export function getLoaderPath(
45
43
  }
46
44
 
47
45
  export function getPathFromLoaderPath(loaderPath: string) {
48
- return loaderPath
49
- .replace(LOADER_JS_POSTFIX_REGEX, '')
50
- .replace(/^(\/_one)?\/assets/, '')
51
- .replace(/_refetch_\d+_/, '')
52
- .replaceAll(/_/g, '/')
46
+ return (
47
+ loaderPath
48
+ .replace(LOADER_JS_POSTFIX_REGEX, '')
49
+ .replace(/^(\/_one)?\/assets/, '')
50
+ .replace(/_refetch_\d+_/, '')
51
+ // decode: __ → _ (escaped underscore), _ → / (path separator)
52
+ .replace(/__|_/g, (match) => (match === '__' ? '_' : '/'))
53
+ )
53
54
  }