one 1.9.3 → 1.9.5

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 (129) hide show
  1. package/dist/cjs/cli/build.cjs +3 -1
  2. package/dist/cjs/cli/build.js +5 -1
  3. package/dist/cjs/cli/build.js.map +1 -1
  4. package/dist/cjs/cli/build.native.js +3 -1
  5. package/dist/cjs/cli/build.native.js.map +1 -1
  6. package/dist/cjs/cli/buildPageWorker.cjs +1 -1
  7. package/dist/cjs/cli/buildPageWorker.js +1 -1
  8. package/dist/cjs/cli/buildPageWorker.js.map +1 -1
  9. package/dist/cjs/cli/buildPageWorker.native.js +1 -1
  10. package/dist/cjs/cli/buildPageWorker.native.js.map +1 -1
  11. package/dist/cjs/daemon/picker.cjs +4 -0
  12. package/dist/cjs/daemon/picker.js +4 -0
  13. package/dist/cjs/daemon/picker.js.map +1 -1
  14. package/dist/cjs/daemon/picker.native.js +4 -0
  15. package/dist/cjs/daemon/picker.native.js.map +1 -1
  16. package/dist/cjs/daemon/tui.cjs +5 -1
  17. package/dist/cjs/daemon/tui.js +5 -1
  18. package/dist/cjs/daemon/tui.js.map +1 -1
  19. package/dist/cjs/daemon/tui.native.js +5 -1
  20. package/dist/cjs/daemon/tui.native.js.map +1 -1
  21. package/dist/cjs/router/router.cjs +4 -0
  22. package/dist/cjs/router/router.js +4 -0
  23. package/dist/cjs/router/router.js.map +1 -1
  24. package/dist/cjs/router/router.native.js +4 -0
  25. package/dist/cjs/router/router.native.js.map +1 -1
  26. package/dist/cjs/server/oneServe.cjs +42 -25
  27. package/dist/cjs/server/oneServe.js +45 -24
  28. package/dist/cjs/server/oneServe.js.map +1 -1
  29. package/dist/cjs/server/oneServe.native.js +41 -24
  30. package/dist/cjs/server/oneServe.native.js.map +1 -1
  31. package/dist/cjs/useLoader.cjs +43 -25
  32. package/dist/cjs/useLoader.js +30 -12
  33. package/dist/cjs/useLoader.js.map +1 -1
  34. package/dist/cjs/useLoader.native.js +54 -29
  35. package/dist/cjs/useLoader.native.js.map +1 -1
  36. package/dist/cjs/vite/plugins/clientTreeShakePlugin.cjs +3 -1
  37. package/dist/cjs/vite/plugins/clientTreeShakePlugin.js +1 -1
  38. package/dist/cjs/vite/plugins/clientTreeShakePlugin.js.map +1 -1
  39. package/dist/cjs/vite/plugins/clientTreeShakePlugin.native.js +3 -1
  40. package/dist/cjs/vite/plugins/clientTreeShakePlugin.native.js.map +1 -1
  41. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.cjs +98 -10
  42. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js +102 -13
  43. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.js.map +2 -2
  44. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js +106 -10
  45. package/dist/cjs/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  46. package/dist/cjs/vite/plugins/sourceInspectorPlugin.cjs +6 -4
  47. package/dist/cjs/vite/plugins/sourceInspectorPlugin.js +7 -5
  48. package/dist/cjs/vite/plugins/sourceInspectorPlugin.js.map +1 -1
  49. package/dist/cjs/vite/plugins/sourceInspectorPlugin.native.js +8 -5
  50. package/dist/cjs/vite/plugins/sourceInspectorPlugin.native.js.map +1 -1
  51. package/dist/esm/cli/build.js +5 -1
  52. package/dist/esm/cli/build.js.map +1 -1
  53. package/dist/esm/cli/build.mjs +3 -1
  54. package/dist/esm/cli/build.mjs.map +1 -1
  55. package/dist/esm/cli/build.native.js +3 -1
  56. package/dist/esm/cli/build.native.js.map +1 -1
  57. package/dist/esm/cli/buildPageWorker.js +1 -1
  58. package/dist/esm/cli/buildPageWorker.js.map +1 -1
  59. package/dist/esm/cli/buildPageWorker.mjs +1 -1
  60. package/dist/esm/cli/buildPageWorker.mjs.map +1 -1
  61. package/dist/esm/cli/buildPageWorker.native.js +1 -1
  62. package/dist/esm/cli/buildPageWorker.native.js.map +1 -1
  63. package/dist/esm/daemon/picker.js +4 -0
  64. package/dist/esm/daemon/picker.js.map +1 -1
  65. package/dist/esm/daemon/picker.mjs +4 -0
  66. package/dist/esm/daemon/picker.mjs.map +1 -1
  67. package/dist/esm/daemon/picker.native.js +4 -0
  68. package/dist/esm/daemon/picker.native.js.map +1 -1
  69. package/dist/esm/daemon/tui.js +5 -1
  70. package/dist/esm/daemon/tui.js.map +1 -1
  71. package/dist/esm/daemon/tui.mjs +5 -1
  72. package/dist/esm/daemon/tui.mjs.map +1 -1
  73. package/dist/esm/daemon/tui.native.js +5 -1
  74. package/dist/esm/daemon/tui.native.js.map +1 -1
  75. package/dist/esm/router/router.js +4 -0
  76. package/dist/esm/router/router.js.map +1 -1
  77. package/dist/esm/router/router.mjs +4 -0
  78. package/dist/esm/router/router.mjs.map +1 -1
  79. package/dist/esm/router/router.native.js +4 -0
  80. package/dist/esm/router/router.native.js.map +1 -1
  81. package/dist/esm/server/oneServe.js +45 -24
  82. package/dist/esm/server/oneServe.js.map +1 -1
  83. package/dist/esm/server/oneServe.mjs +42 -25
  84. package/dist/esm/server/oneServe.mjs.map +1 -1
  85. package/dist/esm/server/oneServe.native.js +41 -24
  86. package/dist/esm/server/oneServe.native.js.map +1 -1
  87. package/dist/esm/useLoader.js +30 -12
  88. package/dist/esm/useLoader.js.map +1 -1
  89. package/dist/esm/useLoader.mjs +43 -25
  90. package/dist/esm/useLoader.mjs.map +1 -1
  91. package/dist/esm/useLoader.native.js +54 -29
  92. package/dist/esm/useLoader.native.js.map +1 -1
  93. package/dist/esm/vite/plugins/clientTreeShakePlugin.js +1 -1
  94. package/dist/esm/vite/plugins/clientTreeShakePlugin.js.map +1 -1
  95. package/dist/esm/vite/plugins/clientTreeShakePlugin.mjs +3 -1
  96. package/dist/esm/vite/plugins/clientTreeShakePlugin.mjs.map +1 -1
  97. package/dist/esm/vite/plugins/clientTreeShakePlugin.native.js +3 -1
  98. package/dist/esm/vite/plugins/clientTreeShakePlugin.native.js.map +1 -1
  99. package/dist/esm/vite/plugins/fileSystemRouterPlugin.js +102 -13
  100. package/dist/esm/vite/plugins/fileSystemRouterPlugin.js.map +2 -2
  101. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs +98 -10
  102. package/dist/esm/vite/plugins/fileSystemRouterPlugin.mjs.map +1 -1
  103. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js +106 -10
  104. package/dist/esm/vite/plugins/fileSystemRouterPlugin.native.js.map +1 -1
  105. package/dist/esm/vite/plugins/sourceInspectorPlugin.js +7 -5
  106. package/dist/esm/vite/plugins/sourceInspectorPlugin.js.map +1 -1
  107. package/dist/esm/vite/plugins/sourceInspectorPlugin.mjs +6 -4
  108. package/dist/esm/vite/plugins/sourceInspectorPlugin.mjs.map +1 -1
  109. package/dist/esm/vite/plugins/sourceInspectorPlugin.native.js +8 -5
  110. package/dist/esm/vite/plugins/sourceInspectorPlugin.native.js.map +1 -1
  111. package/package.json +9 -9
  112. package/src/cli/build.ts +7 -1
  113. package/src/cli/buildPageWorker.ts +4 -1
  114. package/src/daemon/picker.ts +8 -0
  115. package/src/daemon/tui.ts +8 -0
  116. package/src/router/router.ts +12 -0
  117. package/src/server/oneServe.ts +66 -35
  118. package/src/useLoader.ts +88 -40
  119. package/src/vite/plugins/clientTreeShakePlugin.ts +1 -1
  120. package/src/vite/plugins/fileSystemRouterPlugin.tsx +163 -22
  121. package/src/vite/plugins/sourceInspectorPlugin.ts +18 -16
  122. package/types/cli/build.d.ts.map +1 -1
  123. package/types/daemon/picker.d.ts.map +1 -1
  124. package/types/daemon/tui.d.ts.map +1 -1
  125. package/types/router/router.d.ts.map +1 -1
  126. package/types/server/oneServe.d.ts.map +1 -1
  127. package/types/useLoader.d.ts.map +1 -1
  128. package/types/vite/plugins/fileSystemRouterPlugin.d.ts.map +1 -1
  129. package/types/vite/plugins/sourceInspectorPlugin.d.ts.map +1 -1
@@ -72,6 +72,20 @@ export async function oneServe(
72
72
 
73
73
  const { routeToBuildInfo, routeMap } = buildInfo as One.BuildInfo
74
74
 
75
+ // find nearest +not-found path by walking up from a url path
76
+ function findNearestNotFoundPath(urlPath: string): string {
77
+ let cur = urlPath
78
+ while (cur) {
79
+ const parent = cur.lastIndexOf('/') > 0 ? cur.slice(0, cur.lastIndexOf('/')) : ''
80
+ if (routeMap[`${parent}/+not-found`]) {
81
+ return `${parent}/+not-found`
82
+ }
83
+ if (!parent) break
84
+ cur = parent
85
+ }
86
+ return '/+not-found'
87
+ }
88
+
75
89
  const serverOptions = {
76
90
  ...oneOptions,
77
91
  root: '.',
@@ -450,46 +464,36 @@ url: ${url}`)
450
464
  // dynamic route matched but no static HTML exists for this path
451
465
  // (slug wasn't in generateStaticParams) - return 404
452
466
  if (isDynamicRoute) {
453
- // find nearest +not-found.html by walking up the path
454
- let currentPath = url.pathname
455
- while (currentPath) {
456
- const parentDir =
457
- currentPath.lastIndexOf('/') > 0
458
- ? currentPath.slice(0, currentPath.lastIndexOf('/'))
459
- : ''
460
- const notFoundPath = routeMap[`${parentDir}/+not-found`]
461
-
462
- if (notFoundPath) {
463
- const fetchStaticHtml = getFetchStaticHtml()
464
- let notFoundHtml: string | null = null
465
-
466
- if (fetchStaticHtml) {
467
- notFoundHtml = await fetchStaticHtml(notFoundPath)
468
- }
467
+ const notFoundRoute = findNearestNotFoundPath(url.pathname)
468
+ const notFoundHtmlPath = routeMap[notFoundRoute]
469
469
 
470
- if (!notFoundHtml) {
471
- try {
472
- notFoundHtml = await readFile(
473
- join('dist/client', notFoundPath),
474
- 'utf-8'
475
- )
476
- } catch {
477
- // File not found
478
- }
479
- }
470
+ if (notFoundHtmlPath) {
471
+ const fetchStaticHtml = getFetchStaticHtml()
472
+ let notFoundHtml: string | null = null
473
+
474
+ if (fetchStaticHtml) {
475
+ notFoundHtml = await fetchStaticHtml(notFoundHtmlPath)
476
+ }
480
477
 
481
- if (notFoundHtml) {
482
- const headers = new Headers()
483
- headers.set('content-type', 'text/html')
484
- return new Response(notFoundHtml, {
485
- headers,
486
- status: 404,
487
- })
478
+ if (!notFoundHtml) {
479
+ try {
480
+ notFoundHtml = await readFile(
481
+ join('dist/client', notFoundHtmlPath),
482
+ 'utf-8'
483
+ )
484
+ } catch {
485
+ // File not found
488
486
  }
489
487
  }
490
488
 
491
- if (!parentDir) break
492
- currentPath = parentDir
489
+ if (notFoundHtml) {
490
+ const headers = new Headers()
491
+ headers.set('content-type', 'text/html')
492
+ return new Response(notFoundHtml, {
493
+ headers,
494
+ status: 404,
495
+ })
496
+ }
493
497
  }
494
498
 
495
499
  // no +not-found.html found, return basic 404
@@ -542,6 +546,19 @@ url: ${url}`)
542
546
  // this handles all loader refetches or fetches due to navigation
543
547
  if (url.pathname.endsWith(LOADER_JS_POSTFIX_UNCACHED)) {
544
548
  const originalUrl = getPathFromLoaderPath(url.pathname)
549
+
550
+ // for ssg routes with dynamic params, check if this path was statically generated
551
+ // if not in routeMap, the slug wasn't in generateStaticParams - return 404
552
+ if (route.type === 'ssg' && Object.keys(route.routeKeys).length > 0) {
553
+ if (!routeMap[originalUrl]) {
554
+ const nfPath = findNearestNotFoundPath(originalUrl)
555
+ return new Response(
556
+ `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`,
557
+ { headers: { 'Content-Type': 'text/javascript' } }
558
+ )
559
+ }
560
+ }
561
+
545
562
  const finalUrl = new URL(originalUrl, url.origin)
546
563
  const cleanedRequest = new Request(finalUrl, request)
547
564
  return resolveLoaderRoute(requestHandlers, cleanedRequest, finalUrl, route)
@@ -687,6 +704,20 @@ url: ${url}`)
687
704
  continue
688
705
  }
689
706
 
707
+ // for ssg routes with dynamic params, check if this path was statically generated
708
+ if (
709
+ route.type === 'ssg' &&
710
+ Object.keys(route.routeKeys).length > 0 &&
711
+ !routeMap[originalUrl]
712
+ ) {
713
+ const nfPath = findNearestNotFoundPath(originalUrl)
714
+ c.header('Content-Type', 'text/javascript')
715
+ c.status(200)
716
+ return c.body(
717
+ `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`
718
+ )
719
+ }
720
+
690
721
  // for now just change this
691
722
  const loaderRoute = {
692
723
  ...route,
package/src/useLoader.ts CHANGED
@@ -32,37 +32,38 @@ export type LoaderTimingEntry = {
32
32
  const loaderTimingHistory: LoaderTimingEntry[] = []
33
33
  const MAX_TIMING_HISTORY = 50
34
34
 
35
- function recordLoaderTiming(entry: LoaderTimingEntry) {
36
- if (process.env.NODE_ENV !== 'development') return
37
-
38
- loaderTimingHistory.unshift(entry)
39
- if (loaderTimingHistory.length > MAX_TIMING_HISTORY) {
40
- loaderTimingHistory.pop()
41
- }
42
- // Dispatch event for devtools (web only - CustomEvent doesn't exist on native)
43
- if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {
44
- window.dispatchEvent(new CustomEvent('one-loader-timing', { detail: entry }))
45
-
46
- // Also dispatch error event if there was an error
47
- if (entry.error) {
48
- window.dispatchEvent(
49
- new CustomEvent('one-error', {
50
- detail: {
51
- error: {
52
- message: entry.error,
53
- name: 'LoaderError',
54
- },
55
- route: {
56
- pathname: entry.path,
57
- },
58
- timestamp: Date.now(),
59
- type: 'loader',
60
- },
61
- })
62
- )
63
- }
64
- }
65
- }
35
+ const recordLoaderTiming =
36
+ process.env.NODE_ENV === 'development'
37
+ ? (entry: LoaderTimingEntry) => {
38
+ loaderTimingHistory.unshift(entry)
39
+ if (loaderTimingHistory.length > MAX_TIMING_HISTORY) {
40
+ loaderTimingHistory.pop()
41
+ }
42
+ // Dispatch event for devtools (web only - CustomEvent doesn't exist on native)
43
+ if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {
44
+ window.dispatchEvent(new CustomEvent('one-loader-timing', { detail: entry }))
45
+
46
+ // Also dispatch error event if there was an error
47
+ if (entry.error) {
48
+ window.dispatchEvent(
49
+ new CustomEvent('one-error', {
50
+ detail: {
51
+ error: {
52
+ message: entry.error,
53
+ name: 'LoaderError',
54
+ },
55
+ route: {
56
+ pathname: entry.path,
57
+ },
58
+ timestamp: Date.now(),
59
+ type: 'loader',
60
+ },
61
+ })
62
+ )
63
+ }
64
+ }
65
+ }
66
+ : undefined
66
67
 
67
68
  export function getLoaderTimingHistory(): LoaderTimingEntry[] {
68
69
  return loaderTimingHistory
@@ -146,7 +147,7 @@ export async function refetchLoader(pathname: string): Promise<void> {
146
147
 
147
148
  // detect server redirect signal during refetch
148
149
  if (result?.__oneRedirect) {
149
- recordLoaderTiming({
150
+ recordLoaderTiming?.({
150
151
  path: pathname,
151
152
  startTime,
152
153
  moduleLoadTime,
@@ -163,6 +164,21 @@ export async function refetchLoader(pathname: string): Promise<void> {
163
164
  return
164
165
  }
165
166
 
167
+ // detect 404 error signal during refetch
168
+ // don't clear data - keep existing data while navigating to not-found
169
+ if (result?.__oneError === 404) {
170
+ recordLoaderTiming?.({
171
+ path: pathname,
172
+ startTime,
173
+ moduleLoadTime,
174
+ executionTime,
175
+ totalTime,
176
+ source: 'refetch',
177
+ })
178
+ router.replace(result.__oneNotFoundPath || '/+not-found')
179
+ return
180
+ }
181
+
166
182
  updateState(pathname, {
167
183
  data: result,
168
184
  state: 'idle',
@@ -170,7 +186,7 @@ export async function refetchLoader(pathname: string): Promise<void> {
170
186
  hasLoadedOnce: true,
171
187
  })
172
188
 
173
- recordLoaderTiming({
189
+ recordLoaderTiming?.({
174
190
  path: pathname,
175
191
  startTime,
176
192
  moduleLoadTime,
@@ -186,7 +202,7 @@ export async function refetchLoader(pathname: string): Promise<void> {
186
202
  state: 'idle',
187
203
  })
188
204
 
189
- recordLoaderTiming({
205
+ recordLoaderTiming?.({
190
206
  path: pathname,
191
207
  startTime,
192
208
  totalTime,
@@ -360,7 +376,7 @@ export function useLoaderState<
360
376
 
361
377
  // detect server redirect signal on native
362
378
  if (data?.__oneRedirect) {
363
- recordLoaderTiming({
379
+ recordLoaderTiming?.({
364
380
  path: currentPath,
365
381
  startTime,
366
382
  moduleLoadTime,
@@ -377,13 +393,29 @@ export function useLoaderState<
377
393
  return
378
394
  }
379
395
 
396
+ // detect 404 error signal on native
397
+ // don't update state so the component stays suspended while navigating
398
+ if (data?.__oneError === 404) {
399
+ recordLoaderTiming?.({
400
+ path: currentPath,
401
+ startTime,
402
+ moduleLoadTime,
403
+ executionTime,
404
+ totalTime,
405
+ source: 'initial',
406
+ })
407
+ router.replace(data.__oneNotFoundPath || '/+not-found')
408
+ // keep component suspended until navigation unmounts it
409
+ await new Promise(() => {})
410
+ }
411
+
380
412
  updateState(currentPath, {
381
413
  data,
382
414
  hasLoadedOnce: true,
383
415
  promise: undefined,
384
416
  })
385
417
 
386
- recordLoaderTiming({
418
+ recordLoaderTiming?.({
387
419
  path: currentPath,
388
420
  startTime,
389
421
  moduleLoadTime,
@@ -398,7 +430,7 @@ export function useLoaderState<
398
430
  data: {},
399
431
  promise: undefined,
400
432
  })
401
- recordLoaderTiming({
433
+ recordLoaderTiming?.({
402
434
  path: currentPath,
403
435
  startTime,
404
436
  totalTime,
@@ -434,7 +466,7 @@ export function useLoaderState<
434
466
 
435
467
  // detect server redirect signal (fallback if preload didn't catch it)
436
468
  if (result?.__oneRedirect) {
437
- recordLoaderTiming({
469
+ recordLoaderTiming?.({
438
470
  path: currentPath,
439
471
  startTime,
440
472
  moduleLoadTime,
@@ -451,13 +483,29 @@ export function useLoaderState<
451
483
  return
452
484
  }
453
485
 
486
+ // detect 404 error signal - navigate to not-found page
487
+ // don't update state so the component stays suspended while navigating
488
+ if (result?.__oneError === 404) {
489
+ recordLoaderTiming?.({
490
+ path: currentPath,
491
+ startTime,
492
+ moduleLoadTime,
493
+ executionTime,
494
+ totalTime,
495
+ source: 'initial',
496
+ })
497
+ router.replace(result.__oneNotFoundPath || '/+not-found')
498
+ // keep component suspended until navigation unmounts it
499
+ await new Promise(() => {})
500
+ }
501
+
454
502
  updateState(currentPath, {
455
503
  data: result,
456
504
  hasLoadedOnce: true,
457
505
  promise: undefined,
458
506
  })
459
507
 
460
- recordLoaderTiming({
508
+ recordLoaderTiming?.({
461
509
  path: currentPath,
462
510
  startTime,
463
511
  moduleLoadTime,
@@ -473,7 +521,7 @@ export function useLoaderState<
473
521
  promise: undefined,
474
522
  })
475
523
 
476
- recordLoaderTiming({
524
+ recordLoaderTiming?.({
477
525
  path: currentPath,
478
526
  startTime,
479
527
  totalTime,
@@ -179,7 +179,7 @@ export async function transformTreeShakeClient(code: string, id: string) {
179
179
  // Restore any type imports that were incorrectly removed
180
180
  restoreTypeImports(ast, typeImports)
181
181
 
182
- const out = generate(ast)
182
+ const out = generate(ast, { retainLines: true })
183
183
 
184
184
  // add back in empty or filled loader and genparams
185
185
  const codeOut =
@@ -50,6 +50,34 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
50
50
 
51
51
  function createRequestHandler() {
52
52
  const routerRoot = getRouterRootFromOneOptions(options)
53
+
54
+ // find the nearest +not-found route by walking up from a route's directory
55
+ async function findNearestNotFoundPath(routeFile: string): Promise<string> {
56
+ const routeDir = routeFile.replace(/\/[^/]+$/, '')
57
+ let searchDir = routeDir
58
+ while (true) {
59
+ for (const ext of ['.tsx', '.ts', '.jsx', '.js']) {
60
+ const candidate = path.join(routerRoot, searchDir, `+not-found${ext}`)
61
+ try {
62
+ const mod = await runner.import(candidate)
63
+ if (mod?.default) {
64
+ return searchDir ? `/${searchDir}/+not-found` : '/+not-found'
65
+ }
66
+ } catch {
67
+ // not found at this level
68
+ }
69
+ }
70
+ if (!searchDir) break
71
+ const parent = searchDir.replace(/\/[^/]+$/, '')
72
+ if (parent === searchDir) {
73
+ searchDir = ''
74
+ } else {
75
+ searchDir = parent
76
+ }
77
+ }
78
+ return '/+not-found'
79
+ }
80
+
53
81
  return createHandleRequest(
54
82
  {
55
83
  async handlePage({ route, url, loaderProps }) {
@@ -214,7 +242,90 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
214
242
 
215
243
  LoaderDataCache[route.file] = loaderData
216
244
 
217
- const is404 = route.isNotFound || !getPageExport(exported)
245
+ // detect 404: not-found routes, missing page exports, or ssg dynamic routes with invalid slugs
246
+ const isDynamicRoute = Object.keys(route.routeKeys || {}).length > 0
247
+
248
+ // for ssg dynamic routes, check generateStaticParams to validate the slug
249
+ let isMissingSsgSlug = false
250
+ if (route.type === 'ssg' && isDynamicRoute && exported.generateStaticParams) {
251
+ const staticParams = await exported.generateStaticParams({
252
+ params: loaderProps?.params,
253
+ })
254
+ const currentParams = loaderProps?.params || {}
255
+ isMissingSsgSlug = !staticParams.some((sp: Record<string, string>) =>
256
+ Object.keys(sp).every((key) => sp[key] === currentParams[key])
257
+ )
258
+ }
259
+
260
+ const is404 =
261
+ route.isNotFound ||
262
+ !getPageExport(exported) ||
263
+ isMissingSsgSlug ||
264
+ (route.type === 'ssg' && isDynamicRoute && loaderData === undefined)
265
+
266
+ // for ssg dynamic routes with invalid slug, render the not-found page instead
267
+ if (
268
+ isMissingSsgSlug ||
269
+ (route.type === 'ssg' && isDynamicRoute && loaderData === undefined)
270
+ ) {
271
+ // find nearest +not-found by walking up the route's directory
272
+ let notFoundExported: any = {}
273
+ let notFoundRoutePath = '/+not-found'
274
+ const routeDir = route.file.replace(/\/[^/]+$/, '')
275
+ let searchDir = routeDir
276
+ while (true) {
277
+ for (const ext of ['.tsx', '.ts', '.jsx', '.js']) {
278
+ const candidate = path.join(routerRoot, searchDir, `+not-found${ext}`)
279
+ try {
280
+ notFoundExported = await runner.import(candidate)
281
+ if (notFoundExported?.default) {
282
+ notFoundRoutePath = searchDir
283
+ ? `/${searchDir}/+not-found`
284
+ : '/+not-found'
285
+ break
286
+ }
287
+ } catch {
288
+ // not found at this level
289
+ }
290
+ }
291
+ if (notFoundExported?.default || !searchDir) break
292
+ const parent = searchDir.replace(/\/[^/]+$/, '')
293
+ if (parent === searchDir) {
294
+ searchDir = ''
295
+ } else {
296
+ searchDir = parent
297
+ }
298
+ }
299
+
300
+ if (notFoundExported.default) {
301
+ // override the route to render the not-found page
302
+ setServerContext({
303
+ loaderData: undefined,
304
+ loaderProps,
305
+ matches: [],
306
+ })
307
+
308
+ const notFoundHtml = await render({
309
+ mode: 'ssg',
310
+ loaderData: undefined,
311
+ loaderProps,
312
+ path: notFoundRoutePath,
313
+ preloads,
314
+ matches: [],
315
+ })
316
+
317
+ return new Response(notFoundHtml, {
318
+ status: 404,
319
+ headers: { 'Content-Type': 'text/html' },
320
+ })
321
+ }
322
+
323
+ // fallback if no +not-found page exists
324
+ return new Response('<html><body><h1>404 - Not Found</h1></body></html>', {
325
+ status: 404,
326
+ headers: { 'Content-Type': 'text/html' },
327
+ })
328
+ }
218
329
 
219
330
  const html = await render({
220
331
  mode: isSpaShell
@@ -298,34 +409,64 @@ export function createFileSystemRouterPlugin(options: One.PluginOptions): Plugin
298
409
 
299
410
  const exported = await runner.import(routeFile)
300
411
 
412
+ // for ssg dynamic routes, check generateStaticParams to validate the slug
413
+ const isDynamicRoute = Object.keys(route.routeKeys || {}).length > 0
414
+ if (route.type === 'ssg' && isDynamicRoute && exported.generateStaticParams) {
415
+ const staticParams = await exported.generateStaticParams({
416
+ params: loaderProps?.params,
417
+ })
418
+ const currentParams = loaderProps?.params || {}
419
+ const isValidSlug = staticParams.some((sp: Record<string, string>) =>
420
+ Object.keys(sp).every((key) => sp[key] === currentParams[key])
421
+ )
422
+ if (!isValidSlug) {
423
+ const nfPath = await findNearestNotFoundPath(route.file)
424
+ return `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`
425
+ }
426
+ }
427
+
301
428
  // Track file dependencies from loader for hot reload
302
429
  let loaderData: any
303
430
  if (exported.loader) {
304
- const tracked = await trackLoaderDependencies(() =>
305
- exported.loader(loaderProps)
306
- )
307
- loaderData = tracked.result
431
+ try {
432
+ const tracked = await trackLoaderDependencies(() =>
433
+ exported.loader(loaderProps)
434
+ )
435
+ loaderData = tracked.result
308
436
 
309
- // if the loader returned a Response (e.g. redirect()), throw it
310
- // so it bubbles up through resolveResponse and can be transformed
311
- // into a JS redirect module for client-side navigation
312
- if (isResponse(loaderData)) {
313
- throw loaderData
314
- }
437
+ // if the loader returned a Response (e.g. redirect()), throw it
438
+ // so it bubbles up through resolveResponse and can be transformed
439
+ // into a JS redirect module for client-side navigation
440
+ if (isResponse(loaderData)) {
441
+ throw loaderData
442
+ }
315
443
 
316
- // Register dependencies: map file path -> route paths that depend on it
317
- const routePath = loaderProps?.path || '/'
318
- for (const dep of tracked.dependencies) {
319
- // Resolve to absolute path for consistent lookup when file changes
320
- const absoluteDep = path.resolve(dep)
321
- if (!loaderFileDependencies.has(absoluteDep)) {
322
- loaderFileDependencies.set(absoluteDep, new Set())
323
- server?.watcher.add(absoluteDep)
324
- if (debugLoaderDeps) {
325
- console.info(` ⓵ [loader-dep] watching: ${absoluteDep}`)
444
+ // Register dependencies: map file path -> route paths that depend on it
445
+ const routePath = loaderProps?.path || '/'
446
+ for (const dep of tracked.dependencies) {
447
+ // Resolve to absolute path for consistent lookup when file changes
448
+ const absoluteDep = path.resolve(dep)
449
+ if (!loaderFileDependencies.has(absoluteDep)) {
450
+ loaderFileDependencies.set(absoluteDep, new Set())
451
+ server?.watcher.add(absoluteDep)
452
+ if (debugLoaderDeps) {
453
+ console.info(` ⓵ [loader-dep] watching: ${absoluteDep}`)
454
+ }
326
455
  }
456
+ loaderFileDependencies.get(absoluteDep)!.add(routePath)
457
+ }
458
+ } catch (err) {
459
+ // re-throw Response errors (redirects)
460
+ if (isResponse(err)) {
461
+ throw err
462
+ }
463
+ // for file-not-found errors (e.g., missing MDX for non-existent slug),
464
+ // return a 404 signal so the client navigates to +not-found
465
+ if ((err as any)?.code === 'ENOENT') {
466
+ const nfPath = await findNearestNotFoundPath(route.file)
467
+ return `export function loader(){return{__oneError:404,__oneErrorMessage:'Not Found',__oneNotFoundPath:${JSON.stringify(nfPath)}}}`
327
468
  }
328
- loaderFileDependencies.get(absoluteDep)!.add(routePath)
469
+ throw err
329
470
  }
330
471
  }
331
472
 
@@ -15,7 +15,6 @@ interface JsxLocation {
15
15
 
16
16
  /**
17
17
  * Parse code with oxc and find all JSX opening elements.
18
- * Returns insertion points sorted by offset (descending for safe insertion).
19
18
  */
20
19
  async function findJsxElements(code: string, filename: string): Promise<JsxLocation[]> {
21
20
  const result = await parse(filename, code)
@@ -53,14 +52,12 @@ async function findJsxElements(code: string, filename: string): Promise<JsxLocat
53
52
 
54
53
  // skip Fragment and already-tagged elements
55
54
  if (tagName && tagName !== 'Fragment' && !tagName.endsWith('.Fragment')) {
56
- // Check if already has data-one-source
57
55
  const hasSourceAttr = node.attributes?.some(
58
56
  (attr: any) =>
59
57
  attr.type === 'JSXAttribute' && attr.name?.name === 'data-one-source'
60
58
  )
61
59
 
62
60
  if (!hasSourceAttr) {
63
- // Insert position is right after the tag name
64
61
  const nameEnd = node.name.end
65
62
  const loc = getLocation(node.start)
66
63
 
@@ -73,7 +70,6 @@ async function findJsxElements(code: string, filename: string): Promise<JsxLocat
73
70
  }
74
71
  }
75
72
 
76
- // Walk all child nodes in consistent order
77
73
  for (const key of Object.keys(node)) {
78
74
  if (key === 'parent') continue
79
75
  const value = node[key]
@@ -89,19 +85,15 @@ async function findJsxElements(code: string, filename: string): Promise<JsxLocat
89
85
 
90
86
  walk(result.program)
91
87
 
92
- // Sort by offset descending so we can insert from end to start without shifting positions
93
88
  return locations.sort((a, b) => b.insertOffset - a.insertOffset)
94
89
  }
95
90
 
91
+ type TransformOut = { code: string; map?: null } | undefined
92
+
96
93
  /**
97
94
  * Transforms JSX to inject data-one-source attributes using oxc-parser.
98
- * Embeds line:column directly in the attribute - safe because both SSR and client
99
- * run this same transform with enforce:'pre' on the same source.
100
95
  */
101
- async function injectSourceToJsx(
102
- code: string,
103
- id: string
104
- ): Promise<{ code: string; map?: null } | undefined> {
96
+ async function injectSourceToJsx(code: string, id: string): Promise<TransformOut> {
105
97
  const [filePath] = id.split('?')
106
98
  if (!filePath) return
107
99
 
@@ -155,9 +147,10 @@ async function openInEditor(
155
147
 
156
148
  // track connected vscode clients and browser clients
157
149
  const vscodeClients = new Set<WebSocket>()
158
- let viteServer: ViteDevServer | null = null
159
150
 
160
151
  export function sourceInspectorPlugin(): Plugin[] {
152
+ const cache = new Map<string, TransformOut>()
153
+
161
154
  return [
162
155
  // Transform plugin - injects data-one-source attributes
163
156
  {
@@ -165,7 +158,7 @@ export function sourceInspectorPlugin(): Plugin[] {
165
158
  enforce: 'pre',
166
159
  apply: 'serve',
167
160
 
168
- transform(code, id) {
161
+ async transform(code, id) {
169
162
  const envName = this.environment?.name
170
163
  // Skip native environments only - transform both client and SSR for consistency
171
164
  if (envName === 'ios' || envName === 'android') return
@@ -181,7 +174,18 @@ export function sourceInspectorPlugin(): Plugin[] {
181
174
 
182
175
  if (!id.endsWith('.jsx') && !id.endsWith('.tsx')) return
183
176
 
184
- return injectSourceToJsx(code, id)
177
+ if (cache.has(code)) {
178
+ return cache.get(code)
179
+ }
180
+
181
+ const out = await injectSourceToJsx(code, id)
182
+ cache.set(code, out)
183
+
184
+ if (cache.size > 100) {
185
+ cache.clear()
186
+ }
187
+
188
+ return out
185
189
  },
186
190
  },
187
191
 
@@ -193,8 +197,6 @@ export function sourceInspectorPlugin(): Plugin[] {
193
197
  apply: 'serve',
194
198
 
195
199
  configureServer(server: ViteDevServer) {
196
- viteServer = server
197
-
198
200
  // set up websocket server for vscode cursor position
199
201
  let wss: InstanceType<typeof import('ws').WebSocketServer> | null = null
200
202
 
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/cli/build.ts"],"names":[],"mappings":"AAwDA,wBAAsB,KAAK,CAAC,IAAI,EAAE;IAChC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,SAAS,CAAA;IACpC,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,iBAmgCA"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/cli/build.ts"],"names":[],"mappings":"AAwDA,wBAAsB,KAAK,CAAC,IAAI,EAAE;IAChC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,SAAS,CAAA;IACpC,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,iBAygCA"}