one 1.21.1 → 1.21.3

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 (158) hide show
  1. package/dist/cjs/cli/build.cjs +9 -8
  2. package/dist/cjs/cli/build.native.js +17 -16
  3. package/dist/cjs/cli/build.native.js.map +1 -1
  4. package/dist/cjs/cli/buildPage.cjs +1 -1
  5. package/dist/cjs/cli/buildPage.native.js +1 -1
  6. package/dist/cjs/cli/buildPage.native.js.map +1 -1
  7. package/dist/cjs/daemon/ipc.cjs +21 -3
  8. package/dist/cjs/daemon/ipc.native.js +21 -3
  9. package/dist/cjs/daemon/ipc.native.js.map +1 -1
  10. package/dist/cjs/router/getRoutes.cjs +1 -2
  11. package/dist/cjs/router/getRoutes.native.js +1 -2
  12. package/dist/cjs/router/getRoutes.native.js.map +1 -1
  13. package/dist/cjs/router/hmrImport.native.js +13 -2
  14. package/dist/cjs/router/hmrImport.native.js.map +1 -1
  15. package/dist/cjs/router/hmrImport.native.test.cjs +52 -0
  16. package/dist/cjs/router/hmrImport.native.test.native.js +61 -0
  17. package/dist/cjs/router/hmrImport.native.test.native.js.map +1 -0
  18. package/dist/cjs/router/matchers.cjs +7 -0
  19. package/dist/cjs/router/matchers.native.js +8 -0
  20. package/dist/cjs/router/matchers.native.js.map +1 -1
  21. package/dist/cjs/router/routeHmr.cjs +32 -0
  22. package/dist/cjs/router/routeHmr.native.js +57 -0
  23. package/dist/cjs/router/routeHmr.native.js.map +1 -0
  24. package/dist/cjs/router/routeHmr.native.test.cjs +83 -0
  25. package/dist/cjs/router/routeHmr.native.test.native.js +95 -0
  26. package/dist/cjs/router/routeHmr.native.test.native.js.map +1 -0
  27. package/dist/cjs/router/useScreens.cjs +2 -0
  28. package/dist/cjs/router/useScreens.native.js +2 -0
  29. package/dist/cjs/router/useScreens.native.js.map +1 -1
  30. package/dist/cjs/router/useViteRoutes.cjs +7 -17
  31. package/dist/cjs/router/useViteRoutes.native.js +8 -18
  32. package/dist/cjs/router/useViteRoutes.native.js.map +1 -1
  33. package/dist/cjs/router/useViteRoutes.test.cjs +42 -0
  34. package/dist/cjs/router/useViteRoutes.test.native.js +55 -0
  35. package/dist/cjs/router/useViteRoutes.test.native.js.map +1 -0
  36. package/dist/cjs/utils/cleanUrl.cjs +12 -2
  37. package/dist/cjs/utils/cleanUrl.native.js +15 -3
  38. package/dist/cjs/utils/cleanUrl.native.js.map +1 -1
  39. package/dist/cjs/utils/cleanUrl.test.cjs +37 -0
  40. package/dist/cjs/utils/cleanUrl.test.native.js +37 -0
  41. package/dist/cjs/utils/cleanUrl.test.native.js.map +1 -1
  42. package/dist/cjs/vite/plugins/virtualEntryPlugin.cjs +37 -2
  43. package/dist/cjs/vite/plugins/virtualEntryPlugin.native.js +70 -8
  44. package/dist/cjs/vite/plugins/virtualEntryPlugin.native.js.map +1 -1
  45. package/dist/cjs/vite/plugins/virtualEntryPlugin.test.cjs +58 -0
  46. package/dist/cjs/vite/plugins/virtualEntryPlugin.test.native.js +62 -0
  47. package/dist/cjs/vite/plugins/virtualEntryPlugin.test.native.js.map +1 -1
  48. package/dist/esm/cli/build.mjs +9 -8
  49. package/dist/esm/cli/build.mjs.map +1 -1
  50. package/dist/esm/cli/build.native.js +17 -16
  51. package/dist/esm/cli/build.native.js.map +1 -1
  52. package/dist/esm/cli/buildPage.mjs +2 -2
  53. package/dist/esm/cli/buildPage.mjs.map +1 -1
  54. package/dist/esm/cli/buildPage.native.js +2 -2
  55. package/dist/esm/cli/buildPage.native.js.map +1 -1
  56. package/dist/esm/daemon/ipc.mjs +21 -3
  57. package/dist/esm/daemon/ipc.mjs.map +1 -1
  58. package/dist/esm/daemon/ipc.native.js +21 -3
  59. package/dist/esm/daemon/ipc.native.js.map +1 -1
  60. package/dist/esm/getURL.native.test.mjs +1 -1
  61. package/dist/esm/router/getRoutes.mjs +2 -3
  62. package/dist/esm/router/getRoutes.mjs.map +1 -1
  63. package/dist/esm/router/getRoutes.native.js +2 -3
  64. package/dist/esm/router/getRoutes.native.js.map +1 -1
  65. package/dist/esm/router/hmrImport.native.js +13 -2
  66. package/dist/esm/router/hmrImport.native.js.map +1 -1
  67. package/dist/esm/router/hmrImport.native.test.js +51 -0
  68. package/dist/esm/router/hmrImport.native.test.js.map +6 -0
  69. package/dist/esm/router/hmrImport.native.test.mjs +53 -0
  70. package/dist/esm/router/hmrImport.native.test.mjs.map +1 -0
  71. package/dist/esm/router/hmrImport.native.test.native.js +59 -0
  72. package/dist/esm/router/hmrImport.native.test.native.js.map +1 -0
  73. package/dist/esm/router/matchers.mjs +7 -1
  74. package/dist/esm/router/matchers.mjs.map +1 -1
  75. package/dist/esm/router/matchers.native.js +8 -1
  76. package/dist/esm/router/matchers.native.js.map +1 -1
  77. package/dist/esm/router/routeHmr.mjs +6 -0
  78. package/dist/esm/router/routeHmr.mjs.map +1 -0
  79. package/dist/esm/router/routeHmr.native.js +28 -0
  80. package/dist/esm/router/routeHmr.native.js.map +1 -0
  81. package/dist/esm/router/routeHmr.native.test.js +58 -0
  82. package/dist/esm/router/routeHmr.native.test.js.map +6 -0
  83. package/dist/esm/router/routeHmr.native.test.mjs +60 -0
  84. package/dist/esm/router/routeHmr.native.test.mjs.map +1 -0
  85. package/dist/esm/router/routeHmr.native.test.native.js +69 -0
  86. package/dist/esm/router/routeHmr.native.test.native.js.map +1 -0
  87. package/dist/esm/router/useScreens.mjs +1 -0
  88. package/dist/esm/router/useScreens.mjs.map +1 -1
  89. package/dist/esm/router/useScreens.native.js +2 -0
  90. package/dist/esm/router/useScreens.native.js.map +1 -1
  91. package/dist/esm/router/useViteRoutes.mjs +7 -17
  92. package/dist/esm/router/useViteRoutes.mjs.map +1 -1
  93. package/dist/esm/router/useViteRoutes.native.js +8 -18
  94. package/dist/esm/router/useViteRoutes.native.js.map +1 -1
  95. package/dist/esm/router/useViteRoutes.test.mjs +43 -0
  96. package/dist/esm/router/useViteRoutes.test.mjs.map +1 -0
  97. package/dist/esm/router/useViteRoutes.test.native.js +53 -0
  98. package/dist/esm/router/useViteRoutes.test.native.js.map +1 -0
  99. package/dist/esm/utils/cleanUrl.mjs +11 -3
  100. package/dist/esm/utils/cleanUrl.mjs.map +1 -1
  101. package/dist/esm/utils/cleanUrl.native.js +14 -4
  102. package/dist/esm/utils/cleanUrl.native.js.map +1 -1
  103. package/dist/esm/utils/cleanUrl.test.mjs +38 -1
  104. package/dist/esm/utils/cleanUrl.test.mjs.map +1 -1
  105. package/dist/esm/utils/cleanUrl.test.native.js +38 -1
  106. package/dist/esm/utils/cleanUrl.test.native.js.map +1 -1
  107. package/dist/esm/vite/plugins/virtualEntryPlugin.mjs +26 -2
  108. package/dist/esm/vite/plugins/virtualEntryPlugin.mjs.map +1 -1
  109. package/dist/esm/vite/plugins/virtualEntryPlugin.native.js +59 -8
  110. package/dist/esm/vite/plugins/virtualEntryPlugin.native.js.map +1 -1
  111. package/dist/esm/vite/plugins/virtualEntryPlugin.test.mjs +58 -0
  112. package/dist/esm/vite/plugins/virtualEntryPlugin.test.mjs.map +1 -1
  113. package/dist/esm/vite/plugins/virtualEntryPlugin.test.native.js +62 -0
  114. package/dist/esm/vite/plugins/virtualEntryPlugin.test.native.js.map +1 -1
  115. package/package.json +9 -9
  116. package/src/cli/build.ts +18 -11
  117. package/src/cli/buildPage.ts +12 -2
  118. package/src/daemon/ipc.ts +46 -4
  119. package/src/router/getRoutes.ts +2 -3
  120. package/src/router/hmrImport.native.test.ts +55 -0
  121. package/src/router/hmrImport.native.ts +25 -5
  122. package/src/router/matchers.ts +10 -0
  123. package/src/router/routeHmr.native.test.ts +71 -0
  124. package/src/router/routeHmr.native.ts +37 -0
  125. package/src/router/routeHmr.ts +8 -0
  126. package/src/router/useScreens.tsx +12 -0
  127. package/src/router/useViteRoutes.test.ts +49 -0
  128. package/src/router/useViteRoutes.tsx +12 -21
  129. package/src/utils/cleanUrl.test.ts +63 -1
  130. package/src/utils/cleanUrl.ts +25 -3
  131. package/src/vite/plugins/virtualEntryPlugin.test.ts +70 -0
  132. package/src/vite/plugins/virtualEntryPlugin.ts +48 -2
  133. package/src/vite/types.ts +9 -0
  134. package/types/cli/build.d.ts.map +1 -1
  135. package/types/cli/buildPage.d.ts.map +1 -1
  136. package/types/daemon/ipc.d.ts.map +1 -1
  137. package/types/router/getRoutes.d.ts.map +1 -1
  138. package/types/router/hmrImport.native.d.ts +8 -3
  139. package/types/router/hmrImport.native.d.ts.map +1 -1
  140. package/types/router/hmrImport.native.test.d.ts +2 -0
  141. package/types/router/hmrImport.native.test.d.ts.map +1 -0
  142. package/types/router/matchers.d.ts +2 -0
  143. package/types/router/matchers.d.ts.map +1 -1
  144. package/types/router/routeHmr.d.ts +3 -0
  145. package/types/router/routeHmr.d.ts.map +1 -0
  146. package/types/router/routeHmr.native.d.ts +6 -0
  147. package/types/router/routeHmr.native.d.ts.map +1 -0
  148. package/types/router/routeHmr.native.test.d.ts +2 -0
  149. package/types/router/routeHmr.native.test.d.ts.map +1 -0
  150. package/types/router/useScreens.d.ts.map +1 -1
  151. package/types/router/useViteRoutes.d.ts.map +1 -1
  152. package/types/router/useViteRoutes.test.d.ts +2 -0
  153. package/types/router/useViteRoutes.test.d.ts.map +1 -0
  154. package/types/utils/cleanUrl.d.ts +2 -0
  155. package/types/utils/cleanUrl.d.ts.map +1 -1
  156. package/types/vite/plugins/virtualEntryPlugin.d.ts.map +1 -1
  157. package/types/vite/types.d.ts +8 -0
  158. package/types/vite/types.d.ts.map +1 -1
@@ -0,0 +1,55 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { hmrImport } from './hmrImport.native'
3
+
4
+ const realRuntime = (globalThis as any).__rolldown_runtime__
5
+ afterEach(() => {
6
+ ;(globalThis as any).__rolldown_runtime__ = realRuntime
7
+ vi.restoreAllMocks()
8
+ })
9
+ const setRuntime = (runtime: unknown) => {
10
+ ;(globalThis as any).__rolldown_runtime__ = runtime
11
+ }
12
+
13
+ describe('hmrImport (native)', () => {
14
+ it('rejects when no runtime is present', async () => {
15
+ setRuntime(undefined)
16
+ await expect(hmrImport('foo.tsx')).rejects.toThrow(
17
+ 'HMR import not supported on native'
18
+ )
19
+ })
20
+
21
+ it('rejects when loadExports is missing', async () => {
22
+ setRuntime({})
23
+ await expect(hmrImport('foo.tsx')).rejects.toThrow(
24
+ 'HMR import not supported on native'
25
+ )
26
+ })
27
+
28
+ it('resolves with the runtime exports and strips ./ and / prefixes', async () => {
29
+ const exports = { default: () => null }
30
+ const loadExports = vi.fn(() => exports)
31
+ setRuntime({ loadExports })
32
+
33
+ await expect(hmrImport('./foo.tsx')).resolves.toBe(exports)
34
+ await expect(hmrImport('/foo.tsx')).resolves.toBe(exports)
35
+ await expect(hmrImport('foo.tsx')).resolves.toBe(exports)
36
+ expect(loadExports).toHaveBeenNthCalledWith(1, 'foo.tsx')
37
+ expect(loadExports).toHaveBeenNthCalledWith(2, 'foo.tsx')
38
+ expect(loadExports).toHaveBeenNthCalledWith(3, 'foo.tsx')
39
+ })
40
+
41
+ it('rejects when loadExports returns null', async () => {
42
+ setRuntime({ loadExports: () => null })
43
+ await expect(hmrImport('foo.tsx')).rejects.toThrow('no exports for foo.tsx')
44
+ })
45
+
46
+ it('rejects when loadExports throws', async () => {
47
+ const boom = new Error('boom')
48
+ setRuntime({
49
+ loadExports: () => {
50
+ throw boom
51
+ },
52
+ })
53
+ await expect(hmrImport('foo.tsx')).rejects.toBe(boom)
54
+ })
55
+ })
@@ -1,8 +1,28 @@
1
1
  /**
2
- * Native stub: HMR cache-busting is not supported on native.
3
- * This avoids the dynamic import() syntax that Hermes cannot parse.
2
+ * native HMR re-import
3
+ *
4
+ * vxrn's native dev runtime re-registers the edited module into
5
+ * `globalThis.__rolldown_runtime__` before One evicts its route cache, so the
6
+ * fresh exports can be pulled straight from the runtime without dynamic `import()`
7
+ * (which Hermes cannot parse). Rejects when the runtime is unavailable so a
8
+ * broken development runtime is visible instead of serving a stale route.
4
9
  */
5
- export function hmrImport(_path: string): Promise<any> {
6
- // Return a rejected promise to fall back to normal import
7
- return Promise.reject(new Error('HMR import not supported on native'))
10
+ export function hmrImport(path: string): Promise<any> {
11
+ const runtime = (
12
+ globalThis as typeof globalThis & {
13
+ __rolldown_runtime__?: { loadExports(id: string): unknown }
14
+ }
15
+ ).__rolldown_runtime__
16
+ if (!runtime || typeof runtime.loadExports !== 'function') {
17
+ return Promise.reject(new Error('HMR import not supported on native'))
18
+ }
19
+ // route paths arrive as `./foo.tsx` / `/foo.tsx`; rolldown ids are bare (`foo.tsx`)
20
+ const id = path.replace(/^\.\//, '').replace(/^\//, '')
21
+ return Promise.resolve().then(() => {
22
+ const mod = runtime.loadExports(id)
23
+ if (mod == null) {
24
+ throw new Error(`no exports for ${id}`)
25
+ }
26
+ return mod
27
+ })
8
28
  }
@@ -169,6 +169,16 @@ export interface DirectoryRenderModeMatch {
169
169
  renderMode: One.RouteRenderMode | 'api'
170
170
  }
171
171
 
172
+ /** match file render mode suffixes: index+ssr.tsx, feed+spa.web.tsx, etc. */
173
+ export function matchFileRenderMode(
174
+ name: string
175
+ ): One.RouteRenderMode | 'api' | undefined {
176
+ const mode = name.match(/\+(api|ssg|ssr|spa)\.(?:\w+\.)?[jt]sx?$/)?.[1]
177
+ if (mode === 'api' || mode === 'ssg' || mode === 'ssr' || mode === 'spa') {
178
+ return mode
179
+ }
180
+ }
181
+
172
182
  /**
173
183
  * Match directory render mode suffixes
174
184
  *
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ type RouteHmrModule = typeof import('./routeHmr.native')
4
+
5
+ describe('routeHmr.native', () => {
6
+ let routeHmr: RouteHmrModule
7
+ const realWindow = (globalThis as any).window
8
+ const realModuleUpdatedHook = globalThis.__VXRN_ON_MODULE_UPDATED__
9
+
10
+ beforeEach(async () => {
11
+ // the __VXRN_ON_MODULE_UPDATED__ registration is dev-gated and runs at module
12
+ // load, so stub NODE_ENV then re-import to exercise it
13
+ vi.stubEnv('NODE_ENV', 'development')
14
+ vi.resetModules()
15
+ routeHmr = await import('./routeHmr.native')
16
+ })
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllEnvs()
20
+ globalThis.__VXRN_ON_MODULE_UPDATED__ = realModuleUpdatedHook
21
+ ;(globalThis as any).window = realWindow
22
+ })
23
+
24
+ it('registers globalThis.__VXRN_ON_MODULE_UPDATED__ in development', () => {
25
+ expect(typeof globalThis.__VXRN_ON_MODULE_UPDATED__).toBe('function')
26
+ })
27
+
28
+ it('bumps the epoch and notifies subscribers, and stops after unsubscribe', () => {
29
+ const before = routeHmr.getRouteHmrEpoch()
30
+ const listener = vi.fn()
31
+ const unsubscribe = routeHmr.subscribeRouteHmr(listener)
32
+
33
+ globalThis.__VXRN_ON_MODULE_UPDATED__!('app/index.tsx')
34
+ expect(routeHmr.getRouteHmrEpoch()).toBe(before + 1)
35
+ expect(listener).toHaveBeenCalledTimes(1)
36
+
37
+ unsubscribe()
38
+ globalThis.__VXRN_ON_MODULE_UPDATED__!('app/index.tsx')
39
+ expect(listener).toHaveBeenCalledTimes(1)
40
+ })
41
+
42
+ it('evicts the route cache for the updated file when window.__oneRouteCache is present', () => {
43
+ const clearFile = vi.fn()
44
+ ;(globalThis as any).window = { __oneRouteCache: { clearFile } }
45
+ globalThis.__VXRN_ON_MODULE_UPDATED__!('app/_layout.tsx')
46
+ expect(clearFile).toHaveBeenCalledWith('app/_layout.tsx')
47
+ })
48
+
49
+ it('does not throw when window / route cache is absent', () => {
50
+ ;(globalThis as any).window = undefined
51
+ expect(() => globalThis.__VXRN_ON_MODULE_UPDATED__!('x.tsx')).not.toThrow()
52
+ })
53
+
54
+ it('still notifies subscribers when route-cache eviction throws', () => {
55
+ const listener = vi.fn()
56
+ const unsubscribe = routeHmr.subscribeRouteHmr(listener)
57
+ ;(globalThis as any).window = {
58
+ __oneRouteCache: {
59
+ clearFile() {
60
+ throw new Error('cache eviction failed')
61
+ },
62
+ },
63
+ }
64
+
65
+ expect(() => globalThis.__VXRN_ON_MODULE_UPDATED__!('app/index.tsx')).toThrow(
66
+ 'cache eviction failed'
67
+ )
68
+ expect(listener).toHaveBeenCalledOnce()
69
+ unsubscribe()
70
+ })
71
+ })
@@ -0,0 +1,37 @@
1
+ // native Fast Refresh for One routes
2
+ //
3
+ // vxrn calls the global hook after committing an updated module. One evicts that
4
+ // route and bumps this external-store epoch so mounted screens load fresh exports.
5
+
6
+ declare global {
7
+ // vxrn's native HMR runtime invokes this (when defined) with each committed
8
+ // module id, so a framework can react to a hot update.
9
+ var __VXRN_ON_MODULE_UPDATED__: ((moduleId: string) => void) | undefined
10
+ }
11
+
12
+ let routeHmrEpoch = 0
13
+ const routeHmrListeners = new Set<() => void>()
14
+
15
+ export const subscribeRouteHmr = (onStoreChange: () => void) => {
16
+ routeHmrListeners.add(onStoreChange)
17
+ return () => {
18
+ routeHmrListeners.delete(onStoreChange)
19
+ }
20
+ }
21
+
22
+ export const getRouteHmrEpoch = () => routeHmrEpoch
23
+
24
+ if (process.env.NODE_ENV === 'development') {
25
+ globalThis.__VXRN_ON_MODULE_UPDATED__ = (id: string) => {
26
+ try {
27
+ const routeCache =
28
+ typeof window === 'undefined' ? undefined : (window as any).__oneRouteCache
29
+ if (typeof routeCache?.clearFile === 'function') {
30
+ routeCache.clearFile(id)
31
+ }
32
+ } finally {
33
+ routeHmrEpoch++
34
+ routeHmrListeners.forEach((listener) => listener())
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,8 @@
1
+ // web counterpart of routeHmr.native.ts. Web route Fast Refresh happens through
2
+ // the `one-hmr-update` event (see useScreens' web block), so this is a no-op store
3
+ // whose exports let useScreens import `./routeHmr` on either platform.
4
+ export const subscribeRouteHmr = (_onStoreChange: () => void): (() => void) => {
5
+ return () => {}
6
+ }
7
+
8
+ export const getRouteHmrEpoch = () => 0
@@ -28,6 +28,7 @@ import {
28
28
  import { SpaShellContext } from './SpaShellContext'
29
29
  import { NamedSlot } from '../views/Navigator'
30
30
  import { sortRoutesWithInitial } from './sortRoutes'
31
+ import { getRouteHmrEpoch, subscribeRouteHmr } from './routeHmr'
31
32
  import { getClientMatchesSnapshot } from '../useMatches'
32
33
 
33
34
  // `@react-navigation/core` does not expose the Screen or Group components directly, so we have to
@@ -395,6 +396,17 @@ export function getQualifiedRouteComponent(value: RouteNode) {
395
396
  }, [])
396
397
  }
397
398
 
399
+ // native Fast Refresh: subscribe to the route-hot epoch (the routeHmr.native
400
+ // store bumps it when vxrn reports a route module update) so this component
401
+ // re-renders and re-runs loadRoute() to pick up the edited module's exports
402
+ if (
403
+ process.env.NODE_ENV === 'development' &&
404
+ process.env.TAMAGUI_TARGET === 'native'
405
+ ) {
406
+ // eslint-disable-next-line react-hooks/rules-of-hooks
407
+ React.useSyncExternalStore(subscribeRouteHmr, getRouteHmrEpoch, getRouteHmrEpoch)
408
+ }
409
+
398
410
  // in spa-shell mode, only SSG/SSR layouts render on the server.
399
411
  // SPA layouts and leaf pages get a placeholder, swapped for real
400
412
  // content after hydration.
@@ -0,0 +1,49 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ hmrImport: vi.fn(),
5
+ }))
6
+
7
+ vi.mock('./hmrImport', () => ({
8
+ hmrImport: mocks.hmrImport,
9
+ }))
10
+
11
+ import { globbedRoutesToRouteContext } from './useViteRoutes'
12
+
13
+ const realWindow = (globalThis as any).window
14
+
15
+ afterEach(() => {
16
+ vi.unstubAllEnvs()
17
+ mocks.hmrImport.mockReset()
18
+ ;(globalThis as any).window = realWindow
19
+ })
20
+
21
+ async function resolveRoute(context: ReturnType<typeof globbedRoutesToRouteContext>) {
22
+ try {
23
+ return context('./index.tsx')
24
+ } catch (pending) {
25
+ await pending
26
+ return context('./index.tsx')
27
+ }
28
+ }
29
+
30
+ describe('globbedRoutesToRouteContext HMR', () => {
31
+ it('evicts and reloads a route under a custom router root', async () => {
32
+ vi.stubEnv('NODE_ENV', 'development')
33
+ ;(globalThis as any).window = {}
34
+ const initialRoute = { default: () => 'initial' }
35
+ const updatedRoute = { default: () => 'updated' }
36
+ const context = globbedRoutesToRouteContext(
37
+ { '/routes/index.tsx': () => Promise.resolve(initialRoute) },
38
+ 'routes'
39
+ )
40
+
41
+ await expect(resolveRoute(context)).resolves.toBe(initialRoute)
42
+
43
+ mocks.hmrImport.mockResolvedValue(updatedRoute)
44
+ ;(globalThis as any).window.__oneRouteCache.clearFile('routes/index.tsx')
45
+
46
+ await expect(resolveRoute(context)).resolves.toBe(updatedRoute)
47
+ expect(mocks.hmrImport).toHaveBeenCalledWith('/routes/index.tsx')
48
+ })
49
+ })
@@ -221,24 +221,16 @@ export function globbedRoutesToRouteContext(
221
221
  },
222
222
  clearFile: (file: string) => {
223
223
  hmrVersion++
224
- // Clear only the specific file's cache
225
- // file is like "app/_layout.tsx", need to match against keys like "./_layout.tsx"
226
- const normalizedFile = file.replace(/^app\//, './')
227
- Object.keys(loadedRoutes).forEach((key) => {
228
- if (key === normalizedFile || key.endsWith(normalizedFile.replace('./', '/'))) {
229
- delete loadedRoutes[key]
230
- }
231
- })
232
- Object.keys(promises).forEach((key) => {
233
- if (key === normalizedFile || key.endsWith(normalizedFile.replace('./', '/'))) {
234
- delete promises[key]
235
- }
236
- })
237
- Object.keys(preloadedModules).forEach((key) => {
238
- if (key.includes(file)) {
239
- delete preloadedModules[key]
240
- }
241
- })
224
+ const normalizedFile = file.replace(/\\/g, '/').replace(/^\//, '')
225
+ const routeId = moduleKeys.find(
226
+ (key) =>
227
+ routePaths[key].replace(/\\/g, '/').replace(/^\//, '') === normalizedFile
228
+ )
229
+ if (!routeId) return
230
+
231
+ delete loadedRoutes[routeId]
232
+ delete promises[routeId]
233
+ delete preloadedModules[routePaths[routeId]]
242
234
  },
243
235
  getVersion: () => hmrVersion,
244
236
  }
@@ -264,11 +256,10 @@ export function globbedRoutesToRouteContext(
264
256
  }
265
257
 
266
258
  if (!promises[id]) {
267
- // In dev mode after HMR, use cache-busting import to get fresh module
268
- // hmrImport is platform-specific: .native.ts returns rejected promise, base uses dynamic import()
259
+ // in dev mode after HMR, load the fresh module through the platform runtime
269
260
  let importPromise: Promise<any>
270
261
  if (process.env.NODE_ENV === 'development' && hmrVersion > 0 && routePaths[id]) {
271
- importPromise = hmrImport(routePaths[id]).catch(() => routesSync[id]())
262
+ importPromise = hmrImport(routePaths[id])
272
263
  } else {
273
264
  importPromise = routesSync[id]()
274
265
  }
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { getLoaderPath, getPathFromLoaderPath } from './cleanUrl'
2
+ import {
3
+ encodeReservedFilenameChars,
4
+ getLoaderPath,
5
+ getPathFromLoaderPath,
6
+ getPreloadPath,
7
+ } from './cleanUrl'
3
8
 
4
9
  /**
5
10
  * tests the cleanUrl encode/decode roundtrip used for loader URLs.
@@ -91,6 +96,63 @@ describe('getLoaderPath format', () => {
91
96
  })
92
97
  })
93
98
 
99
+ describe('NTFS-reserved characters in route-derived names', () => {
100
+ // route patterns reach these functions verbatim when a dynamic route has no
101
+ // generateStaticParams (every dynamic +ssr/+spa route at build time)
102
+ it('roundtrips a root dynamic route pattern', () => {
103
+ expect(roundtrip('/:param')).toBe('/:param')
104
+ })
105
+
106
+ it('roundtrips a nested dynamic route pattern', () => {
107
+ expect(roundtrip('/dashboard/:appId')).toBe('/dashboard/:appId')
108
+ })
109
+
110
+ it('roundtrips a catch-all pattern', () => {
111
+ expect(roundtrip('/*')).toBe('/*')
112
+ })
113
+
114
+ it('roundtrips hostile concrete param values (chars that survive URL parsing raw)', () => {
115
+ // getLoaderPath URL-parses its input; WHATWG URL percent-encodes " < > |
116
+ // in pathnames but passes : and * through raw — those two are the ones
117
+ // that reach generated filenames verbatim (and are the NTFS killers)
118
+ expect(roundtrip('/blog/a:b*c')).toBe('/blog/a:b*c')
119
+ })
120
+
121
+ it('emits filesystem-legal names for the full reserved set via preload paths', () => {
122
+ // getPreloadPath does not URL-parse, so every reserved char reaches the
123
+ // encoder raw — assert none survive into the filename
124
+ expect(getPreloadPath('/blog/a:b*c"d<e>f|g?h')).not.toMatch(/[<>:"|?*]/)
125
+ })
126
+
127
+ it('roundtrips literal equals signs (self-escape)', () => {
128
+ expect(roundtrip('/foo=bar')).toBe('/foo=bar')
129
+ })
130
+
131
+ it('roundtrips equals followed by hex-looking characters', () => {
132
+ expect(roundtrip('/x=3ay')).toBe('/x=3ay')
133
+ })
134
+
135
+ it('emits no NTFS-reserved characters in loader filenames', () => {
136
+ expect(getLoaderPath('/dashboard/:appId', false)).not.toMatch(/[<>:"|?*]/)
137
+ })
138
+
139
+ it('emits no NTFS-reserved characters in preload filenames', () => {
140
+ expect(getPreloadPath('/:param')).not.toMatch(/[<>:"|?*]/)
141
+ expect(getPreloadPath('/*')).not.toMatch(/[<>:"|?*]/)
142
+ })
143
+
144
+ it('encodeReservedFilenameChars is identity for clean paths', () => {
145
+ expect(encodeReservedFilenameChars('/blog/my-slug.html')).toBe('/blog/my-slug.html')
146
+ })
147
+
148
+ it('encodes the html artifact path for non-prerendered dynamic routes', () => {
149
+ expect(encodeReservedFilenameChars('/dashboard/:appId.html')).toBe(
150
+ '/dashboard/=3aappId.html'
151
+ )
152
+ expect(encodeReservedFilenameChars('/*.html')).toBe('/=2a.html')
153
+ })
154
+ })
155
+
94
156
  describe('getPathFromLoaderPath', () => {
95
157
  it('strips /_one/assets prefix', () => {
96
158
  expect(getPathFromLoaderPath('/_one/assets/docs_intro_999_vxrn_loader.js')).toBe(
@@ -8,9 +8,31 @@ import {
8
8
  import { getURL } from '../getURL'
9
9
  import { removeSearch } from './removeSearch'
10
10
 
11
+ // Route patterns (`/:param`, `/*`) and concrete param values flow into
12
+ // generated artifact filenames. `< > : " | ? *` are reserved on Windows
13
+ // filesystems — a leading `:` segment fails outright and a mid-name `:`
14
+ // silently writes an NTFS alternate data stream — so encode them as `=hh`
15
+ // (hex char code), with literal `=` self-escaped first so decoding is
16
+ // unambiguous. Identity for paths without reserved characters.
17
+ export function encodeReservedFilenameChars(path: string) {
18
+ return path
19
+ .replaceAll('=', '=3d')
20
+ .replace(
21
+ /[<>:"|?*]/g,
22
+ (char) => `=${char.charCodeAt(0).toString(16).padStart(2, '0')}`
23
+ )
24
+ }
25
+
26
+ export function decodeReservedFilenameChars(path: string) {
27
+ return path.replace(/=([0-9a-f]{2})/g, (_match, hexCode) =>
28
+ String.fromCharCode(Number.parseInt(hexCode, 16))
29
+ )
30
+ }
31
+
11
32
  function cleanUrl(path: string) {
12
- return removeSearch(path)
13
- .replace(/\/$/, '') // remove trailing slash before encoding
33
+ return encodeReservedFilenameChars(
34
+ removeSearch(path).replace(/\/$/, '') // remove trailing slash before encoding
35
+ )
14
36
  .replaceAll('_', '__') // escape existing underscores
15
37
  .replaceAll('/', '_') // use underscore as path separator
16
38
  }
@@ -43,7 +65,7 @@ export function getLoaderPath(
43
65
  }
44
66
 
45
67
  export function getPathFromLoaderPath(loaderPath: string) {
46
- return (
68
+ return decodeReservedFilenameChars(
47
69
  loaderPath
48
70
  .replace(LOADER_JS_POSTFIX_REGEX, '')
49
71
  .replace(/^(\/_one)?\/assets/, '')
@@ -1,4 +1,9 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
1
5
  import { describe, expect, it, vi } from 'vitest'
6
+ import { build } from 'vite'
2
7
  import { createVirtualEntry } from './virtualEntryPlugin'
3
8
 
4
9
  // mock isNativeEnvironment
@@ -22,6 +27,71 @@ function loadEntry(plugin: any, envName: string, command: 'serve' | 'build' = 'b
22
27
  }
23
28
 
24
29
  describe('virtualEntryPlugin', () => {
30
+ it('keeps spa route matching without bundling the client module on the server', async () => {
31
+ const root = mkdtempSync(join(tmpdir(), 'one-spa-server-'))
32
+ const appDir = join(root, 'app')
33
+ mkdirSync(appDir)
34
+ const spaRoute = join(appDir, 'client-only+spa.tsx')
35
+ writeFileSync(
36
+ spaRoute,
37
+ `export const browserPayload = 'must-not-enter-server-graph'
38
+ export default function ClientOnlyPage() { return null }
39
+ `
40
+ )
41
+
42
+ try {
43
+ const result = await build({
44
+ root,
45
+ configFile: false,
46
+ logLevel: 'silent',
47
+ plugins: [
48
+ {
49
+ name: 'test-one-entry',
50
+ enforce: 'pre',
51
+ resolveId(id) {
52
+ if (id === 'one') return '\0test-one-entry'
53
+ },
54
+ load(id) {
55
+ if (id === '\0test-one-entry') {
56
+ return `export const createApp = (options) => options
57
+ export const registerPreloadedRoute = () => {}`
58
+ }
59
+ },
60
+ },
61
+ createVirtualEntry({ root: 'app', flags: {} }),
62
+ ],
63
+ build: {
64
+ ssr: true,
65
+ write: false,
66
+ rolldownOptions: {
67
+ input: 'virtual:one-entry',
68
+ },
69
+ },
70
+ })
71
+ if (Array.isArray(result)) throw new Error('expected one server build output')
72
+ if (!('output' in result)) throw new Error('expected completed server build')
73
+
74
+ const moduleIds = result.output.flatMap((output) =>
75
+ output.type === 'chunk' ? output.moduleIds : []
76
+ )
77
+ expect(moduleIds).not.toContain(resolve(spaRoute))
78
+
79
+ const entry = result.output.find(
80
+ (output) => output.type === 'chunk' && output.isEntry
81
+ )
82
+ if (!entry || entry.type !== 'chunk') throw new Error('missing server entry chunk')
83
+ const built = await import(
84
+ `data:text/javascript;base64,${Buffer.from(entry.code).toString('base64')}`
85
+ )
86
+ expect(Object.keys(built.default.routes)).toContain('/app/client-only+spa.tsx')
87
+ await expect(built.default.routes['/app/client-only+spa.tsx']()).resolves.toEqual(
88
+ {}
89
+ )
90
+ } finally {
91
+ rmSync(root, { recursive: true, force: true })
92
+ }
93
+ })
94
+
25
95
  describe('setupFile', () => {
26
96
  const base = {
27
97
  root: 'app',
@@ -1,6 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { pathToFileURL } from 'node:url'
3
3
  import { configuration } from '@vxrn/compiler'
4
+ import Glob from 'fast-glob'
4
5
  import type { Plugin } from 'vite'
5
6
  import { isNativeEnvironment } from 'vxrn'
6
7
  import {
@@ -9,6 +10,7 @@ import {
9
10
  ROUTE_NATIVE_EXCLUSION_GLOB_PATTERNS,
10
11
  ROUTE_WEB_EXCLUSION_GLOB_PATTERNS,
11
12
  } from '../../router/glob-patterns'
13
+ import { matchDirectoryRenderMode, matchFileRenderMode } from '../../router/matchers'
12
14
  import type { One } from '../types'
13
15
  import {
14
16
  resolvedVirtualEntryId,
@@ -151,6 +153,47 @@ export function createVirtualEntry(options: {
151
153
  if (id === resolvedVirtualEntryId) {
152
154
  const isNative = isNativeEnvironment(this.environment)
153
155
  const isSSR = this.environment.name === 'ssr'
156
+ const serverSpaRouteFiles = isSSR
157
+ ? Glob.sync('**/*.{ts,tsx}', {
158
+ cwd: resolve(viteRoot, options.root),
159
+ ignore: [
160
+ ...ROUTE_WEB_EXCLUSION_GLOB_PATTERNS,
161
+ ...(options.router?.ignoredRouteFiles || []),
162
+ ],
163
+ })
164
+ .map((file) => file.replaceAll('\\', '/'))
165
+ .filter((file) => {
166
+ const parts = file.split('/')
167
+ const filename = parts.at(-1) || ''
168
+ if (
169
+ filename.startsWith('_layout') ||
170
+ filename.startsWith('_middleware')
171
+ ) {
172
+ return false
173
+ }
174
+
175
+ const fileMode = matchFileRenderMode(filename)
176
+ if (fileMode) return fileMode === 'spa'
177
+
178
+ let parentMode: One.RouteRenderMode | 'api' | undefined
179
+ for (const directory of parts.slice(0, -1)) {
180
+ parentMode =
181
+ matchDirectoryRenderMode(directory)?.renderMode ?? parentMode
182
+ }
183
+ return parentMode === 'spa'
184
+ })
185
+ : []
186
+ const serverSpaRouteStubs = serverSpaRouteFiles
187
+ .map(
188
+ (file) =>
189
+ `${JSON.stringify(`/${options.root}/${file}`)}: () => Promise.resolve({})`
190
+ )
191
+ .join(',\n ')
192
+ const webRouteGlobs = [
193
+ ...routeGlobs,
194
+ ...ROUTE_WEB_EXCLUSION_GLOB_PATTERNS.map((pattern) => `!${pattern}`),
195
+ ...serverSpaRouteFiles.map((file) => `!/${options.root}/${file}`),
196
+ ]
154
197
  // native always needs static import. SSR in dev mode uses static import
155
198
  // so Vite crawls the setupFile's dep tree (including react) and pre-bundles
156
199
  // them before the first request. SSR in build mode uses lazy import so the
@@ -191,10 +234,13 @@ if (typeof window !== 'undefined') {
191
234
  window.__oneRegisterPreloadedRoute = registerPreloadedRoute
192
235
  }
193
236
 
194
- // globbing ${JSON.stringify(routeGlobs)}
237
+ // globbing ${JSON.stringify(webRouteGlobs)}
195
238
  export default createApp({
196
239
  ${setupPromiseArg}
197
- routes: import.meta.glob(${JSON.stringify([...routeGlobs, ...ROUTE_WEB_EXCLUSION_GLOB_PATTERNS.map((p) => `!${p}`)])}, { exhaustive: true }),
240
+ routes: {
241
+ ...import.meta.glob(${JSON.stringify(webRouteGlobs)}, { exhaustive: true }),
242
+ ${serverSpaRouteStubs}
243
+ },
198
244
  routerRoot: ${JSON.stringify(options.root)},
199
245
  flags: ${JSON.stringify(options.flags)},
200
246
  ${linkingArg}
package/src/vite/types.ts CHANGED
@@ -599,6 +599,15 @@ export namespace One {
599
599
  server?: VXRNBuildOptions | false
600
600
  api?: VXRNBuildOptions
601
601
 
602
+ /**
603
+ * maximum number of static pages to build concurrently.
604
+ * lower this when builds share a machine with other CPU-heavy work.
605
+ * `ONE_BUILD_CONCURRENCY` overrides this value.
606
+ *
607
+ * @default Math.min(available CPU cores, 8)
608
+ */
609
+ concurrency?: number
610
+
602
611
  /**
603
612
  * Use worker threads for parallel static page generation during build.
604
613
  * This utilizes multiple CPU cores for faster builds.
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/cli/build.ts"],"names":[],"mappings":"AA0XA,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,iBA8vCA"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/cli/build.ts"],"names":[],"mappings":"AAmXA,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,iBA4wCA"}
@@ -1 +1 @@
1
- {"version":3,"file":"buildPage.d.ts","sourceRoot":"","sources":["../../src/cli/buildPage.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAkBnD,wBAAgB,iBAAiB,SAUhC;AAED,wBAAsB,SAAS,CAC7B,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,GAAG,EACX,UAAU,EAAE,SAAS,CAAC,MAAM,CAAC,EAC7B,mBAAmB,EAAE,GAAG,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACxC,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAAE,EAClB,MAAM,EAAE,MAAM,EAAE,EAChB,SAAS,EAAE,MAAM,EAAE,EACnB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACrC,cAAc,CAAC,EAAE,MAAM,EAAE,EACzB,gBAAgB,CAAC,EAAE,MAAM,EAAE,EAC3B,gBAAgB,CAAC,EAAE,MAAM,EAAE,EAC3B,WAAW,CAAC,EAAE,OAAO,EACrB,qBAAqB,CAAC,EAAE,OAAO,GAC9B,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAka7B"}
1
+ {"version":3,"file":"buildPage.d.ts","sourceRoot":"","sources":["../../src/cli/buildPage.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAkBnD,wBAAgB,iBAAiB,SAUhC;AAED,wBAAsB,SAAS,CAC7B,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,GAAG,EACX,UAAU,EAAE,SAAS,CAAC,MAAM,CAAC,EAC7B,mBAAmB,EAAE,GAAG,EACxB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACxC,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAAE,EAClB,MAAM,EAAE,MAAM,EAAE,EAChB,SAAS,EAAE,MAAM,EAAE,EACnB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACrC,cAAc,CAAC,EAAE,MAAM,EAAE,EACzB,gBAAgB,CAAC,EAAE,MAAM,EAAE,EAC3B,gBAAgB,CAAC,EAAE,MAAM,EAAE,EAC3B,WAAW,CAAC,EAAE,OAAO,EACrB,qBAAqB,CAAC,EAAE,OAAO,GAC9B,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAua7B"}