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.
- package/dist/cjs/cli/build.cjs +25 -19
- package/dist/cjs/cli/build.js +36 -24
- package/dist/cjs/cli/build.js.map +2 -2
- package/dist/cjs/cli/build.native.js +153 -117
- package/dist/cjs/cli/build.native.js.map +1 -1
- package/dist/cjs/createApp.cjs +12 -1
- package/dist/cjs/createApp.js +11 -1
- package/dist/cjs/createApp.js.map +1 -1
- package/dist/cjs/createHandleRequest.cjs +1 -1
- package/dist/cjs/createHandleRequest.js +1 -1
- package/dist/cjs/createHandleRequest.js.map +1 -1
- package/dist/cjs/createHandleRequest.native.js +1 -1
- package/dist/cjs/createHandleRequest.native.js.map +1 -1
- package/dist/cjs/createHandleRequest.test.cjs +13 -7
- package/dist/cjs/createHandleRequest.test.js +6 -3
- package/dist/cjs/createHandleRequest.test.js.map +1 -1
- package/dist/cjs/createHandleRequest.test.native.js +13 -7
- package/dist/cjs/createHandleRequest.test.native.js.map +1 -1
- package/dist/cjs/notFoundState.cjs +103 -0
- package/dist/cjs/notFoundState.js +99 -0
- package/dist/cjs/notFoundState.js.map +6 -0
- package/dist/cjs/notFoundState.native.js +176 -0
- package/dist/cjs/notFoundState.native.js.map +1 -0
- package/dist/cjs/router/router.cjs +10 -3
- package/dist/cjs/router/router.js +9 -3
- package/dist/cjs/router/router.js.map +1 -1
- package/dist/cjs/router/router.native.js +10 -3
- package/dist/cjs/router/router.native.js.map +1 -1
- package/dist/cjs/server/oneServe.cjs +21 -16
- package/dist/cjs/server/oneServe.js +24 -15
- package/dist/cjs/server/oneServe.js.map +1 -1
- package/dist/cjs/server/oneServe.native.js +28 -26
- package/dist/cjs/server/oneServe.native.js.map +1 -1
- package/dist/cjs/useLoader.cjs +26 -9
- package/dist/cjs/useLoader.js +26 -11
- package/dist/cjs/useLoader.js.map +1 -1
- package/dist/cjs/useLoader.native.js +44 -17
- package/dist/cjs/useLoader.native.js.map +1 -1
- package/dist/cjs/utils/cleanUrl.cjs +2 -2
- package/dist/cjs/utils/cleanUrl.js +2 -2
- package/dist/cjs/utils/cleanUrl.js.map +1 -1
- package/dist/cjs/utils/cleanUrl.native.js +4 -2
- package/dist/cjs/utils/cleanUrl.native.js.map +1 -1
- package/dist/cjs/utils/cleanUrl.test.cjs +64 -0
- package/dist/cjs/utils/cleanUrl.test.js +72 -0
- package/dist/cjs/utils/cleanUrl.test.js.map +6 -0
- package/dist/cjs/utils/cleanUrl.test.native.js +67 -0
- package/dist/cjs/utils/cleanUrl.test.native.js.map +1 -0
- package/dist/cjs/utils/getPathnameFromFilePath.test.cjs +60 -0
- package/dist/cjs/utils/getPathnameFromFilePath.test.js +72 -0
- package/dist/cjs/utils/getPathnameFromFilePath.test.js.map +6 -0
- package/dist/cjs/utils/getPathnameFromFilePath.test.native.js +65 -0
- package/dist/cjs/utils/getPathnameFromFilePath.test.native.js.map +1 -0
- package/dist/cjs/views/Navigator.cjs +18 -3
- package/dist/cjs/views/Navigator.js +13 -4
- package/dist/cjs/views/Navigator.js.map +2 -2
- package/dist/cjs/views/Navigator.native.js +21 -6
- package/dist/cjs/views/Navigator.native.js.map +1 -1
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.cjs +23 -36
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js +28 -34
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js.map +1 -1
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js +30 -40
- package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
- package/dist/esm/cli/build.js +36 -24
- package/dist/esm/cli/build.js.map +2 -2
- package/dist/esm/cli/build.mjs +25 -19
- package/dist/esm/cli/build.mjs.map +1 -1
- package/dist/esm/cli/build.native.js +153 -117
- package/dist/esm/cli/build.native.js.map +1 -1
- package/dist/esm/createApp.js +11 -1
- package/dist/esm/createApp.js.map +1 -1
- package/dist/esm/createApp.mjs +12 -1
- package/dist/esm/createApp.mjs.map +1 -1
- package/dist/esm/createHandleRequest.js +1 -1
- package/dist/esm/createHandleRequest.js.map +1 -1
- package/dist/esm/createHandleRequest.mjs +1 -1
- package/dist/esm/createHandleRequest.mjs.map +1 -1
- package/dist/esm/createHandleRequest.native.js +1 -1
- package/dist/esm/createHandleRequest.native.js.map +1 -1
- package/dist/esm/createHandleRequest.test.js +6 -3
- package/dist/esm/createHandleRequest.test.js.map +1 -1
- package/dist/esm/createHandleRequest.test.mjs +13 -7
- package/dist/esm/createHandleRequest.test.mjs.map +1 -1
- package/dist/esm/createHandleRequest.test.native.js +13 -7
- package/dist/esm/createHandleRequest.test.native.js.map +1 -1
- package/dist/esm/notFoundState.js +75 -0
- package/dist/esm/notFoundState.js.map +6 -0
- package/dist/esm/notFoundState.mjs +64 -0
- package/dist/esm/notFoundState.mjs.map +1 -0
- package/dist/esm/notFoundState.native.js +134 -0
- package/dist/esm/notFoundState.native.js.map +1 -0
- package/dist/esm/router/router.js +13 -2
- package/dist/esm/router/router.js.map +1 -1
- package/dist/esm/router/router.mjs +9 -2
- package/dist/esm/router/router.mjs.map +1 -1
- package/dist/esm/router/router.native.js +9 -2
- package/dist/esm/router/router.native.js.map +1 -1
- package/dist/esm/server/oneServe.js +24 -15
- package/dist/esm/server/oneServe.js.map +1 -1
- package/dist/esm/server/oneServe.mjs +21 -16
- package/dist/esm/server/oneServe.mjs.map +1 -1
- package/dist/esm/server/oneServe.native.js +28 -26
- package/dist/esm/server/oneServe.native.js.map +1 -1
- package/dist/esm/useLoader.js +27 -11
- package/dist/esm/useLoader.js.map +1 -1
- package/dist/esm/useLoader.mjs +27 -10
- package/dist/esm/useLoader.mjs.map +1 -1
- package/dist/esm/useLoader.native.js +45 -18
- package/dist/esm/useLoader.native.js.map +1 -1
- package/dist/esm/utils/cleanUrl.js +2 -2
- package/dist/esm/utils/cleanUrl.js.map +1 -1
- package/dist/esm/utils/cleanUrl.mjs +2 -2
- package/dist/esm/utils/cleanUrl.mjs.map +1 -1
- package/dist/esm/utils/cleanUrl.native.js +4 -2
- package/dist/esm/utils/cleanUrl.native.js.map +1 -1
- package/dist/esm/utils/cleanUrl.test.js +73 -0
- package/dist/esm/utils/cleanUrl.test.js.map +6 -0
- package/dist/esm/utils/cleanUrl.test.mjs +65 -0
- package/dist/esm/utils/cleanUrl.test.mjs.map +1 -0
- package/dist/esm/utils/cleanUrl.test.native.js +65 -0
- package/dist/esm/utils/cleanUrl.test.native.js.map +1 -0
- package/dist/esm/utils/getPathnameFromFilePath.test.js +73 -0
- package/dist/esm/utils/getPathnameFromFilePath.test.js.map +6 -0
- package/dist/esm/utils/getPathnameFromFilePath.test.mjs +61 -0
- package/dist/esm/utils/getPathnameFromFilePath.test.mjs.map +1 -0
- package/dist/esm/utils/getPathnameFromFilePath.test.native.js +63 -0
- package/dist/esm/utils/getPathnameFromFilePath.test.native.js.map +1 -0
- package/dist/esm/views/Navigator.js +16 -1
- package/dist/esm/views/Navigator.js.map +1 -1
- package/dist/esm/views/Navigator.mjs +16 -1
- package/dist/esm/views/Navigator.mjs.map +1 -1
- package/dist/esm/views/Navigator.native.js +18 -3
- package/dist/esm/views/Navigator.native.js.map +1 -1
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.js +28 -34
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.js.map +1 -1
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs +23 -36
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs.map +1 -1
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js +30 -40
- package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
- package/package.json +9 -9
- package/src/cli/build.ts +43 -30
- package/src/createApp.tsx +22 -0
- package/src/createHandleRequest.test.ts +11 -3
- package/src/createHandleRequest.ts +3 -1
- package/src/notFoundState.ts +158 -0
- package/src/router/router.ts +15 -1
- package/src/server/oneServe.ts +37 -14
- package/src/useLoader.ts +25 -12
- package/src/utils/cleanUrl.test.ts +120 -0
- package/src/utils/cleanUrl.ts +12 -11
- package/src/utils/getPathnameFromFilePath.test.ts +118 -0
- package/src/views/Navigator.tsx +28 -0
- package/src/vite/plugins/fileSystemRouterPlugin.tsx +31 -45
- package/types/cli/build.d.ts.map +1 -1
- package/types/createApp.d.ts.map +1 -1
- package/types/createHandleRequest.d.ts.map +1 -1
- package/types/notFoundState.d.ts +13 -0
- package/types/notFoundState.d.ts.map +1 -0
- package/types/router/router.d.ts.map +1 -1
- package/types/server/oneServe.d.ts.map +1 -1
- package/types/useLoader.d.ts.map +1 -1
- package/types/utils/cleanUrl.d.ts.map +1 -1
- package/types/utils/cleanUrl.test.d.ts +2 -0
- package/types/utils/cleanUrl.test.d.ts.map +1 -0
- package/types/utils/getPathnameFromFilePath.test.d.ts +2 -0
- package/types/utils/getPathnameFromFilePath.test.d.ts.map +1 -0
- package/types/views/Navigator.d.ts +2 -2
- package/types/views/Navigator.d.ts.map +1 -1
- 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
|
|
143
|
+
it('should skip vite internal paths like /@fs/', async () => {
|
|
144
144
|
const { handler } = createHandleRequest(mockHandlers, { routerRoot: '/app' })
|
|
145
|
-
|
|
146
|
-
expect(
|
|
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
|
+
}
|
package/src/router/router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/server/oneServe.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
557
|
-
{
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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 -
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
+
})
|
package/src/utils/cleanUrl.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
}
|