@terpjs/react-core 0.5.10 → 0.6.1

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 CHANGED
@@ -72,10 +72,46 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
72
72
  | `DetailPage` | One record's screen (level 3); breadcrumb trail = ancestors + record title. |
73
73
  | `Breadcrumbs` | The trail itself (used by the archetypes; rarely composed directly). Ancestor crumbs use the router's `Link` by default — `renderLink` is only for rendering outside a Terp router. |
74
74
  | `NavLinkContext`, `useNavLink` | The ambient link renderer `buildAppRouter` publishes (and the layout components default to); provide it yourself in a standalone story/test tree or a bespoke shell. |
75
- | `useRouteParam` | Read one route param, fail closed: the declared param comes back as a string, an undeclared name throws a directive error instead of silently yielding `undefined`. Routes are realised at runtime from manifests, so TanStack's type registry cannot check param names in any app — this replaces the unchecked `useParams({ strict: false }) as {…}` cast (ADR 0092). |
75
+ | `useRouteParam` | Read one route param, fail closed: the declared param comes back as a string, an undeclared name throws a directive error instead of silently yielding `undefined`. Replaces the unchecked `useParams({ strict: false }) as {…}` cast (ADR 0092). Checked against the generated route table when the app has one. |
76
+ | `useRouteParams` | Read a whole declared route's params, typed exactly: `const { recordId } = useRouteParams("/records/:recordId")`. With a generated route table, a typo in the path *or* a param name is a typecheck error. |
77
+ | `useTerpNavigate` | Navigate by manifest path: `navigate({ to: "/records/:recordId", params: { recordId } })`. An undeclared path is a typecheck error and a parameterised route requires its params — a typo'd path used to be a dead link that shipped green. Takes the manifest's `:id` spelling and translates to the router's `$id`. |
76
78
  | `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
77
79
  | `PageActions` | Primary action + overflow menu for a page header. |
78
80
 
81
+ ### Typed route paths and params (generated, ADR 0092)
82
+
83
+ `buildAppRouter` realises routes at runtime from manifest data, which leaves TanStack
84
+ Router's type registry empty: nothing checks a route path or a param name, so a typo'd
85
+ path is a dead link and a typo'd param silently reads `undefined`. The manifests are
86
+ static data, so the check is generated from them:
87
+
88
+ ```bash
89
+ npm --prefix frontend run routes # or: uv run terp routes
90
+ ```
91
+
92
+ `terp-routes` reads every `src/modules/<name>/module.tsx` manifest and writes a
93
+ **committed** `src/routes.gen.d.ts` that augments `TerpRouteTable`:
94
+
95
+ ```ts
96
+ declare module "@terpjs/react-core" {
97
+ interface TerpRouteTable {
98
+ "/records": Record<never, never>;
99
+ "/records/:recordId": { recordId: string };
100
+ }
101
+ }
102
+ ```
103
+
104
+ From then on `useRouteParams("/records/:recordId")` is exact, `useRouteParam` refuses a
105
+ param no route declares, and `useTerpNavigate` refuses an undeclared path. Regenerate
106
+ after changing a manifest route — `terp verify`'s `routes-drift` check refuses a stale
107
+ table and names the command (it runs before the typecheck, so a stale table reads as
108
+ "regenerate", not as errors in your own screens). A route whose `path` is not a plain
109
+ string literal is refused with its file and line rather than silently omitted: a partial
110
+ table would turn a real path into a type error. Routes a packaged area mounts (the admin
111
+ area) are not keyed — the file stays a pure function of the app's own manifests. Without
112
+ a generated file every helper falls back to `string`, so adopting is opt-in: add the
113
+ `routes` script, generate, commit.
114
+
79
115
  ### Slot-typed layout contracts (opt-in, ADR 0079)
80
116
 
81
117
  An app can ratchet the archetype control further with a named **layout contract**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.5.10",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@tanstack/react-router": "^1.170.16",
16
- "@terpjs/contract": "^0.5.10"
16
+ "@terpjs/contract": "^0.6.1"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^18.3.0 || ^19.0.0",
@@ -12,7 +12,7 @@ import { DataView, HttpDataViewRepository } from "../dataview";
12
12
  import type { DataViewColumn } from "../dataview";
13
13
  import { DetailList, Stack } from "../layout";
14
14
  import { PageActions } from "../PageActions";
15
- import { useRouteParam } from "../router";
15
+ import { useDeclaredParam } from "../router";
16
16
  import { useRecord } from "../useRecord";
17
17
  import { useToast } from "../toast";
18
18
  import { Button } from "../ui/Button";
@@ -40,7 +40,7 @@ const SEARCH_DEBOUNCE_MS = 250;
40
40
  * backend resolved for them.
41
41
  */
42
42
  export function GroupDetail() {
43
- const groupId = useRouteParam("groupId");
43
+ const groupId = useDeclaredParam("groupId");
44
44
  const client = useTerpClient();
45
45
  const navigate = useNavigate();
46
46
  const toast = useToast();
@@ -7,7 +7,7 @@ import { Field } from "../Field";
7
7
  import { Icon } from "../icons";
8
8
  import { DetailList } from "../layout";
9
9
  import { PageActions } from "../PageActions";
10
- import { useRouteParam } from "../router";
10
+ import { useDeclaredParam } from "../router";
11
11
  import { useTerpClient } from "../TerpProvider";
12
12
  import { useRecord } from "../useRecord";
13
13
  import { useToast } from "../toast";
@@ -27,7 +27,7 @@ type PendingLifecycle =
27
27
 
28
28
  /** Dedicated account detail and lifecycle page (`/admin/users/$userId`). */
29
29
  export function UserDetail() {
30
- const userId = useRouteParam("userId");
30
+ const userId = useDeclaredParam("userId");
31
31
  const client = useTerpClient();
32
32
  const strings = useStrings();
33
33
  const toast = useToast();
package/src/index.ts CHANGED
@@ -116,8 +116,24 @@ export { Field } from "./Field";
116
116
  export type { FieldProps } from "./Field";
117
117
  export { Stack, DetailList } from "./layout";
118
118
  export type { StackProps, DetailListProps, DetailItem, SpaceToken } from "./layout";
119
- export { buildAppRouter, DEFAULT_ROLE_RANKS, PROFILE_PATH, useRouteParam } from "./router";
119
+ export {
120
+ buildAppRouter,
121
+ DEFAULT_ROLE_RANKS,
122
+ PROFILE_PATH,
123
+ useRouteParam,
124
+ useRouteParams,
125
+ useTerpNavigate,
126
+ } from "./router";
120
127
  export type { BuildAppRouterOptions } from "./router";
128
+ // The generated `routes.gen.d.ts` augments TerpRouteTable (ADR 0092); the derived types
129
+ // are exported so an app can name a route path or a param object in its own signatures.
130
+ export type {
131
+ TerpNavigateTarget,
132
+ TerpRouteParamName,
133
+ TerpRouteParams,
134
+ TerpRoutePath,
135
+ TerpRouteTable,
136
+ } from "./routeTypes";
121
137
  export { LoginView } from "./LoginView";
122
138
  export type { DevCredentials, LoginViewProps } from "./LoginView";
123
139
  export {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The type-level route table an app's generated `routes.gen.d.ts` augments (ADR 0092).
3
+ *
4
+ * `buildAppRouter` realises routes at runtime from manifest data, so TanStack Router's
5
+ * own type registry is empty for every Terp app: no route path and no param name is
6
+ * checked anywhere, and a typo ships green. The information to check them is already
7
+ * checked in — the manifests are static data — so `terp routes` extracts it and emits a
8
+ * committed declaration file that augments this interface:
9
+ *
10
+ * ```ts
11
+ * declare module "@terpjs/react-core" {
12
+ * interface TerpRouteTable {
13
+ * "/records": Record<never, never>;
14
+ * "/records/:recordId": { recordId: string };
15
+ * }
16
+ * }
17
+ * ```
18
+ *
19
+ * Paths are keyed in the manifest's stack-agnostic spelling (`:recordId`, not `$recordId`),
20
+ * which is the spelling {@link useTerpNavigate} accepts and translates.
21
+ *
22
+ * Deliberately empty here: with no generated file the table has no keys, every helper
23
+ * below falls back to `string`, and an app that has not generated keeps exactly today's
24
+ * behavior. Generating is what turns these checks on.
25
+ */
26
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
27
+ export interface TerpRouteTable {}
28
+
29
+ /** True when no `routes.gen.d.ts` has augmented {@link TerpRouteTable}. */
30
+ type Ungenerated = keyof TerpRouteTable extends never ? true : false;
31
+
32
+ /** Every route path the app's manifests declare — or `string` before generating. */
33
+ export type TerpRoutePath = Ungenerated extends true ? string : keyof TerpRouteTable & string;
34
+
35
+ /** The params of one declared route (`Record<never, never>` for a paramless route). */
36
+ export type TerpRouteParams<P extends TerpRoutePath> = P extends keyof TerpRouteTable
37
+ ? TerpRouteTable[P]
38
+ : Record<string, string>;
39
+
40
+ /**
41
+ * Every param name declared by any route — or `string` before generating.
42
+ *
43
+ * This is the union across all routes, not per route: it refuses a name no route
44
+ * declares (the reported failure mode, where a typo silently yielded `undefined`),
45
+ * while a name belonging to a *different* route stays a runtime refusal. Read params
46
+ * through {@link useRouteParams} when you want the exact, per-route check.
47
+ */
48
+ export type TerpRouteParamName = Ungenerated extends true
49
+ ? string
50
+ : { [P in keyof TerpRouteTable]: keyof TerpRouteTable[P] }[keyof TerpRouteTable] & string;
51
+
52
+ /**
53
+ * A navigation target: a declared path, plus that path's params when it takes any.
54
+ * A paramless route refuses a `params` object; a parameterised one requires it, with
55
+ * the names the manifest declared. Before generating, this is the loose shape.
56
+ */
57
+ export type TerpNavigateTarget = Ungenerated extends true
58
+ ? { to: string; params?: Record<string, string> }
59
+ : {
60
+ [P in keyof TerpRouteTable & string]: keyof TerpRouteTable[P] extends never
61
+ ? { to: P; params?: undefined }
62
+ : { to: P; params: TerpRouteTable[P] };
63
+ }[keyof TerpRouteTable & string];
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { ModuleManifest } from "@terpjs/contract";
7
7
 
8
- import { buildAppRouter, useRouteParam } from "./router";
8
+ import { buildAppRouter, useRouteParam, useRouteParams, useTerpNavigate } from "./router";
9
9
  import { Page } from "./Page";
10
10
  import { TerpProvider, useAuth } from "./TerpProvider";
11
11
 
@@ -16,6 +16,22 @@ function jsonResponse(body: unknown): Response {
16
16
  });
17
17
  }
18
18
 
19
+ /** The login + session-probe fetch a routed test needs to reach a guarded view. */
20
+ function sessionFetch() {
21
+ return vi.fn<typeof fetch>(async (input) => {
22
+ const url = (input as Request).url;
23
+ if (url.endsWith("/api/v1/auth/login")) {
24
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
25
+ }
26
+ return jsonResponse({
27
+ id: "1",
28
+ email: "editor@example.com",
29
+ role_rank: 20,
30
+ role_name: "editor",
31
+ });
32
+ });
33
+ }
34
+
19
35
  afterEach(() => {
20
36
  cleanup();
21
37
  vi.restoreAllMocks();
@@ -187,6 +203,106 @@ describe("buildAppRouter", () => {
187
203
  expect(screen.queryByRole("heading", { name: /Wrong/ })).not.toBeInTheDocument();
188
204
  });
189
205
 
206
+ it("useRouteParams reads a whole declared route's params, and refuses a stale one", async () => {
207
+ // The exact read (ADR 0092): keyed by the manifest path, every param that path
208
+ // declares must be present. A generated table that no longer matches the manifest
209
+ // therefore fails closed naming the param, instead of leaking undefined into a request.
210
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
211
+ vi.stubGlobal("fetch", sessionFetch());
212
+
213
+ function PairView() {
214
+ const { spaceId, itemId } = useRouteParams("/spaces/:spaceId/items/:itemId");
215
+ return <Page title={`${spaceId}/${itemId}`}>pair body</Page>;
216
+ }
217
+ render(
218
+ <TerpProvider baseUrl="https://api.test">
219
+ <LogInOnMount />
220
+ <RouterProvider
221
+ router={buildAppRouter(
222
+ [{ name: "spaces", routes: [{ path: "/spaces/:spaceId/items/:itemId", view: "Pair" }] }],
223
+ {
224
+ views: { Pair: PairView },
225
+ title: "Terp",
226
+ history: createMemoryHistory({ initialEntries: ["/spaces/s1/items/i9"] }),
227
+ },
228
+ )}
229
+ />
230
+ </TerpProvider>,
231
+ );
232
+ await waitFor(() =>
233
+ expect(screen.getByRole("heading", { name: "s1/i9" })).toBeInTheDocument(),
234
+ );
235
+ cleanup();
236
+
237
+ // The same view under a route that declares only one of the two params.
238
+ render(
239
+ <TerpProvider baseUrl="https://api.test">
240
+ <LogInOnMount />
241
+ <RouterProvider
242
+ router={buildAppRouter(
243
+ [{ name: "spaces", routes: [{ path: "/spaces/:spaceId", view: "Pair" }] }],
244
+ {
245
+ views: { Pair: PairView },
246
+ title: "Terp",
247
+ history: createMemoryHistory({ initialEntries: ["/spaces/s1"] }),
248
+ },
249
+ )}
250
+ />
251
+ </TerpProvider>,
252
+ );
253
+ await waitFor(() => expect(console.error).toHaveBeenCalled());
254
+ expect(screen.queryByRole("heading", { name: /s1/ })).not.toBeInTheDocument();
255
+ });
256
+
257
+ it("useTerpNavigate navigates by the manifest's path spelling, carrying params", async () => {
258
+ // The manifest spells a param `:id`; TanStack wants `$id`. useTerpNavigate takes the
259
+ // manifest spelling (what the generated table is keyed by) and translates, so a caller
260
+ // never holds two spellings — and the params ride along.
261
+ vi.stubGlobal("fetch", sessionFetch());
262
+
263
+ function ListView() {
264
+ const navigate = useTerpNavigate();
265
+ return (
266
+ <Page title="Things view">
267
+ <button type="button" onClick={() => void navigate({ to: "/things/:thingId", params: { thingId: "abc" } })}>
268
+ open abc
269
+ </button>
270
+ </Page>
271
+ );
272
+ }
273
+ function DetailView() {
274
+ return <Page title={`Thing ${useRouteParam("thingId")}`}>thing body</Page>;
275
+ }
276
+ render(
277
+ <TerpProvider baseUrl="https://api.test">
278
+ <LogInOnMount />
279
+ <RouterProvider
280
+ router={buildAppRouter(
281
+ [
282
+ {
283
+ name: "things",
284
+ routes: [
285
+ { path: "/things", view: "ThingsList" },
286
+ { path: "/things/:thingId", view: "ThingDetail" },
287
+ ],
288
+ },
289
+ ],
290
+ {
291
+ views: { ThingsList: ListView, ThingDetail: DetailView },
292
+ title: "Terp",
293
+ history: createMemoryHistory({ initialEntries: ["/things"] }),
294
+ },
295
+ )}
296
+ />
297
+ </TerpProvider>,
298
+ );
299
+
300
+ fireEvent.click(await screen.findByRole("button", { name: "open abc" }));
301
+ await waitFor(() =>
302
+ expect(screen.getByRole("heading", { name: "Thing abc" })).toBeInTheDocument(),
303
+ );
304
+ });
305
+
190
306
  it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
191
307
  // A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
192
308
  // page reload, silently, with nothing to catch it. Inside a Terp router the default
package/src/router.tsx CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  createRouter,
5
5
  Link,
6
6
  Outlet,
7
+ useNavigate,
7
8
  useParams,
8
9
  useRouter,
9
10
  type AnyRoute,
@@ -15,6 +16,12 @@ import type { ModuleManifest } from "@terpjs/contract";
15
16
 
16
17
  import { AppShell } from "./AppShell";
17
18
  import { ProfileView } from "./ProfileView";
19
+ import type {
20
+ TerpNavigateTarget,
21
+ TerpRouteParamName,
22
+ TerpRouteParams,
23
+ TerpRoutePath,
24
+ } from "./routeTypes";
18
25
  import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
19
26
  import { visibleNav } from "./nav";
20
27
  import { NavLinkContext } from "./navLink";
@@ -48,33 +55,123 @@ export function routerPath(path: string): string {
48
55
  return path.replace(/(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g, "$1$$$2");
49
56
  }
50
57
 
58
+ /** Read the router's params bag, whatever route matched (a hook: it reads router state). */
59
+ function useCurrentParams(): Record<string, string | undefined> {
60
+ return useParams({ strict: false }) as Record<string, string | undefined>;
61
+ }
62
+
63
+ /** The shared refusal both param reads raise, phrased as a directive. */
64
+ function missingParam(name: string, params: Record<string, string | undefined>): Error {
65
+ const seen = Object.keys(params);
66
+ return new Error(
67
+ `Route param "${name}" is not present on the current route (params seen: ` +
68
+ `${seen.length > 0 ? seen.join(", ") : "none"}). A param is declared in the ` +
69
+ `module manifest's route path (e.g. "/records/:${name}") and must be read ` +
70
+ "under that route — check the name against the manifest, and run `terp routes` " +
71
+ "if the manifest changed.",
72
+ );
73
+ }
74
+
51
75
  /**
52
- * Read one route param, fail closed when it is absent.
76
+ * Read one route param by name, fail closed the untyped core.
53
77
  *
54
- * {@link buildAppRouter} realises routes at runtime from manifest data, so TanStack's
55
- * type-level route registry is empty for every Terp app: `useParams` cannot check a
56
- * param name anywhere, and the raw idiom is an unchecked cast
57
- * `useParams({ strict: false }) as { recordId?: string }` whose typo typechecks
58
- * green and renders a broken screen. Until route params are generated as types
59
- * (ADR 0092), this is the sanctioned read: the param the manifest route path declared
60
- * (`/records/:recordId`) comes back as a string, and a name the current route did not
61
- * declare throws a directive error instead of silently yielding undefined.
78
+ * Internal on purpose. The app-facing {@link useRouteParam} checks the name against the
79
+ * app's generated route table, and this package's own packaged screens (the admin area)
80
+ * must NOT be checked against it: their routes come from this package's manifest, not
81
+ * from the app's, so constraining them to the app's declared params would fail an app
82
+ * that simply declares no params of its own. A packaged screen's route correctness is
83
+ * this package's own test surface.
62
84
  */
63
- export function useRouteParam(name: string): string {
64
- const params = useParams({ strict: false }) as Record<string, string | undefined>;
85
+ export function useDeclaredParam(name: string): string {
86
+ const params = useCurrentParams();
65
87
  const value = params[name];
66
88
  if (value === undefined) {
67
- const seen = Object.keys(params);
68
- throw new Error(
69
- `Route param "${name}" is not present on the current route (params seen: ` +
70
- `${seen.length > 0 ? seen.join(", ") : "none"}). A param is declared in the ` +
71
- `module manifest's route path (e.g. "/records/:${name}") and must be read ` +
72
- "under that route — check the name against the manifest.",
73
- );
89
+ throw missingParam(name, params);
74
90
  }
75
91
  return value;
76
92
  }
77
93
 
94
+ /**
95
+ * Read one route param, fail closed when it is absent.
96
+ *
97
+ * The name is checked against the app's generated route table when there is one
98
+ * ({@link TerpRouteParamName} — every param name any manifest route declares), and is
99
+ * a plain `string` before `terp routes` has generated. Either way a name the *current*
100
+ * route did not declare throws a directive error instead of silently yielding
101
+ * `undefined`, which is what the raw `useParams({ strict: false }) as {…}` cast did.
102
+ *
103
+ * Reach for {@link useRouteParams} when you want the exact, per-route check: keyed by
104
+ * the route path, it refuses a param that route does not declare, not merely one no
105
+ * route declares.
106
+ */
107
+ export function useRouteParam(name: TerpRouteParamName): string {
108
+ return useDeclaredParam(name);
109
+ }
110
+
111
+ /**
112
+ * Read a declared route's params as a typed object — the exact read (ADR 0092).
113
+ *
114
+ * ```tsx
115
+ * const { recordId } = useRouteParams("/records/:recordId");
116
+ * ```
117
+ *
118
+ * Once `terp routes` has generated the app's route table, the path must be one the
119
+ * manifests declare and the returned object carries exactly that route's params, so a
120
+ * typo in either the path or a param name is a typecheck error rather than a runtime
121
+ * surprise. Before generating, the path is a plain `string` and the result is a
122
+ * string-keyed record.
123
+ *
124
+ * The path is the manifest's stack-agnostic spelling (`:recordId`). Every declared
125
+ * param must be present at runtime; a missing one fails closed, which is how a stale
126
+ * generated table (manifest changed, `terp routes` not re-run) surfaces as an error
127
+ * naming the param instead of `undefined` flowing into a request.
128
+ */
129
+ export function useRouteParams<P extends TerpRoutePath>(path: P): TerpRouteParams<P> {
130
+ const params = useCurrentParams();
131
+ const declared = declaredParamNames(path);
132
+ const resolved: Record<string, string> = {};
133
+ for (const name of declared) {
134
+ const value = params[name];
135
+ if (value === undefined) {
136
+ throw missingParam(name, params);
137
+ }
138
+ resolved[name] = value;
139
+ }
140
+ return resolved as TerpRouteParams<P>;
141
+ }
142
+
143
+ /** The param names a manifest path declares, in declaration order (`:name` segments). */
144
+ function declaredParamNames(path: string): string[] {
145
+ return [...path.matchAll(/(?:^|\/)[:$]([A-Za-z_][A-Za-z0-9_]*)/g)].map((match) => match[1]!);
146
+ }
147
+
148
+ /**
149
+ * Navigate to a declared route, with that route's params (ADR 0092).
150
+ *
151
+ * ```tsx
152
+ * const navigate = useTerpNavigate();
153
+ * void navigate({ to: "/records/:recordId", params: { recordId: row.id } });
154
+ * ```
155
+ *
156
+ * The reason to prefer this over the router's own `navigate`: once the route table is
157
+ * generated, an undeclared path is a typecheck error, a parameterised route *requires*
158
+ * its params, and the param names are the manifest's. A typo'd path was previously a
159
+ * dead link that shipped green — nothing checked it, because the route tree is built at
160
+ * runtime. Paths are written in the manifest spelling and translated to the router's
161
+ * dialect here ({@link routerPath}), so callers never hold two spellings.
162
+ */
163
+ export function useTerpNavigate(): (target: TerpNavigateTarget) => Promise<void> {
164
+ const navigate = useNavigate();
165
+ return (target: TerpNavigateTarget) =>
166
+ navigate({
167
+ to: routerPath(target.to),
168
+ // The reducer form, not a bare object: on a router whose route tree is built at
169
+ // runtime TanStack types `params` as a reducer (or `true`), and merging over the
170
+ // previous params is also the honest semantic for an in-place param change.
171
+ params: (previous: Record<string, unknown>) => ({ ...previous, ...(target.params ?? {}) }),
172
+ });
173
+ }
174
+
78
175
  export interface BuildAppRouterOptions {
79
176
  /** Maps a manifest route's `view` id to the component that renders it. */
80
177
  views: Record<string, ComponentType>;