@octane-xplat/ui 0.7.2 → 0.9.0

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 (219) hide show
  1. package/README.md +4 -3
  2. package/dist/native/Button.tsrx.js +1 -1
  3. package/dist/native/CommandPalette.tsrx.js +7 -1
  4. package/dist/native/Grid.tsrx.js +31 -2
  5. package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
  6. package/dist/native/Markdown.tsrx.js +233 -0
  7. package/dist/native/Overlay.tsrx.js +15 -26
  8. package/dist/native/Pagination.tsrx.js +15 -12
  9. package/dist/native/PinInput.tsrx.js +25 -7
  10. package/dist/native/Popover.tsrx.js +33 -23
  11. package/dist/native/Pressable.tsrx.js +4 -1
  12. package/dist/native/Screen.tsrx.js +1 -1
  13. package/dist/native/SearchInput.tsrx.js +6 -3
  14. package/dist/native/Sheet.tsrx.js +23 -38
  15. package/dist/native/Slider.tsrx.js +23 -14
  16. package/dist/native/Text.tsrx.js +16 -0
  17. package/dist/native/TextArea.tsrx.js +6 -3
  18. package/dist/native/TextInput.tsrx.js +6 -3
  19. package/dist/native/VirtualList.tsrx.js +305 -121
  20. package/dist/native/WebView.tsrx.js +17 -2
  21. package/dist/native/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
  22. package/dist/native/escape-props.js +2 -0
  23. package/dist/native/grid-auto-placement.js +83 -0
  24. package/dist/native/grid-placement.js +20 -0
  25. package/dist/native/index.shared.js +7 -5
  26. package/dist/native/keyboard-inset.mobile.js +5 -5
  27. package/dist/native/navigation-request.js +26 -0
  28. package/dist/native/octane-client-build.json +1 -1
  29. package/dist/native/overlay-lifetime.mobile.js +65 -0
  30. package/dist/native/pan.tsrx.js +16 -2
  31. package/dist/native/route-table.js +90 -10
  32. package/dist/native/route.js +26 -16
  33. package/dist/native/shared.js +7 -5
  34. package/dist/native/sheet-service.js +2 -2
  35. package/dist/native/svg.mobile.js +1 -1
  36. package/dist/native/{tap-to-blur.mobile.js → tap-to-blur.ios.js} +20 -32
  37. package/dist/native/text-focus.js +10 -0
  38. package/dist/native/text-write.js +18 -11
  39. package/dist/native/toast-anchor.tsrx.js +1 -1
  40. package/dist/native/ui.js +7 -6
  41. package/dist/native/useMeasure.tsrx.js +17 -18
  42. package/dist/native/vendor/ui-svg/index.android.js +2 -2
  43. package/dist/native/vendor/ui-svg/index.ios.js +2 -2
  44. package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
  45. package/dist/native/virtual-list-cell-pool.js +63 -0
  46. package/dist/web/Button.web.tsrx.js +1 -1
  47. package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
  48. package/dist/web/Markdown.tsrx.js +98 -0
  49. package/dist/web/Overlay.web.tsrx.js +81 -32
  50. package/dist/web/PinInput.web.tsrx.js +24 -10
  51. package/dist/web/Pressable.web.tsrx.js +169 -53
  52. package/dist/web/SearchInput.web.tsrx.js +2 -2
  53. package/dist/web/Sheet.web.tsrx.js +20 -7
  54. package/dist/web/TextArea.web.tsrx.js +22 -17
  55. package/dist/web/TextInput.web.tsrx.js +2 -2
  56. package/dist/web/VirtualList.web.tsrx.js +220 -109
  57. package/dist/web/WebView.web.tsrx.js +3 -3
  58. package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
  59. package/dist/web/modal-focus.web.js +95 -0
  60. package/dist/web/navigation-request.js +26 -0
  61. package/dist/web/octane-client-build.json +1 -1
  62. package/dist/web/pan.web.tsrx.js +27 -10
  63. package/dist/web/route-table.js +90 -10
  64. package/dist/web/route.web.js +88 -22
  65. package/dist/web/sheet-service.web.js +8 -0
  66. package/dist/web/theme/colorScheme.web.js +31 -4
  67. package/dist/web/ui.js +8 -6
  68. package/dist/web/virtual-list-cell-pool.js +63 -0
  69. package/package.json +21 -19
  70. package/platforms/android/native-api-usage.json +7 -7
  71. package/src/CommandPalette.tsrx +8 -1
  72. package/src/Grid.tsrx +32 -1
  73. package/src/Icon.macos.tsrx +1 -0
  74. package/src/KeyboardAvoiding.macos.tsrx +7 -1
  75. package/src/KeyboardAvoiding.mobile.tsrx +2 -2
  76. package/src/KeyboardAvoiding.tsrx +15 -0
  77. package/src/KeyboardAvoiding.web.tsrx +15 -0
  78. package/src/Markdown.tsrx +121 -0
  79. package/src/Meter.tsrx +1 -0
  80. package/src/Overlay.tsrx +15 -30
  81. package/src/Overlay.web.tsrx +12 -3
  82. package/src/Pagination.tsrx +6 -4
  83. package/src/PinInput.macos.tsrx +20 -6
  84. package/src/PinInput.mobile.test.ts +107 -0
  85. package/src/PinInput.tsrx +20 -6
  86. package/src/PinInput.web.test.tsrx +42 -0
  87. package/src/PinInput.web.tsrx +20 -6
  88. package/src/Popover.tsrx +36 -22
  89. package/src/Pressable.tsrx +1 -1
  90. package/src/Pressable.web.test.tsrx +36 -0
  91. package/src/Pressable.web.tsrx +37 -6
  92. package/src/RouteHost.macos.tsrx +1 -0
  93. package/src/Screen.tsrx +1 -1
  94. package/src/SearchInput.tsrx +4 -3
  95. package/src/SearchInput.web.tsrx +1 -1
  96. package/src/Sheet.tsrx +21 -42
  97. package/src/Sheet.web.tsrx +9 -1
  98. package/src/Slider.tsrx +13 -11
  99. package/src/Text.tsrx +36 -0
  100. package/src/TextArea.tsrx +6 -5
  101. package/src/TextArea.web.test.tsrx +39 -0
  102. package/src/TextArea.web.tsrx +7 -5
  103. package/src/TextInput.tsrx +6 -6
  104. package/src/TextInput.web.tsrx +1 -1
  105. package/src/VirtualList.macos.tsrx +93 -6
  106. package/src/VirtualList.mobile.test.tsrx +232 -0
  107. package/src/VirtualList.tsrx +285 -81
  108. package/src/VirtualList.web.tsrx +131 -25
  109. package/src/WebView.tsrx +20 -10
  110. package/src/WebView.web.tsrx +3 -0
  111. package/src/android/BottomNavigationView.android.tsrx +1 -0
  112. package/src/android/MaterialDialog.android.tsrx +1 -0
  113. package/src/anim.macos.tsrx +1 -0
  114. package/src/apply-native-modifiers.native.ts +4 -1
  115. package/src/child-array.ts +12 -7
  116. package/src/colorScheme.macos.ts +1 -0
  117. package/src/controls.macos.tsrx +4 -1
  118. package/src/data-driver.mobile.test.ts +120 -0
  119. package/src/data-lifecycle.mobile.test.ts +251 -0
  120. package/src/escape-props.ts +13 -1
  121. package/src/grid-auto-placement.ts +163 -0
  122. package/src/grid-placement.ts +29 -0
  123. package/src/hosted-unsupported.macos.tsrx +1 -0
  124. package/src/index.macos.ts +23 -3
  125. package/src/index.mobile.ts +0 -1
  126. package/src/index.shared.ts +22 -1
  127. package/src/index.shared.web.ts +291 -0
  128. package/src/index.web.ts +1 -1
  129. package/src/input-readiness.web.test.tsrx +82 -0
  130. package/src/ios/UITabBar.ios.tsrx +1 -0
  131. package/src/keyboard-inset.mobile.test.ts +62 -0
  132. package/src/keyboard-inset.mobile.ts +63 -43
  133. package/src/layout-shells.macos.tsrx +1 -5
  134. package/src/macos-extras.macos.tsrx +2 -0
  135. package/src/modal-focus.web.test.tsrx +63 -0
  136. package/src/modal-focus.web.ts +162 -0
  137. package/src/navigation-request.ts +37 -0
  138. package/src/overlay-lifecycle.mobile.test.ts +316 -0
  139. package/src/overlay-lifetime.mobile.ts +107 -0
  140. package/src/pan.mobile.test.tsrx +116 -0
  141. package/src/pan.tsrx +20 -2
  142. package/src/pan.web.tsrx +35 -7
  143. package/src/platformProps.ts +1 -0
  144. package/src/popover.mobile.test.ts +9 -1
  145. package/src/pressable-accessibility.mobile.test.ts +30 -0
  146. package/src/props.ts +163 -3
  147. package/src/refresh.ts +6 -1
  148. package/src/refresh.web.test.ts +77 -77
  149. package/src/route-android.test.ts +162 -0
  150. package/src/route-history.web.test.ts +80 -0
  151. package/src/route-navigation.web.test.ts +173 -0
  152. package/src/route-table.test.ts +188 -9
  153. package/src/route-table.ts +138 -10
  154. package/src/route.macos.ts +56 -13
  155. package/src/route.ts +54 -24
  156. package/src/route.web.ts +141 -24
  157. package/src/sheet-detents.ts +5 -3
  158. package/src/sheet-service.macos.ts +5 -1
  159. package/src/sheet-service.ts +1 -1
  160. package/src/sheet-service.web.ts +8 -0
  161. package/src/sheet-snap.test.ts +23 -23
  162. package/src/sheet-snap.ts +6 -1
  163. package/src/svg.mobile.ts +9 -3
  164. package/src/tap-to-blur.ios.ts +106 -0
  165. package/src/tap-to-blur.mobile.ts +21 -95
  166. package/src/text-focus.ts +12 -0
  167. package/src/text-input.mobile.test.ts +89 -0
  168. package/src/text-write.ts +21 -11
  169. package/src/theme/colorScheme.linux.ts +2 -4
  170. package/src/theme/colorScheme.web.ts +51 -4
  171. package/src/theme/theme-scheme.macos.ts +8 -2
  172. package/src/theme/tokens.css +1740 -1718
  173. package/src/translate.macos.ts +4 -1
  174. package/src/useMeasure.tsrx +20 -21
  175. package/src/virtual-list-cell-pool.test.ts +53 -0
  176. package/src/virtual-list-cell-pool.ts +81 -0
  177. package/src/virtual-list-layout.test.ts +11 -8
  178. package/src/virtual-list-layout.ts +4 -8
  179. package/src/windows.linux.ts +7 -2
  180. package/src/windows.macos.ts +1 -0
  181. package/types/generated/.tsrx-typegen-manifest.json +16 -3
  182. package/types/generated/Grid.d.ts +0 -1
  183. package/types/generated/KeyboardAvoiding.d.ts +3 -0
  184. package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
  185. package/types/generated/Markdown.d.ts +59 -0
  186. package/types/generated/TextArea.d.ts +2 -2
  187. package/types/generated/TextArea.web.d.ts +1 -1
  188. package/types/generated/TextInput.d.ts +2 -3
  189. package/types/generated/VirtualList.d.ts +3 -3
  190. package/types/generated/escape-props.d.ts +4 -0
  191. package/types/generated/grid-auto-placement.d.ts +13 -0
  192. package/types/generated/grid-placement.d.ts +5 -0
  193. package/types/generated/index.mobile.d.ts +0 -1
  194. package/types/generated/index.shared.d.ts +6 -3
  195. package/types/generated/index.shared.web.d.ts +114 -0
  196. package/types/generated/index.web.d.ts +1 -1
  197. package/types/generated/modal-focus.web.d.ts +3 -0
  198. package/types/generated/navigation-request.d.ts +10 -0
  199. package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
  200. package/types/generated/props.d.ts +111 -3
  201. package/types/generated/route-table.d.ts +14 -3
  202. package/types/generated/svg.mobile.d.ts +2 -2
  203. package/types/generated/tap-to-blur.ios.d.ts +13 -0
  204. package/types/generated/tap-to-blur.mobile.d.ts +1 -15
  205. package/types/generated/text-focus.d.ts +3 -0
  206. package/types/generated/text-write.d.ts +3 -1
  207. package/types/generated/theme/colorScheme.web.d.ts +5 -0
  208. package/types/generated/virtual-list-cell-pool.d.ts +18 -0
  209. package/types/index.macos.d.ts +15 -3
  210. /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
  211. /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
  212. /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
  213. /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
  214. /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
  215. /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
  216. /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
  217. /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
  218. /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
  219. /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
@@ -1,10 +1,18 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
2
+ import type {
3
+ ManifestRouteNames,
4
+ ManifestRouteParams,
5
+ ManifestRoutePresentations,
6
+ RouteManifest,
7
+ } from './props'
8
+
2
9
  import {
3
10
  buildRoutePath,
4
11
  defineRoutes,
5
12
  deriveRouteManifest,
6
13
  layoutChain,
7
14
  linkPath,
15
+ manifestToJson,
8
16
  matchRoute,
9
17
  matchUrl,
10
18
  mergeRouteManifests,
@@ -201,6 +209,17 @@ describe('matchUrl + linkPath', () => {
201
209
  expect(linkPath('textcoral://demo/9')).toBe('/demo/9')
202
210
  expect(linkPath('/demo/9')).toBe('/demo/9')
203
211
  })
212
+
213
+ it.each(['/demo/%', '/demo/%E0%A4%A', '/detail?from=%', '/detail?%=x'])(
214
+ 'rejects malformed encoding without throwing: %s',
215
+ (url) => expect(matchUrl(routes, url)).toBeNull(),
216
+ )
217
+
218
+ it('ignores fragments and preserves query delimiters inside values', () => {
219
+ expect(matchUrl(routes, linkPath('xplat://demo/counter?from=a?b#ignored'))).toMatchObject({
220
+ params: { id: 'counter', from: 'a?b' },
221
+ })
222
+ })
204
223
  })
205
224
 
206
225
  describe('layoutChain', () => {
@@ -278,15 +297,176 @@ describe('defineRoutes', () => {
278
297
 
279
298
  expect(m.layouts.guides.displayName).toBe('GuideShell')
280
299
  expect(m.layouts[''].displayName).toBe('RootShell')
281
- expect(layoutChain(m.layouts, 'guides/:slug').map((c) => c.displayName)).toEqual([
282
- 'GuideShell',
283
- ])
300
+ expect(layoutChain(m.layouts, 'guides/:slug').map((c) => c.displayName)).toEqual(['GuideShell'])
284
301
  })
285
302
 
286
303
  it('warns and skips a non-component screen', () => {
287
304
  const m = defineRoutes([{ path: 'bad', screen: { default: C('B') } as any }])
288
305
  expect(m.screens.bad).toBeUndefined()
289
306
  })
307
+
308
+ it('brands literal route names, params, and presentations onto the manifest type', () => {
309
+ const m = defineRoutes({
310
+ routes: [
311
+ { path: 'guides', screen: C('GuideIndex') },
312
+ { path: 'guides/:slug', screen: C('Guide'), presentation: 'modal' },
313
+ { path: 'docs/[id]', screen: C('Doc') },
314
+ { path: 'settings/index', screen: C('Settings') },
315
+ { path: '', screen: C('Root') },
316
+ ],
317
+ })
318
+
319
+ expectTypeOf<ManifestRouteNames<typeof m>>().toEqualTypeOf<
320
+ 'guides' | 'guides/:slug' | 'docs/:id' | 'settings' | 'index'
321
+ >()
322
+
323
+ expectTypeOf<ManifestRouteParams<typeof m>['guides/:slug']>().toEqualTypeOf<{
324
+ slug: string
325
+ }>()
326
+
327
+ expectTypeOf<ManifestRouteParams<typeof m>['docs/:id']>().toEqualTypeOf<{ id: string }>()
328
+ expectTypeOf<ManifestRouteParams<typeof m>['guides']>().toEqualTypeOf<{}>()
329
+ expectTypeOf<ManifestRoutePresentations<typeof m>['guides/:slug']>().toEqualTypeOf<'modal'>()
330
+
331
+ // Still a plain RouteManifest — registerRoutes/matchRoute accept it.
332
+ expectTypeOf(m).toMatchTypeOf<RouteManifest>()
333
+ })
334
+
335
+ it('spec parity — a spec produces the same meta its file would', () => {
336
+ const loader = (p: Record<string, unknown>) => p
337
+ const beforeLoad = () => ({ ok: true })
338
+ const head = { title: 'Guide' }
339
+
340
+ const file = manifest({
341
+ './app/guides/[slug]+modal.tsrx': {
342
+ G: C('Guide'),
343
+ loader,
344
+ beforeLoad,
345
+ head,
346
+ },
347
+ })
348
+
349
+ const spec = defineRoutes([
350
+ { path: 'guides/:slug', screen: C('Guide'), presentation: 'modal', loader, beforeLoad, head },
351
+ ])
352
+
353
+ const fm = file.routes.find((r) => r.name === 'guides/:slug')!
354
+ const sm = spec.routes.find((r) => r.name === 'guides/:slug')!
355
+ expect({ ...sm, file: undefined }).toEqual({ ...fm, file: undefined })
356
+ expect(spec.loaders!['guides/:slug']).toBe(loader)
357
+ })
358
+
359
+ it('baked routes resolve data from the manifest baked map, not a loader call', async () => {
360
+ const m = defineRoutes({
361
+ routes: [
362
+ { path: 'changelog', screen: C('Changelog'), dataMode: 'baked' },
363
+ { path: 'live', screen: C('Live'), loader: () => 'live-data' },
364
+ ],
365
+ baked: { changelog: { entries: ['a', 'b'] } },
366
+ })
367
+
368
+ const baked = m.routes.find((r) => r.name === 'changelog')!
369
+ expect(baked.dataMode).toBe('baked')
370
+ expect(await m.loaders!.changelog({})).toEqual({ entries: ['a', 'b'] })
371
+
372
+ const live = m.routes.find((r) => r.name === 'live')!
373
+ expect(live.dataMode).toBeUndefined()
374
+ expect(await m.loaders!.live({})).toBe('live-data')
375
+ })
376
+
377
+ it('a baked route with no baked entry throws like a loader failure', () => {
378
+ const m = defineRoutes([{ path: 'gone', screen: C('Gone'), dataMode: 'baked' }])
379
+ // commitRoute wraps the call in Promise.resolve().then() — a sync
380
+ // throw becomes a rejection there, landing as the screen's error prop.
381
+ expect(() => m.loaders!.gone({})).toThrow(/no baked data/)
382
+ })
383
+
384
+ it('baked reads the owning manifest through merges', async () => {
385
+ const prog = defineRoutes({
386
+ routes: [{ path: 'docs', screen: C('Docs'), dataMode: 'baked' }],
387
+ baked: { docs: { pages: 3 } },
388
+ })
389
+
390
+ const merged = mergeRouteManifests(
391
+ manifest({ './app/detail.tsrx': { D: C('FileDetail') } }),
392
+ prog,
393
+ )
394
+
395
+ expect(await merged.loaders!.docs({})).toEqual({ pages: 3 })
396
+ expect(merged.baked).toEqual({ docs: { pages: 3 } })
397
+ })
398
+
399
+ it('manifestToJson emits the host schema identically for files and specs', () => {
400
+ const loader = () => 'data'
401
+ const head = { title: 'Guide' }
402
+ const file = manifest({
403
+ './app/_layout.tsrx': { L: C('Shell') },
404
+ './app/guides/_layout.tsrx': { G: C('GuidesShell') },
405
+ './app/guides/[slug]+modal.tsrx': {
406
+ G: C('Guide'),
407
+ loader,
408
+ head,
409
+ dataMode: 'baked',
410
+ },
411
+ })
412
+
413
+ file.baked = { 'guides/:slug': { ok: true } }
414
+
415
+ const spec = defineRoutes({
416
+ routes: [
417
+ {
418
+ path: 'guides/:slug',
419
+ screen: C('Guide'),
420
+ presentation: 'modal',
421
+ loader,
422
+ head,
423
+ dataMode: 'baked',
424
+ source: './app/guides/[slug]+modal.tsrx',
425
+ },
426
+ ],
427
+ layouts: { '': C('Shell'), guides: C('GuidesShell') },
428
+ baked: { 'guides/:slug': { ok: true } },
429
+ })
430
+
431
+ const fj = manifestToJson(file).routes[0]
432
+ const sj = manifestToJson(spec).routes[0]
433
+ expect(sj).toEqual(fj)
434
+ expect(fj).toEqual({
435
+ name: 'guides/:slug',
436
+ path: 'guides/:slug',
437
+ params: ['slug'],
438
+ presentation: 'modal',
439
+ dataMode: 'baked',
440
+ layouts: ['', 'guides'],
441
+ source: './app/guides/[slug]+modal.tsrx',
442
+ loader: true,
443
+ guard: false,
444
+ head: true,
445
+ })
446
+
447
+ expect(manifestToJson(spec).layouts).toEqual(['', 'guides'])
448
+ })
449
+
450
+ it('file routes honor dataMode and warn on an in-file loader', () => {
451
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
452
+ const loader = () => 'live'
453
+ const m = manifest({
454
+ './app/report.tsrx': { R: C('Report'), dataMode: 'baked', loader },
455
+ './app/report.loader.ts': { loader }, // never a route itself
456
+ })
457
+
458
+ const meta = m.routes.find((r) => r.name === 'report')!
459
+ expect(meta.dataMode).toBe('baked')
460
+ expect(meta.loader).not.toBe(loader) // baked lookup, not the file's
461
+ expect(m.routes.map((r) => r.name)).toEqual(['report'])
462
+ warn.mockRestore()
463
+ })
464
+
465
+ it('unbranded manifests extract to never/empty', () => {
466
+ const m = manifest({ './app/detail.tsrx': { D: C('D') } })
467
+ expectTypeOf<ManifestRouteNames<typeof m>>().toEqualTypeOf<never>()
468
+ expectTypeOf<ManifestRouteParams<typeof m>>().toEqualTypeOf<{}>()
469
+ })
290
470
  })
291
471
 
292
472
  describe('mergeRouteManifests', () => {
@@ -301,13 +481,11 @@ describe('mergeRouteManifests', () => {
301
481
  routes: [{ path: 'guides/:slug', screen: C('Guide') }],
302
482
  layouts: { docs: C('DocsShell') },
303
483
  })
484
+
304
485
  const merged = mergeRouteManifests(files, dynamic)
305
486
 
306
- expect(Object.keys(merged.screens).sort()).toEqual([
307
- 'demo/:id',
308
- 'detail',
309
- 'guides/:slug',
310
- ])
487
+ expect(Object.keys(merged.screens).sort()).toEqual(['demo/:id', 'detail', 'guides/:slug'])
488
+
311
489
  expect(merged.layouts.guides.displayName).toBe('FileLayout')
312
490
  expect(merged.layouts.docs.displayName).toBe('DocsShell')
313
491
  // Sort order is rebuilt — 'demo/:id' still loses to nothing, but a
@@ -320,6 +498,7 @@ describe('mergeRouteManifests', () => {
320
498
  const file = manifest({
321
499
  './app/detail.tsrx': { D: C('FileDetail'), loader: fileLoader },
322
500
  })
501
+
323
502
  const dynamic = defineRoutes([{ path: 'detail', screen: C('DynDetail') }])
324
503
  const merged = mergeRouteManifests(file, dynamic)
325
504
 
@@ -30,14 +30,43 @@
30
30
  * fallback (rank = prefer.length). Web callers pass ['web']; mobile
31
31
  * callers pass the running OS first. */
32
32
 
33
- import type { Route, RouteManifest, RouteMeta, RouteSpec, RouteSpecSet } from './props'
33
+ import type {
34
+ Route,
35
+ RouteManifest,
36
+ RouteManifestJson,
37
+ RouteMeta,
38
+ RouteSpec,
39
+ RouteSpecSet,
40
+ SpecRouteInfo,
41
+ } from './props'
34
42
 
35
43
  const EXT = /\.(tsrx|tsx|ts|mts|cts|js|mjs|cjs|jsx)$/
36
44
  const SUFFIX = /\.(web|mobile|ios|android|macos|windows|linux)$/
37
45
  const PARAM = /^\[(.+)\]$/
46
+ /** Build-time loader modules — `docs.loader.ts` pairs with route `docs`
47
+ * for `dataMode: 'baked'`. Never a route itself, never in a runtime glob. */
48
+ const LOADER_FILE = /\.loader\.(ts|mts|cts|js|mjs|cjs|jsx)$/
38
49
  // `settings+modal.tsrx` → route 'settings' presented modally by default.
39
50
  const PRESENT = /\+(modal|fade|push)$/
40
51
 
52
+ /** Loader for a `dataMode: 'baked'` route — resolves the name out of its
53
+ * owning manifest's `baked` map at push time. The map is assigned after
54
+ * the manifest is built (generated glue) or passed in (RouteSpecSet), so
55
+ * the lookup reads it lazily. A missing entry throws, landing as the
56
+ * screen's `error` prop like a live loader rejection. */
57
+ function bakedLookup(manifest: () => RouteManifest | undefined, name: string) {
58
+ return () => {
59
+ const baked = manifest()?.baked
60
+ if (baked && Object.prototype.hasOwnProperty.call(baked, name)) {
61
+ return baked[name]
62
+ }
63
+
64
+ throw new Error(
65
+ `[octane-xplat] baked route '${name}' has no baked data — run \`xplat routes\` to regenerate`,
66
+ )
67
+ }
68
+ }
69
+
41
70
  /** Internal error carrier used by the public `redirect()` helper. Keeping
42
71
  * this in the shared route table gives both platform leaves the same guard
43
72
  * behavior without importing a platform runtime. */
@@ -93,8 +122,13 @@ export function deriveRouteManifest(
93
122
  const layouts: Record<string, any> = {}
94
123
  const loaders: RouteManifest['loaders'] = {}
95
124
  const warned = new Set<string>()
125
+ const self: { m?: RouteManifest } = {}
96
126
 
97
127
  for (const key of Object.keys(files).sort()) {
128
+ if (LOADER_FILE.test(key)) {
129
+ continue
130
+ }
131
+
98
132
  let rel = key.replace(/^\.?\//, '')
99
133
  if (rel.startsWith(prefix)) {
100
134
  rel = rel.slice(prefix.length)
@@ -155,8 +189,20 @@ export function deriveRouteManifest(
155
189
  presentation,
156
190
  }
157
191
 
192
+ const dataMode = files[key]?.dataMode
193
+ if (dataMode === 'baked' || dataMode === 'live') {
194
+ meta.dataMode = dataMode
195
+ }
196
+
158
197
  const loader = files[key]?.loader
159
- if (typeof loader === 'function') {
198
+ if (meta.dataMode === 'baked') {
199
+ meta.loader = bakedLookup(() => self.m, name)
200
+ if (typeof loader === 'function') {
201
+ console.warn(
202
+ `[octane-xplat] baked route '${name}' also exports a loader — move it to a .loader.ts sibling so its imports stay out of the bundle`,
203
+ )
204
+ }
205
+ } else if (typeof loader === 'function') {
160
206
  meta.loader = loader
161
207
  }
162
208
 
@@ -203,7 +249,9 @@ export function deriveRouteManifest(
203
249
  // Most-specific patterns first — 'demo/new' must beat 'demo/:id'.
204
250
  routes.sort(bySpecificity)
205
251
 
206
- return { screens, routes, layouts, loaders }
252
+ const manifest = { screens, routes, layouts, loaders }
253
+ self.m = manifest
254
+ return manifest
207
255
  }
208
256
 
209
257
  /** Match-order comparator: static segments (2) outrank params (1), ties
@@ -242,8 +290,15 @@ const mergeWarned = new Set<string>()
242
290
  * from their own sources, e.g. `readdirSync` over a content dir). Each
243
291
  * spec's `path` uses the route-dir vocabulary; `layouts` keys are path
244
292
  * prefixes playing the `_layout` role. Same-name duplicates inside the
245
- * set keep the later entry with a warn, matching merge precedence. */
246
- export function defineRoutes(input: readonly RouteSpec[] | RouteSpecSet): RouteManifest {
293
+ * set keep the later entry with a warn, matching merge precedence.
294
+ *
295
+ * The return is branded with the spec-derived route types — read them
296
+ * with `ManifestRouteParams`/`ManifestRouteNames`/
297
+ * `ManifestRoutePresentations` to merge programmatic names into the
298
+ * app's typed navigation surface (`RouteName | …`, `RouteParams & …`). */
299
+ export function defineRoutes<const Specs extends readonly RouteSpec[]>(
300
+ input: Specs | (Omit<RouteSpecSet, 'routes'> & { routes: Specs }),
301
+ ): RouteManifest & SpecRouteInfo<Specs> {
247
302
  const set = (Array.isArray(input) ? { routes: input } : input) as RouteSpecSet
248
303
  const specs = set.routes
249
304
  const extra = set.layouts
@@ -252,6 +307,7 @@ export function defineRoutes(input: readonly RouteSpec[] | RouteSpecSet): RouteM
252
307
  const layouts: RouteManifest['layouts'] = {}
253
308
  const metas = new Map<string, RouteMeta>()
254
309
  const warned = new Set<string>()
310
+ const self: { m?: RouteManifest } = {}
255
311
 
256
312
  for (const [key, layout] of Object.entries(extra ?? {})) {
257
313
  const dir = key.replace(/^\/+|\/+$/g, '')
@@ -273,7 +329,18 @@ export function defineRoutes(input: readonly RouteSpec[] | RouteSpecSet): RouteM
273
329
  presentation: spec.presentation,
274
330
  }
275
331
 
276
- if (spec.loader) {
332
+ if (spec.dataMode) {
333
+ meta.dataMode = spec.dataMode
334
+ }
335
+
336
+ if (spec.dataMode === 'baked') {
337
+ meta.loader = bakedLookup(() => self.m, name)
338
+ if (spec.loader) {
339
+ console.warn(
340
+ `[octane-xplat] baked route '${name}' also carries a loader — bake it in the host and pass the result via 'baked' so its code stays out of the bundle`,
341
+ )
342
+ }
343
+ } else if (spec.loader) {
277
344
  meta.loader = spec.loader
278
345
  }
279
346
 
@@ -296,6 +363,7 @@ export function defineRoutes(input: readonly RouteSpec[] | RouteSpecSet): RouteM
296
363
  console.warn(
297
364
  `[octane-xplat] route '${name}' has no component — 'screen' must be a component function, not a module`,
298
365
  )
366
+
299
367
  continue
300
368
  }
301
369
 
@@ -311,7 +379,9 @@ export function defineRoutes(input: readonly RouteSpec[] | RouteSpecSet): RouteM
311
379
  }
312
380
  }
313
381
 
314
- return { screens, routes, layouts, loaders }
382
+ const manifest = { screens, routes, layouts, loaders, baked: set.baked }
383
+ self.m = manifest
384
+ return manifest as RouteManifest & SpecRouteInfo<Specs>
315
385
  }
316
386
 
317
387
  /** Compose manifests into one — file-derived base plus any number of
@@ -323,10 +393,12 @@ export function defineRoutes(input: readonly RouteSpec[] | RouteSpecSet): RouteM
323
393
  export function mergeRouteManifests(...manifests: RouteManifest[]): RouteManifest {
324
394
  const screens: RouteManifest['screens'] = {}
325
395
  const layouts: RouteManifest['layouts'] = {}
396
+ const baked: NonNullable<RouteManifest['baked']> = {}
326
397
  const winners = new Map<string, RouteMeta>()
327
398
 
328
399
  for (const m of manifests) {
329
400
  Object.assign(layouts, m.layouts)
401
+ Object.assign(baked, m.baked)
330
402
  // Screen-table entries without a RouteMeta still register — a screen
331
403
  // is pushable by name even without a URL pattern.
332
404
  Object.assign(screens, m.screens)
@@ -351,7 +423,41 @@ export function mergeRouteManifests(...manifests: RouteManifest[]): RouteManifes
351
423
  }
352
424
  }
353
425
 
354
- return { screens, routes, layouts, loaders }
426
+ return { screens, routes, layouts, loaders, baked }
427
+ }
428
+
429
+ /** Serialize a manifest to the normalized `RouteManifestJson` shape — the
430
+ * same list `xplat routes` emits for file routes, produced here for
431
+ * programmatic/merged manifests so hosts consume one schema. */
432
+ export function manifestToJson(manifest: RouteManifest): RouteManifestJson {
433
+ return {
434
+ version: 1,
435
+ layouts: Object.keys(manifest.layouts).sort(),
436
+ screens: Object.keys(manifest.screens).sort(),
437
+ routes: manifest.routes.map((meta) => {
438
+ const segs = meta.name.split('/')
439
+ const layoutDirs: string[] = manifest.layouts[''] ? [''] : []
440
+ for (let i = 1; i < segs.length; i++) {
441
+ const d = segs.slice(0, i).join('/')
442
+ if (manifest.layouts[d]) {
443
+ layoutDirs.push(d)
444
+ }
445
+ }
446
+
447
+ return {
448
+ name: meta.name,
449
+ path: meta.name === 'index' ? '' : meta.segments.join('/'),
450
+ params: [...meta.params],
451
+ ...(meta.presentation ? { presentation: meta.presentation } : {}),
452
+ ...(meta.dataMode ? { dataMode: meta.dataMode } : {}),
453
+ layouts: layoutDirs,
454
+ source: meta.file,
455
+ loader: !!meta.loader,
456
+ guard: !!meta.beforeLoad,
457
+ head: !!meta.head,
458
+ }
459
+ }),
460
+ }
355
461
  }
356
462
 
357
463
  /** Match URL path segments against a manifest — returns the winning meta
@@ -508,9 +614,31 @@ export function linkPath(url: string): string {
508
614
  * is the stack prefix ('/demos/demo/x' → stack 'demos'). Unmatched names
509
615
  * fall through as literal routes for pre-manifest callers. */
510
616
  export function matchUrl(routes: readonly RouteMeta[], url: string): Route | null {
511
- const [p, qs] = url.split('?')
617
+ // Fragments aren't route params. Split only the first query delimiter;
618
+ // a later '?' belongs to its value. Reject malformed percent encoding at
619
+ // this external-input boundary instead of crashing the mounted app.
620
+ url = url.split('#', 1)[0]
621
+ const at = url.indexOf('?')
622
+ const p = at === -1 ? url : url.slice(0, at)
623
+ const qs = at === -1 ? undefined : url.slice(at + 1)
512
624
  const segs = p.split('/').filter(Boolean)
513
- const query = parseQueryString(qs)
625
+ let query: Record<string, unknown>
626
+ try {
627
+ // Use the decoded value: production optimizers can drop an unused
628
+ // builtin call even though malformed input makes it throw.
629
+ if (!decodeURIComponent(p)) {
630
+ return null
631
+ }
632
+
633
+ query = parseQueryString(qs)
634
+ } catch (error) {
635
+ if (error instanceof URIError) {
636
+ return null
637
+ }
638
+
639
+ throw error
640
+ }
641
+
514
642
  if (!segs.length) {
515
643
  return null
516
644
  }
@@ -1,3 +1,4 @@
1
+ import { createNavigationRequests, type NavigationRequest } from './navigation-request'
1
2
  import { useSyncExternalStore } from 'octane'
2
3
  import type { Route, RouteHead, RouteManifest, RouteMeta, ScreenTable } from './props'
3
4
  import {
@@ -8,6 +9,8 @@ import {
8
9
  RouteRedirect,
9
10
  } from './route-table'
10
11
 
12
+ const navigationRequests = createNavigationRequests()
13
+
11
14
  // Effective tables = the file-derived base manifest merged under every
12
15
  // addRoutes layer, matching the web/native leaves' registry split.
13
16
  let baseManifest: RouteManifest = { screens: {}, routes: [], layouts: {} }
@@ -107,25 +110,49 @@ function contextFor(route: Route): Record<string, unknown> {
107
110
  return route.context ?? {}
108
111
  }
109
112
 
110
- async function prepareRoute(route: Route, redirects = 0): Promise<void> {
113
+ async function prepareRoute(
114
+ route: Route,
115
+ request: NavigationRequest,
116
+ redirects = 0,
117
+ ): Promise<void> {
118
+ if (!request.isCurrent()) {
119
+ return
120
+ }
121
+
122
+ if (!request.claim(route.stack)) {
123
+ return
124
+ }
125
+
111
126
  const beforeLoad = metaFor(route.name)?.beforeLoad
112
127
  if (!beforeLoad) {
113
- commitRoute(route)
128
+ commitRoute(route, request)
114
129
  return
115
130
  }
131
+
116
132
  if (redirects > 16) {
117
133
  console.warn(`[octane-xplat] beforeLoad redirect loop for '${route.name}'`)
118
134
  return
119
135
  }
136
+
120
137
  try {
121
138
  const returned = await beforeLoad({ params: route.params, context: contextFor(route) })
122
- commitRoute({ ...route, context: returned ? { ...contextFor(route), ...returned } : route.context })
139
+ commitRoute(
140
+ { ...route, context: returned ? { ...contextFor(route), ...returned } : route.context },
141
+ request,
142
+ )
123
143
  } catch (error) {
144
+ if (!request.isCurrent()) {
145
+ return
146
+ }
147
+
124
148
  if (error instanceof RouteRedirect) {
125
- await prepareRoute(error.route, redirects + 1)
149
+ await prepareRoute(error.route, request, redirects + 1)
126
150
  return
127
151
  }
128
- console.warn(`[octane-xplat] beforeLoad('${route.name}') rejected: ${(error as Error)?.message ?? error}`)
152
+
153
+ console.warn(
154
+ `[octane-xplat] beforeLoad('${route.name}') rejected: ${(error as Error)?.message ?? error}`,
155
+ )
129
156
  }
130
157
  }
131
158
 
@@ -134,14 +161,20 @@ export function redirect(route: Route): never {
134
161
  }
135
162
 
136
163
  export function pushRoute(route: Route): void {
164
+ const request = navigationRequests.begin(route.stack)
137
165
  if (metaFor(route.name)?.beforeLoad) {
138
- void prepareRoute(route)
166
+ void prepareRoute(route, request)
139
167
  return
140
168
  }
141
- commitRoute(route)
169
+
170
+ commitRoute(route, request)
142
171
  }
143
172
 
144
- function commitRoute(route: Route): void {
173
+ function commitRoute(route: Route, request: NavigationRequest): void {
174
+ if (!request.isCurrent()) {
175
+ return
176
+ }
177
+
145
178
  const entry = { ...route, presentation: route.presentation ?? metaFor(route.name)?.presentation }
146
179
  const loader = routeLoaders[entry.name] ?? metaFor(entry.name)?.loader
147
180
  if (
@@ -149,10 +182,13 @@ function commitRoute(route: Route): void {
149
182
  !Object.prototype.hasOwnProperty.call(entry, 'loaderData') &&
150
183
  !Object.prototype.hasOwnProperty.call(entry, 'loaderError')
151
184
  ) {
152
- void Promise.resolve().then(() => loader(entry.params)).then(
153
- (loaderData) => commitRoute({ ...entry, loaderData }),
154
- (loaderError) => commitRoute({ ...entry, loaderError }),
155
- )
185
+ void Promise.resolve()
186
+ .then(() => loader(entry.params))
187
+ .then(
188
+ (loaderData) => commitRoute({ ...entry, loaderData }, request),
189
+ (loaderError) => commitRoute({ ...entry, loaderError }, request),
190
+ )
191
+
156
192
  return
157
193
  }
158
194
 
@@ -164,11 +200,14 @@ function commitRoute(route: Route): void {
164
200
  lastStack = entry.stack
165
201
  modalRoute = null
166
202
  }
203
+
167
204
  emit()
168
205
  }
169
206
 
170
207
  export function popRoute(stack = 'root'): void {
208
+ navigationRequests.invalidate(stack)
171
209
  if (modalRoute) {
210
+ navigationRequests.invalidate(modalRoute.stack)
172
211
  modalRoute = null
173
212
  } else {
174
213
  const entries = stacks.get(stack) ?? []
@@ -177,14 +216,18 @@ export function popRoute(stack = 'root'): void {
177
216
  } else if (entries.length) {
178
217
  stacks.set(stack, [])
179
218
  }
219
+
180
220
  lastStack = stack
181
221
  }
222
+
182
223
  emit()
183
224
  }
184
225
 
185
226
  export function pushDeepLink(url: string): void {
186
227
  const route = matchUrl(routes, url)
187
- if (route) {pushRoute(route)}
228
+ if (route) {
229
+ pushRoute(route)
230
+ }
188
231
  }
189
232
 
190
233
  export function hrefFor(route: Route): string {