@furystack/shades 13.2.1 → 14.0.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 (111) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/esm/components/define-nested-routes.d.ts +27 -0
  3. package/esm/components/define-nested-routes.d.ts.map +1 -0
  4. package/esm/components/define-nested-routes.js +26 -0
  5. package/esm/components/define-nested-routes.js.map +1 -0
  6. package/esm/components/define-nested-routes.spec.d.ts +2 -0
  7. package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
  8. package/esm/components/define-nested-routes.spec.js +81 -0
  9. package/esm/components/define-nested-routes.spec.js.map +1 -0
  10. package/esm/components/index.d.ts +3 -3
  11. package/esm/components/index.d.ts.map +1 -1
  12. package/esm/components/index.js +3 -3
  13. package/esm/components/index.js.map +1 -1
  14. package/esm/components/nested-hooks.d.ts +62 -0
  15. package/esm/components/nested-hooks.d.ts.map +1 -0
  16. package/esm/components/nested-hooks.js +73 -0
  17. package/esm/components/nested-hooks.js.map +1 -0
  18. package/esm/components/nested-hooks.spec.d.ts +2 -0
  19. package/esm/components/nested-hooks.spec.d.ts.map +1 -0
  20. package/esm/components/nested-hooks.spec.js +211 -0
  21. package/esm/components/nested-hooks.spec.js.map +1 -0
  22. package/esm/components/nested-navigate.d.ts +52 -21
  23. package/esm/components/nested-navigate.d.ts.map +1 -1
  24. package/esm/components/nested-navigate.js +32 -17
  25. package/esm/components/nested-navigate.js.map +1 -1
  26. package/esm/components/nested-navigate.spec.js +102 -12
  27. package/esm/components/nested-navigate.spec.js.map +1 -1
  28. package/esm/components/nested-replace.d.ts +36 -0
  29. package/esm/components/nested-replace.d.ts.map +1 -0
  30. package/esm/components/nested-replace.js +38 -0
  31. package/esm/components/nested-replace.js.map +1 -0
  32. package/esm/components/nested-replace.spec.d.ts +2 -0
  33. package/esm/components/nested-replace.spec.d.ts.map +1 -0
  34. package/esm/components/nested-replace.spec.js +59 -0
  35. package/esm/components/nested-replace.spec.js.map +1 -0
  36. package/esm/components/nested-route-link.d.ts +36 -26
  37. package/esm/components/nested-route-link.d.ts.map +1 -1
  38. package/esm/components/nested-route-link.js +17 -19
  39. package/esm/components/nested-route-link.js.map +1 -1
  40. package/esm/components/nested-route-link.spec.js +61 -11
  41. package/esm/components/nested-route-link.spec.js.map +1 -1
  42. package/esm/components/nested-route-types.d.ts +79 -2
  43. package/esm/components/nested-route-types.d.ts.map +1 -1
  44. package/esm/components/nested-router.d.ts +77 -7
  45. package/esm/components/nested-router.d.ts.map +1 -1
  46. package/esm/components/nested-router.js +137 -11
  47. package/esm/components/nested-router.js.map +1 -1
  48. package/esm/components/nested-router.spec.js +265 -34
  49. package/esm/components/nested-router.spec.js.map +1 -1
  50. package/esm/services/location-service.spec.js +33 -0
  51. package/esm/services/location-service.spec.js.map +1 -1
  52. package/esm/services/route-match-service.spec.js +4 -0
  53. package/esm/services/route-match-service.spec.js.map +1 -1
  54. package/esm/services/route-meta-utils.d.ts +28 -6
  55. package/esm/services/route-meta-utils.d.ts.map +1 -1
  56. package/esm/services/route-meta-utils.js +18 -4
  57. package/esm/services/route-meta-utils.js.map +1 -1
  58. package/esm/services/route-meta-utils.spec.js +57 -2
  59. package/esm/services/route-meta-utils.spec.js.map +1 -1
  60. package/esm/vnode.d.ts +1 -1
  61. package/esm/vnode.js +1 -1
  62. package/package.json +9 -9
  63. package/src/components/define-nested-routes.spec.ts +91 -0
  64. package/src/components/define-nested-routes.ts +27 -0
  65. package/src/components/index.ts +3 -3
  66. package/src/components/nested-hooks.spec.ts +254 -0
  67. package/src/components/nested-hooks.ts +109 -0
  68. package/src/components/nested-navigate.spec.ts +128 -25
  69. package/src/components/nested-navigate.ts +75 -26
  70. package/src/components/nested-replace.spec.ts +88 -0
  71. package/src/components/nested-replace.ts +46 -0
  72. package/src/components/nested-route-link.spec.tsx +84 -16
  73. package/src/components/nested-route-link.tsx +53 -28
  74. package/src/components/nested-route-types.ts +82 -2
  75. package/src/components/nested-router.spec.tsx +316 -33
  76. package/src/components/nested-router.tsx +182 -22
  77. package/src/services/location-service.spec.ts +39 -0
  78. package/src/services/route-match-service.spec.ts +4 -0
  79. package/src/services/route-meta-utils.spec.ts +75 -7
  80. package/src/services/route-meta-utils.ts +44 -15
  81. package/src/vnode.ts +1 -1
  82. package/esm/components/link-to-route.d.ts +0 -10
  83. package/esm/components/link-to-route.d.ts.map +0 -1
  84. package/esm/components/link-to-route.js +0 -16
  85. package/esm/components/link-to-route.js.map +0 -1
  86. package/esm/components/link-to-route.spec.d.ts +0 -2
  87. package/esm/components/link-to-route.spec.d.ts.map +0 -1
  88. package/esm/components/link-to-route.spec.js +0 -30
  89. package/esm/components/link-to-route.spec.js.map +0 -1
  90. package/esm/components/route-link.d.ts +0 -10
  91. package/esm/components/route-link.d.ts.map +0 -1
  92. package/esm/components/route-link.js +0 -25
  93. package/esm/components/route-link.js.map +0 -1
  94. package/esm/components/route-link.spec.d.ts +0 -2
  95. package/esm/components/route-link.spec.d.ts.map +0 -1
  96. package/esm/components/route-link.spec.js +0 -34
  97. package/esm/components/route-link.spec.js.map +0 -1
  98. package/esm/components/router.d.ts +0 -44
  99. package/esm/components/router.d.ts.map +0 -1
  100. package/esm/components/router.js +0 -66
  101. package/esm/components/router.js.map +0 -1
  102. package/esm/components/router.spec.d.ts +0 -2
  103. package/esm/components/router.spec.d.ts.map +0 -1
  104. package/esm/components/router.spec.js +0 -161
  105. package/esm/components/router.spec.js.map +0 -1
  106. package/src/components/link-to-route.spec.tsx +0 -45
  107. package/src/components/link-to-route.tsx +0 -26
  108. package/src/components/route-link.spec.tsx +0 -44
  109. package/src/components/route-link.tsx +0 -29
  110. package/src/components/router.spec.tsx +0 -217
  111. package/src/components/router.tsx +0 -103
package/CHANGELOG.md CHANGED
@@ -1,5 +1,155 @@
1
1
  # Changelog
2
2
 
3
+ ## [14.0.0] - 2026-04-22
4
+
5
+ ### 💥 Breaking Changes
6
+
7
+ ### Removed the legacy flat router, `RouteLink`, and `LinkToRoute`
8
+
9
+ The long-deprecated single-level `Router` component, its supporting `Route` / `RouterProps` / `RouterState` types, and the legacy `RouteLink` / `LinkToRoute` navigation components have been removed. They were superseded by the nested router stack (`NestedRouter`, `NestedRouteLink`, `createNestedRouteLink`, `nestedNavigate`, etc.) and marked `@deprecated` for several releases.
10
+
11
+ **Removed exports:**
12
+
13
+ - `Router`, `RouterProps`, `RouterState`, `Route`
14
+ - `RouteLink`, `RouteLinkProps`
15
+ - `LinkToRoute`, `LinkToRouteProps`
16
+
17
+ **Impact:** Any module still importing `Router`, `RouteLink`, or `LinkToRoute` from `@furystack/shades` will fail to resolve.
18
+
19
+ **Migration:** Replace flat routers with `NestedRouter` + `defineNestedRoutes`, and swap `RouteLink` / `LinkToRoute` for `NestedRouteLink` (or the route-tree-aware `createNestedRouteLink<typeof routes>()` factory). See the `NestedRouter` section in the package README for the migration recipe.
20
+
21
+ ### `NestedRouteLink` and `nestedNavigate` now accept a `path` prop / object argument
22
+
23
+ `NestedRouteLink` now uses `path` instead of `href`, matching the route-definition vocabulary used by `Breadcrumb` and the new navigation helpers. `nestedNavigate` / `createNestedNavigate` switched from positional arguments to an options object so that the new `query` and `hash` fields can be passed alongside `path` / `params` without a growing parameter list.
24
+
25
+ **Before:**
26
+
27
+ ```typescript
28
+ <NestedRouteLink href="/users/:id" params={{ id: '1' }}>User</NestedRouteLink>
29
+
30
+ nestedNavigate(injector, '/users/:id', { id: '1' })
31
+ ```
32
+
33
+ **After:**
34
+
35
+ ```typescript
36
+ <NestedRouteLink path="/users/:id" params={{ id: '1' }}>User</NestedRouteLink>
37
+
38
+ nestedNavigate(injector, { path: '/users/:id', params: { id: '1' } })
39
+ ```
40
+
41
+ **Impact:** Every call site of `NestedRouteLink`, `nestedNavigate`, `createNestedRouteLink` and `createNestedNavigate` must be updated. Wrappers that forward `NestedRouteLinkProps` (for example `AppBarLink` in `@furystack/shades-common-components`) also switched to `path`.
42
+
43
+ **Migration:** Rename `href` → `path` on every `NestedRouteLink` call site, and rewrite `nestedNavigate(injector, path, params?)` as `nestedNavigate(injector, { path, params })`.
44
+
45
+ ### `NestedRoute` gained two optional generic parameters
46
+
47
+ `NestedRoute<TMatchResult>` is now `NestedRoute<TMatchResult, TQuery, THash>`. Existing single-generic usages keep working through the defaults, but the `component` opts are now always called with `query` and `hash` fields (populated with `null` / `undefined` when the route declares no schema). `MatchChainEntry` likewise carries `query: unknown` and `hash: string | undefined` fields.
48
+
49
+ **Impact:** Test fixtures that construct `MatchChainEntry` literals by hand must supply `query` and `hash`. Components that destructure `component` opts by name are unaffected.
50
+
51
+ **Migration:** Add `query: null, hash: undefined` to any hand-rolled `MatchChainEntry` literal (typically in tests).
52
+
53
+ ### ✨ Features
54
+
55
+ ### Route-level `hash` and `query` schemas
56
+
57
+ `NestedRoute` can now declare a readonly tuple of allowed hash literals and a query-string validator. Both flow through the new `createNestedRouteLink` / `createNestedNavigate` / `createNestedHooks` helpers to constrain call sites at compile time and surface typed values to the route's `component` opts at render time.
58
+
59
+ **Usage:**
60
+
61
+ ```typescript
62
+ const routes = defineNestedRoutes({
63
+ '/users/:id': {
64
+ hash: ['profile', 'settings'] as const,
65
+ query: (raw): { page: number } | null =>
66
+ typeof raw.page === 'number' ? { page: raw.page } : null,
67
+ component: ({ match, query, hash }) => <UserPage match={match} query={query} hash={hash} />,
68
+ },
69
+ })
70
+
71
+ const AppLink = createNestedRouteLink<typeof routes>()
72
+ const appNavigate = createNestedNavigate<typeof routes>()
73
+
74
+ <AppLink path="/users/:id" params={{ id: '1' }} query={{ page: 2 }} hash="profile">User</AppLink>
75
+ appNavigate(injector, { path: '/users/:id', params: { id: '1' }, hash: 'settings' })
76
+ ```
77
+
78
+ - The router matches on path only — an invalid query / hash never prevents navigation. Components receive `query: null` or `hash: undefined` when the URL does not satisfy the declared schema.
79
+ - Query serialization uses the existing `@furystack/rest` encoder, so any JSON-serializable shape round-trips without a schema library dependency.
80
+
81
+ ### `defineNestedRoutes` helper
82
+
83
+ `defineNestedRoutes(...)` is an identity helper that preserves the exact inferred literal type of a route tree while still applying a structural `Record<string, NestedRoute<...>>` constraint. Using a plain `satisfies` assertion with the historical bound `NestedRoute<any>` collapsed per-route generics to their defaults, which defeated the downstream extractors used by `createNestedRouteLink` / `createNestedNavigate` / `createNestedHooks`. Declaring route trees through `defineNestedRoutes` keeps the literal shape intact so each route's declared query validator return type and hash literal tuple remain recoverable.
84
+
85
+ ```typescript
86
+ export const appRoutes = defineNestedRoutes({
87
+ '/tabs': { hash: ['a', 'b'] as const, component: () => <div /> },
88
+ })
89
+ ```
90
+
91
+ ### `createNestedHooks` factory
92
+
93
+ `createNestedHooks(routes)` returns `{ getTypedQuery, getTypedHash }`, synchronous read helpers that look up the route declared at a given path and narrow the current URL's query / hash against its schema. The route tree is captured at factory time, so call sites only pass `(injector, path)` and the type-level narrowing is inferred from the tree. Intended for non-component consumers (services, side effects) that need a typed snapshot without subscribing through the router.
94
+
95
+ ```typescript
96
+ const { getTypedQuery, getTypedHash } = createNestedHooks(routes)
97
+ const query = getTypedQuery(injector, '/users/:id') // typed | null
98
+ const hash = getTypedHash(injector, '/users/:id') // typed | undefined
99
+ ```
100
+
101
+ ### `buildNestedNavigateUrl` utility
102
+
103
+ Exposed the URL-composition helper used internally by `nestedNavigate` and `NestedRouteLink` so callers can produce a compiled URL (path + query + hash) without triggering navigation.
104
+
105
+ ### Strongly-typed `extractNavTree` / `NavTreeNode<TRoutes>`
106
+
107
+ `extractNavTree` is now generic over the route tree it is called with, and returns `NavTreeNode<TRoutes>[]` instead of the previous loosely-typed `NavTreeNode[]`. On each node `pattern` is narrowed to `keyof TRoutes & string` and `fullPath` is narrowed to `ExtractRoutePaths<TRoutes> & string`, so the result feeds directly into `createNestedRouteLink` / `createNestedNavigate` / `createNestedReplace` / `createAppBarLink` without an `as` cast at the call site. Consumers that still reference `NavTreeNode` without a type argument keep compiling via a widened default parameter, preserving backward compatibility.
108
+
109
+ ```typescript
110
+ import { extractNavTree, type NavTreeNode } from '@furystack/shades'
111
+
112
+ const tree = extractNavTree(appRoutes)
113
+ // tree[0].fullPath is 'ExtractRoutePaths<typeof appRoutes>', not `string`.
114
+
115
+ const AppLink = createNestedRouteLink<typeof appRoutes>()
116
+ tree.map((node) => <AppLink path={node.fullPath}>{node.meta?.title}</AppLink>)
117
+
118
+ // Widened default is unchanged
119
+ const loose: NavTreeNode = { pattern: '/anything', fullPath: '/anything' }
120
+ ```
121
+
122
+ ### `nestedReplace` / `createNestedReplace`
123
+
124
+ Symmetric counterparts to `nestedNavigate` / `createNestedNavigate` that call `history.replaceState` (via `LocationService.replace`) instead of `pushState`. Use this for SPA redirects — auth guards, canonicalization — where the intermediate URL should not be recoverable with the browser's Back button. Accepts the exact same object-argument shape and inherits the full type-safe narrowing of `path` / `params` / `query` / `hash` against the route tree.
125
+
126
+ ```typescript
127
+ const appReplace = createNestedReplace<typeof appRoutes>()
128
+
129
+ // Unauthenticated visitor → send to /login without polluting history
130
+ appReplace(injector, { path: '/login' })
131
+ ```
132
+
133
+ ### ♻️ Refactoring
134
+
135
+ - The nested router now subscribes to query-string and hash changes in addition to path changes, re-rendering the matched chain without firing `onLeave` / `onVisit` lifecycle hooks when only the URL's search or hash segment changes.
136
+ - Path / query / hash observable bursts from a single `navigate` are now coalesced onto one microtask, so one logical navigation triggers one `updateUrl` run (the `versionRef` guard is retained as a race-safety net).
137
+ - `findDivergenceIndex` keeps its path-only semantics; a separate `hasQueryOrHashChanged` helper drives re-render decisions when the matched chain stays stable but its query / hash differ. Query values are compared with a key-order-independent shallow equality helper instead of `JSON.stringify`.
138
+ - `enrichMatchChain` short-circuits when no chain entry declares `query` or `hash`, returning the input array reference to avoid a per-navigation allocation on the common path-only case.
139
+ - New low-level exports surfaced for advanced routing integrations: `enrichMatchChain`, `hasQueryOrHashChanged`, and the supporting types `QueryValidator`, `HashLiterals`, `ExtractRouteQuery`, `ExtractRouteHash`, `RouteAt`, `TypedParamsArg`, `TypedQueryArg`, `TypedHashArg`, and `TreeAwareNestedRouteLinkProps`.
140
+
141
+ ### 🧪 Tests
142
+
143
+ - Added type-level and runtime coverage for the new `query` / `hash` paths on `createNestedNavigate`, `createNestedRouteLink`, and the router's match-chain enrichment.
144
+ - Added a `buildNestedNavigateUrl` suite covering params, query and hash composition.
145
+ - Added `expectTypeOf` assertions for the new generic `extractNavTree` output: `pattern` / `fullPath` narrow to the declared route keys and composed path union for flat and nested typed trees, and the widened `NavTreeNode` default keeps compiling for ad-hoc consumers.
146
+
147
+ ## [13.2.2] - 2026-04-17
148
+
149
+ ### ⬆️ Dependencies
150
+
151
+ - Raised `path-to-regexp` to ^8.4.2, `jsdom` to ^29.0.2, `@types/node` to ^25.6.0, and dev `typescript` to ^6.0.3 and `vitest` to ^4.1.4.
152
+
3
153
  ## [13.2.1] - 2026-03-27
4
154
 
5
155
  ### 🐛 Bug Fixes
@@ -0,0 +1,27 @@
1
+ import type { NestedRoute } from './nested-router.js';
2
+ /**
3
+ * Identity helper that preserves the exact inferred literal type of a nested
4
+ * route tree while still applying a structural `Record<string, NestedRoute<...>>`
5
+ * constraint for compile-time validation.
6
+ *
7
+ * Using a plain `satisfies Record<string, NestedRoute<any>>` assertion collapses
8
+ * the per-route `query` / `hash` generics to their defaults, which defeats the
9
+ * type-safe navigation helpers (`createNestedRouteLink`, `createNestedNavigate`,
10
+ * `createNestedHooks`). Declaring route trees through this helper keeps the
11
+ * literal shape intact so downstream extractors can recover each route's
12
+ * declared query validator return type and hash literal tuple.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const routes = defineNestedRoutes({
17
+ * '/users/:id': {
18
+ * component: ({ match, query, hash }) => ...,
19
+ * hash: ['profile', 'settings'] as const,
20
+ * query: (raw): { page: number } | null =>
21
+ * typeof raw.page === 'number' ? { page: raw.page } : null,
22
+ * },
23
+ * })
24
+ * ```
25
+ */
26
+ export declare const defineNestedRoutes: <T extends Record<string, NestedRoute<any, any, any>>>(routes: T) => T;
27
+ //# sourceMappingURL=define-nested-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-nested-routes.d.ts","sourceRoot":"","sources":["../../src/components/define-nested-routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAErD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,kBAAkB,GAAI,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC,KAAG,CAAW,CAAA"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Identity helper that preserves the exact inferred literal type of a nested
3
+ * route tree while still applying a structural `Record<string, NestedRoute<...>>`
4
+ * constraint for compile-time validation.
5
+ *
6
+ * Using a plain `satisfies Record<string, NestedRoute<any>>` assertion collapses
7
+ * the per-route `query` / `hash` generics to their defaults, which defeats the
8
+ * type-safe navigation helpers (`createNestedRouteLink`, `createNestedNavigate`,
9
+ * `createNestedHooks`). Declaring route trees through this helper keeps the
10
+ * literal shape intact so downstream extractors can recover each route's
11
+ * declared query validator return type and hash literal tuple.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const routes = defineNestedRoutes({
16
+ * '/users/:id': {
17
+ * component: ({ match, query, hash }) => ...,
18
+ * hash: ['profile', 'settings'] as const,
19
+ * query: (raw): { page: number } | null =>
20
+ * typeof raw.page === 'number' ? { page: raw.page } : null,
21
+ * },
22
+ * })
23
+ * ```
24
+ */
25
+ export const defineNestedRoutes = (routes) => routes;
26
+ //# sourceMappingURL=define-nested-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-nested-routes.js","sourceRoot":"","sources":["../../src/components/define-nested-routes.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAuD,MAAS,EAAK,EAAE,CAAC,MAAM,CAAA"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=define-nested-routes.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-nested-routes.spec.d.ts","sourceRoot":"","sources":["../../src/components/define-nested-routes.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,81 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import { defineNestedRoutes } from './define-nested-routes.js';
3
+ const makeComponent = () => null;
4
+ describe('defineNestedRoutes', () => {
5
+ it('Should return the same object reference at runtime', () => {
6
+ const input = {
7
+ '/tabs': {
8
+ component: () => makeComponent(),
9
+ hash: ['a', 'b'],
10
+ },
11
+ };
12
+ const result = defineNestedRoutes(input);
13
+ expect(result).toBe(input);
14
+ });
15
+ it('Should accept values that satisfy Record<string, NestedRoute<...>>', () => {
16
+ const routes = defineNestedRoutes({
17
+ '/bare': { component: () => makeComponent() },
18
+ '/tabs': {
19
+ component: () => makeComponent(),
20
+ hash: ['a', 'b'],
21
+ query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null),
22
+ },
23
+ });
24
+ expect(Object.keys(routes)).toEqual(['/bare', '/tabs']);
25
+ });
26
+ describe('Type preservation', () => {
27
+ it('Should preserve `hash` as a readonly tuple of literal values', () => {
28
+ const routes = defineNestedRoutes({
29
+ '/tabs': {
30
+ component: () => makeComponent(),
31
+ hash: ['overview', 'details'],
32
+ },
33
+ });
34
+ expectTypeOf(routes['/tabs'].hash).toEqualTypeOf();
35
+ });
36
+ it('Should preserve the declared return type of a `query` validator', () => {
37
+ const routes = defineNestedRoutes({
38
+ '/list': {
39
+ component: () => makeComponent(),
40
+ query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null),
41
+ },
42
+ });
43
+ expectTypeOf().toEqualTypeOf();
44
+ });
45
+ it('Should preserve nested children literals', () => {
46
+ const routes = defineNestedRoutes({
47
+ '/navigation': {
48
+ component: () => makeComponent(),
49
+ children: {
50
+ '/tabs': {
51
+ component: () => makeComponent(),
52
+ hash: ['a'],
53
+ },
54
+ },
55
+ },
56
+ });
57
+ expectTypeOf().toEqualTypeOf();
58
+ });
59
+ it('Should reject values that do not satisfy the NestedRoute constraint', () => {
60
+ // @ts-expect-error -- `component` is required on every route entry
61
+ defineNestedRoutes({ '/invalid': { hash: ['a'] } });
62
+ });
63
+ });
64
+ describe('Satisfies-collapse regression coverage', () => {
65
+ it('Should keep literal shape intact (unlike a bare `satisfies` assertion)', () => {
66
+ // A plain `satisfies` on `Record<string, NestedRoute<any>>` would widen
67
+ // the per-route `hash` to `readonly any[]` and drop the literal tuple.
68
+ // `defineNestedRoutes` must preserve the narrow tuple so downstream
69
+ // type extractors (`ExtractRouteHash`, `RouteAt`) can recover it.
70
+ const routes = defineNestedRoutes({
71
+ '/tabs': {
72
+ component: () => makeComponent(),
73
+ hash: ['overview'],
74
+ },
75
+ });
76
+ expectTypeOf().not.toEqualTypeOf();
77
+ expectTypeOf().toEqualTypeOf();
78
+ });
79
+ });
80
+ });
81
+ //# sourceMappingURL=define-nested-routes.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-nested-routes.spec.js","sourceRoot":"","sources":["../../src/components/define-nested-routes.spec.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,9 @@
1
+ export * from './define-nested-routes.js';
1
2
  export * from './lazy-load.js';
2
- export * from './link-to-route.js';
3
+ export * from './nested-hooks.js';
3
4
  export * from './nested-navigate.js';
5
+ export * from './nested-replace.js';
4
6
  export * from './nested-route-link.js';
5
7
  export * from './nested-route-types.js';
6
8
  export * from './nested-router.js';
7
- export * from './route-link.js';
8
- export * from './router.js';
9
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,yBAAyB,CAAA;AACvC,cAAc,oBAAoB,CAAA;AAClC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAA;AACzC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,qBAAqB,CAAA;AACnC,cAAc,wBAAwB,CAAA;AACtC,cAAc,yBAAyB,CAAA;AACvC,cAAc,oBAAoB,CAAA"}
@@ -1,9 +1,9 @@
1
+ export * from './define-nested-routes.js';
1
2
  export * from './lazy-load.js';
2
- export * from './link-to-route.js';
3
+ export * from './nested-hooks.js';
3
4
  export * from './nested-navigate.js';
5
+ export * from './nested-replace.js';
4
6
  export * from './nested-route-link.js';
5
7
  export * from './nested-route-types.js';
6
8
  export * from './nested-router.js';
7
- export * from './route-link.js';
8
- export * from './router.js';
9
9
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,oBAAoB,CAAA;AAClC,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,yBAAyB,CAAA;AACvC,cAAc,oBAAoB,CAAA;AAClC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAA;AACzC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,qBAAqB,CAAA;AACnC,cAAc,wBAAwB,CAAA;AACtC,cAAc,yBAAyB,CAAA;AACvC,cAAc,oBAAoB,CAAA"}
@@ -0,0 +1,62 @@
1
+ import type { Injector } from '@furystack/inject';
2
+ import type { ExtractRouteHash, ExtractRouteQuery, ExtractRoutePaths, RouteAt } from './nested-route-types.js';
3
+ import type { NestedRoute } from './nested-router.js';
4
+ /**
5
+ * A pair of synchronous read helpers bound to a specific route tree. Use the
6
+ * factory {@link createNestedHooks} to produce one.
7
+ */
8
+ export type NestedHooks<TRoutes extends Record<string, NestedRoute<any, any, any>>> = {
9
+ /**
10
+ * Reads and validates the current URL query string against the declared
11
+ * validator of the route at `path`. Returns the typed query value, or
12
+ * `null` when the route has no validator or the current URL's query does
13
+ * not satisfy it.
14
+ *
15
+ * This is a synchronous snapshot — for reactive use, subscribe to
16
+ * `locationService.onDeserializedLocationSearchChanged` and call this read
17
+ * on each change.
18
+ */
19
+ getTypedQuery: <TPath extends ExtractRoutePaths<TRoutes>>(injector: Injector, path: TPath) => ExtractRouteQuery<RouteAt<TRoutes, TPath>> | null;
20
+ /**
21
+ * Reads the current URL hash and narrows it against the declared literal
22
+ * tuple of the route at `path`. Returns the hash when it matches one of
23
+ * the route's declared literals, or `undefined` otherwise.
24
+ *
25
+ * This is a synchronous snapshot — for reactive use, subscribe to
26
+ * `locationService.onLocationHashChanged` and call this read on each change.
27
+ */
28
+ getTypedHash: <TPath extends ExtractRoutePaths<TRoutes>>(injector: Injector, path: TPath) => ExtractRouteHash<RouteAt<TRoutes, TPath>> | undefined;
29
+ };
30
+ /**
31
+ * Walks a route tree and returns the route value declared at the given path,
32
+ * matching each URL pattern segment-by-segment. The root `/` pattern is
33
+ * transparent: its children are searched with the full path.
34
+ *
35
+ * Exported for unit testing only — consumers should go through
36
+ * {@link createNestedHooks}.
37
+ *
38
+ * @internal
39
+ */
40
+ export declare const walkRoute: (routes: Record<string, NestedRoute<any, any, any>>, path: string) => NestedRoute<any, any, any> | undefined;
41
+ /**
42
+ * Creates a pair of type-safe synchronous read helpers (`getTypedQuery`,
43
+ * `getTypedHash`) bound to a specific route tree. The returned functions
44
+ * validate the current URL's query and hash against the route declared at
45
+ * the given path, returning typed values or `null`/`undefined` on mismatch.
46
+ *
47
+ * The concrete route tree is captured at factory time, so call sites only
48
+ * pass `(injector, path)`; the type-level narrowing is derived from the
49
+ * inferred tree generic.
50
+ *
51
+ * @typeParam TRoutes - The route tree type (inferred from the `routes` argument)
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * const { getTypedQuery, getTypedHash } = createNestedHooks(appRoutes)
56
+ *
57
+ * const query = getTypedQuery(injector, '/users') // typed
58
+ * const hash = getTypedHash(injector, '/users') // typed
59
+ * ```
60
+ */
61
+ export declare const createNestedHooks: <TRoutes extends Record<string, NestedRoute<any, any, any>>>(routes: TRoutes) => NestedHooks<TRoutes>;
62
+ //# sourceMappingURL=nested-hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nested-hooks.d.ts","sourceRoot":"","sources":["../../src/components/nested-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAA;AAC9G,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAErD;;;GAGG;AACH,MAAM,MAAM,WAAW,CAAC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,IAAI;IACpF;;;;;;;;;OASG;IACH,aAAa,EAAE,CAAC,KAAK,SAAS,iBAAiB,CAAC,OAAO,CAAC,EACtD,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,KAAK,KACR,iBAAiB,CAAC,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,GAAG,IAAI,CAAA;IACtD;;;;;;;OAOG;IACH,YAAY,EAAE,CAAC,KAAK,SAAS,iBAAiB,CAAC,OAAO,CAAC,EACrD,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,KAAK,KACR,gBAAgB,CAAC,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,GAAG,SAAS,CAAA;CAC3D,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,SAAS,GACpB,QAAQ,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,EAClD,MAAM,MAAM,KACX,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,SAe/B,CAAA;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,EAC1F,QAAQ,OAAO,KACd,WAAW,CAAC,OAAO,CAkBrB,CAAA"}
@@ -0,0 +1,73 @@
1
+ import { LocationService } from '../services/location-service.js';
2
+ /**
3
+ * Walks a route tree and returns the route value declared at the given path,
4
+ * matching each URL pattern segment-by-segment. The root `/` pattern is
5
+ * transparent: its children are searched with the full path.
6
+ *
7
+ * Exported for unit testing only — consumers should go through
8
+ * {@link createNestedHooks}.
9
+ *
10
+ * @internal
11
+ */
12
+ export const walkRoute = (routes, path) => {
13
+ for (const [pattern, route] of Object.entries(routes)) {
14
+ if (pattern === path)
15
+ return route;
16
+ if (pattern === '/' && route.children) {
17
+ const nested = walkRoute(route.children, path);
18
+ if (nested)
19
+ return nested;
20
+ }
21
+ else if (route.children && path.startsWith(pattern)) {
22
+ const rest = path.slice(pattern.length);
23
+ if (rest.startsWith('/') || rest === '') {
24
+ const nested = walkRoute(route.children, rest || '/');
25
+ if (nested)
26
+ return nested;
27
+ }
28
+ }
29
+ }
30
+ return undefined;
31
+ };
32
+ /**
33
+ * Creates a pair of type-safe synchronous read helpers (`getTypedQuery`,
34
+ * `getTypedHash`) bound to a specific route tree. The returned functions
35
+ * validate the current URL's query and hash against the route declared at
36
+ * the given path, returning typed values or `null`/`undefined` on mismatch.
37
+ *
38
+ * The concrete route tree is captured at factory time, so call sites only
39
+ * pass `(injector, path)`; the type-level narrowing is derived from the
40
+ * inferred tree generic.
41
+ *
42
+ * @typeParam TRoutes - The route tree type (inferred from the `routes` argument)
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * const { getTypedQuery, getTypedHash } = createNestedHooks(appRoutes)
47
+ *
48
+ * const query = getTypedQuery(injector, '/users') // typed
49
+ * const hash = getTypedHash(injector, '/users') // typed
50
+ * ```
51
+ */
52
+ export const createNestedHooks = (routes) => {
53
+ return {
54
+ getTypedQuery: (injector, path) => {
55
+ const route = walkRoute(routes, path);
56
+ if (!route?.query)
57
+ return null;
58
+ const locationService = injector.getInstance(LocationService);
59
+ const deserialized = locationService.onDeserializedLocationSearchChanged.getValue();
60
+ return route.query(deserialized);
61
+ },
62
+ getTypedHash: (injector, path) => {
63
+ const route = walkRoute(routes, path);
64
+ const declaredHash = route?.hash;
65
+ if (!declaredHash)
66
+ return undefined;
67
+ const locationService = injector.getInstance(LocationService);
68
+ const currentHash = locationService.onLocationHashChanged.getValue();
69
+ return declaredHash.includes(currentHash) ? currentHash : undefined;
70
+ },
71
+ };
72
+ };
73
+ //# sourceMappingURL=nested-hooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nested-hooks.js","sourceRoot":"","sources":["../../src/components/nested-hooks.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAqCjE;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CACvB,MAAkD,EAClD,IAAY,EAC4B,EAAE;IAC1C,KAAK,MAAM,CAAC,OAAO,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QACtD,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,KAAK,CAAA;QAClC,IAAI,OAAO,KAAK,GAAG,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YACtC,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;YAC9C,IAAI,MAAM;gBAAE,OAAO,MAAM,CAAA;QAC3B,CAAC;aAAM,IAAI,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;YACtD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;YACvC,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,IAAI,KAAK,EAAE,EAAE,CAAC;gBACxC,MAAM,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,IAAI,GAAG,CAAC,CAAA;gBACrD,IAAI,MAAM;oBAAE,OAAO,MAAM,CAAA;YAC3B,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC,CAAA;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAC/B,MAAe,EACO,EAAE;IACxB,OAAO;QACL,aAAa,EAAE,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE;YAChC,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;YACrC,IAAI,CAAC,KAAK,EAAE,KAAK;gBAAE,OAAO,IAAI,CAAA;YAC9B,MAAM,eAAe,GAAG,QAAQ,CAAC,WAAW,CAAC,eAAe,CAAC,CAAA;YAC7D,MAAM,YAAY,GAAG,eAAe,CAAC,mCAAmC,CAAC,QAAQ,EAAE,CAAA;YACnF,OAAO,KAAK,CAAC,KAAK,CAAC,YAAY,CAAU,CAAA;QAC3C,CAAC;QACD,YAAY,EAAE,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE;YAC/B,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;YACrC,MAAM,YAAY,GAAG,KAAK,EAAE,IAAqC,CAAA;YACjE,IAAI,CAAC,YAAY;gBAAE,OAAO,SAAS,CAAA;YACnC,MAAM,eAAe,GAAG,QAAQ,CAAC,WAAW,CAAC,eAAe,CAAC,CAAA;YAC7D,MAAM,WAAW,GAAG,eAAe,CAAC,qBAAqB,CAAC,QAAQ,EAAE,CAAA;YACpE,OAAO,YAAY,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAE,WAAqB,CAAC,CAAC,CAAC,SAAS,CAAA;QAChF,CAAC;KACF,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=nested-hooks.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nested-hooks.spec.d.ts","sourceRoot":"","sources":["../../src/components/nested-hooks.spec.ts"],"names":[],"mappings":""}