@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.
- package/README.md +4 -3
- package/dist/native/Button.tsrx.js +1 -1
- package/dist/native/CommandPalette.tsrx.js +7 -1
- package/dist/native/Grid.tsrx.js +31 -2
- package/dist/native/KeyboardAvoiding.mobile.tsrx.js +2 -2
- package/dist/native/Markdown.tsrx.js +233 -0
- package/dist/native/Overlay.tsrx.js +15 -26
- package/dist/native/Pagination.tsrx.js +15 -12
- package/dist/native/PinInput.tsrx.js +25 -7
- package/dist/native/Popover.tsrx.js +33 -23
- package/dist/native/Pressable.tsrx.js +4 -1
- package/dist/native/Screen.tsrx.js +1 -1
- package/dist/native/SearchInput.tsrx.js +6 -3
- package/dist/native/Sheet.tsrx.js +23 -38
- package/dist/native/Slider.tsrx.js +23 -14
- package/dist/native/Text.tsrx.js +16 -0
- package/dist/native/TextArea.tsrx.js +6 -3
- package/dist/native/TextInput.tsrx.js +6 -3
- package/dist/native/VirtualList.tsrx.js +305 -121
- package/dist/native/WebView.tsrx.js +17 -2
- package/dist/native/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
- package/dist/native/escape-props.js +2 -0
- package/dist/native/grid-auto-placement.js +83 -0
- package/dist/native/grid-placement.js +20 -0
- package/dist/native/index.shared.js +7 -5
- package/dist/native/keyboard-inset.mobile.js +5 -5
- package/dist/native/navigation-request.js +26 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/overlay-lifetime.mobile.js +65 -0
- package/dist/native/pan.tsrx.js +16 -2
- package/dist/native/route-table.js +90 -10
- package/dist/native/route.js +26 -16
- package/dist/native/shared.js +7 -5
- package/dist/native/sheet-service.js +2 -2
- package/dist/native/svg.mobile.js +1 -1
- package/dist/native/{tap-to-blur.mobile.js → tap-to-blur.ios.js} +20 -32
- package/dist/native/text-focus.js +10 -0
- package/dist/native/text-write.js +18 -11
- package/dist/native/toast-anchor.tsrx.js +1 -1
- package/dist/native/ui.js +7 -6
- package/dist/native/useMeasure.tsrx.js +17 -18
- package/dist/native/vendor/ui-svg/index.android.js +2 -2
- package/dist/native/vendor/ui-svg/index.ios.js +2 -2
- package/dist/native/vendor/ui-svg/{index.common.js → src/index.common.js} +1 -1
- package/dist/native/virtual-list-cell-pool.js +63 -0
- package/dist/web/Button.web.tsrx.js +1 -1
- package/dist/web/KeyboardAvoiding.web.tsrx.js +78 -0
- package/dist/web/Markdown.tsrx.js +98 -0
- package/dist/web/Overlay.web.tsrx.js +81 -32
- package/dist/web/PinInput.web.tsrx.js +24 -10
- package/dist/web/Pressable.web.tsrx.js +169 -53
- package/dist/web/SearchInput.web.tsrx.js +2 -2
- package/dist/web/Sheet.web.tsrx.js +20 -7
- package/dist/web/TextArea.web.tsrx.js +22 -17
- package/dist/web/TextInput.web.tsrx.js +2 -2
- package/dist/web/VirtualList.web.tsrx.js +220 -109
- package/dist/web/WebView.web.tsrx.js +3 -3
- package/dist/web/{index.shared.js → index.shared.web.js} +7 -5
- package/dist/web/modal-focus.web.js +95 -0
- package/dist/web/navigation-request.js +26 -0
- package/dist/web/octane-client-build.json +1 -1
- package/dist/web/pan.web.tsrx.js +27 -10
- package/dist/web/route-table.js +90 -10
- package/dist/web/route.web.js +88 -22
- package/dist/web/sheet-service.web.js +8 -0
- package/dist/web/theme/colorScheme.web.js +31 -4
- package/dist/web/ui.js +8 -6
- package/dist/web/virtual-list-cell-pool.js +63 -0
- package/package.json +21 -19
- package/platforms/android/native-api-usage.json +7 -7
- package/src/CommandPalette.tsrx +8 -1
- package/src/Grid.tsrx +32 -1
- package/src/Icon.macos.tsrx +1 -0
- package/src/KeyboardAvoiding.macos.tsrx +7 -1
- package/src/KeyboardAvoiding.mobile.tsrx +2 -2
- package/src/KeyboardAvoiding.tsrx +15 -0
- package/src/KeyboardAvoiding.web.tsrx +15 -0
- package/src/Markdown.tsrx +121 -0
- package/src/Meter.tsrx +1 -0
- package/src/Overlay.tsrx +15 -30
- package/src/Overlay.web.tsrx +12 -3
- package/src/Pagination.tsrx +6 -4
- package/src/PinInput.macos.tsrx +20 -6
- package/src/PinInput.mobile.test.ts +107 -0
- package/src/PinInput.tsrx +20 -6
- package/src/PinInput.web.test.tsrx +42 -0
- package/src/PinInput.web.tsrx +20 -6
- package/src/Popover.tsrx +36 -22
- package/src/Pressable.tsrx +1 -1
- package/src/Pressable.web.test.tsrx +36 -0
- package/src/Pressable.web.tsrx +37 -6
- package/src/RouteHost.macos.tsrx +1 -0
- package/src/Screen.tsrx +1 -1
- package/src/SearchInput.tsrx +4 -3
- package/src/SearchInput.web.tsrx +1 -1
- package/src/Sheet.tsrx +21 -42
- package/src/Sheet.web.tsrx +9 -1
- package/src/Slider.tsrx +13 -11
- package/src/Text.tsrx +36 -0
- package/src/TextArea.tsrx +6 -5
- package/src/TextArea.web.test.tsrx +39 -0
- package/src/TextArea.web.tsrx +7 -5
- package/src/TextInput.tsrx +6 -6
- package/src/TextInput.web.tsrx +1 -1
- package/src/VirtualList.macos.tsrx +93 -6
- package/src/VirtualList.mobile.test.tsrx +232 -0
- package/src/VirtualList.tsrx +285 -81
- package/src/VirtualList.web.tsrx +131 -25
- package/src/WebView.tsrx +20 -10
- package/src/WebView.web.tsrx +3 -0
- package/src/android/BottomNavigationView.android.tsrx +1 -0
- package/src/android/MaterialDialog.android.tsrx +1 -0
- package/src/anim.macos.tsrx +1 -0
- package/src/apply-native-modifiers.native.ts +4 -1
- package/src/child-array.ts +12 -7
- package/src/colorScheme.macos.ts +1 -0
- package/src/controls.macos.tsrx +4 -1
- package/src/data-driver.mobile.test.ts +120 -0
- package/src/data-lifecycle.mobile.test.ts +251 -0
- package/src/escape-props.ts +13 -1
- package/src/grid-auto-placement.ts +163 -0
- package/src/grid-placement.ts +29 -0
- package/src/hosted-unsupported.macos.tsrx +1 -0
- package/src/index.macos.ts +23 -3
- package/src/index.mobile.ts +0 -1
- package/src/index.shared.ts +22 -1
- package/src/index.shared.web.ts +291 -0
- package/src/index.web.ts +1 -1
- package/src/input-readiness.web.test.tsrx +82 -0
- package/src/ios/UITabBar.ios.tsrx +1 -0
- package/src/keyboard-inset.mobile.test.ts +62 -0
- package/src/keyboard-inset.mobile.ts +63 -43
- package/src/layout-shells.macos.tsrx +1 -5
- package/src/macos-extras.macos.tsrx +2 -0
- package/src/modal-focus.web.test.tsrx +63 -0
- package/src/modal-focus.web.ts +162 -0
- package/src/navigation-request.ts +37 -0
- package/src/overlay-lifecycle.mobile.test.ts +316 -0
- package/src/overlay-lifetime.mobile.ts +107 -0
- package/src/pan.mobile.test.tsrx +116 -0
- package/src/pan.tsrx +20 -2
- package/src/pan.web.tsrx +35 -7
- package/src/platformProps.ts +1 -0
- package/src/popover.mobile.test.ts +9 -1
- package/src/pressable-accessibility.mobile.test.ts +30 -0
- package/src/props.ts +163 -3
- package/src/refresh.ts +6 -1
- package/src/refresh.web.test.ts +77 -77
- package/src/route-android.test.ts +162 -0
- package/src/route-history.web.test.ts +80 -0
- package/src/route-navigation.web.test.ts +173 -0
- package/src/route-table.test.ts +188 -9
- package/src/route-table.ts +138 -10
- package/src/route.macos.ts +56 -13
- package/src/route.ts +54 -24
- package/src/route.web.ts +141 -24
- package/src/sheet-detents.ts +5 -3
- package/src/sheet-service.macos.ts +5 -1
- package/src/sheet-service.ts +1 -1
- package/src/sheet-service.web.ts +8 -0
- package/src/sheet-snap.test.ts +23 -23
- package/src/sheet-snap.ts +6 -1
- package/src/svg.mobile.ts +9 -3
- package/src/tap-to-blur.ios.ts +106 -0
- package/src/tap-to-blur.mobile.ts +21 -95
- package/src/text-focus.ts +12 -0
- package/src/text-input.mobile.test.ts +89 -0
- package/src/text-write.ts +21 -11
- package/src/theme/colorScheme.linux.ts +2 -4
- package/src/theme/colorScheme.web.ts +51 -4
- package/src/theme/theme-scheme.macos.ts +8 -2
- package/src/theme/tokens.css +1740 -1718
- package/src/translate.macos.ts +4 -1
- package/src/useMeasure.tsrx +20 -21
- package/src/virtual-list-cell-pool.test.ts +53 -0
- package/src/virtual-list-cell-pool.ts +81 -0
- package/src/virtual-list-layout.test.ts +11 -8
- package/src/virtual-list-layout.ts +4 -8
- package/src/windows.linux.ts +7 -2
- package/src/windows.macos.ts +1 -0
- package/types/generated/.tsrx-typegen-manifest.json +16 -3
- package/types/generated/Grid.d.ts +0 -1
- package/types/generated/KeyboardAvoiding.d.ts +3 -0
- package/types/generated/KeyboardAvoiding.web.d.ts +2 -0
- package/types/generated/Markdown.d.ts +59 -0
- package/types/generated/TextArea.d.ts +2 -2
- package/types/generated/TextArea.web.d.ts +1 -1
- package/types/generated/TextInput.d.ts +2 -3
- package/types/generated/VirtualList.d.ts +3 -3
- package/types/generated/escape-props.d.ts +4 -0
- package/types/generated/grid-auto-placement.d.ts +13 -0
- package/types/generated/grid-placement.d.ts +5 -0
- package/types/generated/index.mobile.d.ts +0 -1
- package/types/generated/index.shared.d.ts +6 -3
- package/types/generated/index.shared.web.d.ts +114 -0
- package/types/generated/index.web.d.ts +1 -1
- package/types/generated/modal-focus.web.d.ts +3 -0
- package/types/generated/navigation-request.d.ts +10 -0
- package/types/generated/overlay-lifetime.mobile.d.ts +10 -0
- package/types/generated/props.d.ts +111 -3
- package/types/generated/route-table.d.ts +14 -3
- package/types/generated/svg.mobile.d.ts +2 -2
- package/types/generated/tap-to-blur.ios.d.ts +13 -0
- package/types/generated/tap-to-blur.mobile.d.ts +1 -15
- package/types/generated/text-focus.d.ts +3 -0
- package/types/generated/text-write.d.ts +3 -1
- package/types/generated/theme/colorScheme.web.d.ts +5 -0
- package/types/generated/virtual-list-cell-pool.d.ts +18 -0
- package/types/index.macos.d.ts +15 -3
- /package/src/vendor/ui-svg/{index.android.ts → src/index.android.ts} +0 -0
- /package/src/vendor/ui-svg/{index.common.ts → src/index.common.ts} +0 -0
- /package/src/vendor/ui-svg/{index.ios.ts → src/index.ios.ts} +0 -0
- /package/src/vendor/ui-svg/{index.ts → src/index.ts} +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/SVGKit.d.ts +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/android.d.ts +0 -0
- /package/src/vendor/ui-svg/{typings → src/typings}/ios.d.ts +0 -0
- /package/types/generated/vendor/ui-svg/{index.common.d.ts → src/index.common.d.ts} +0 -0
- /package/types/generated/vendor/ui-svg/{index.d.ts → src/index.d.ts} +0 -0
- /package/types/generated/vendor/ui-svg/{index.ios.d.ts → src/index.ios.d.ts} +0 -0
package/src/route-table.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/route-table.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/route.macos.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
153
|
-
(
|
|
154
|
-
(
|
|
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) {
|
|
228
|
+
if (route) {
|
|
229
|
+
pushRoute(route)
|
|
230
|
+
}
|
|
188
231
|
}
|
|
189
232
|
|
|
190
233
|
export function hrefFor(route: Route): string {
|