@real-router/core 0.22.0 → 0.23.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 (80) hide show
  1. package/README.md +1 -3
  2. package/dist/cjs/index.d.ts +1 -1
  3. package/dist/cjs/index.js +1 -1
  4. package/dist/cjs/index.js.map +1 -1
  5. package/dist/cjs/metafile-cjs.json +1 -1
  6. package/dist/esm/index.d.mts +1 -1
  7. package/dist/esm/index.mjs +1 -1
  8. package/dist/esm/index.mjs.map +1 -1
  9. package/dist/esm/metafile-esm.json +1 -1
  10. package/package.json +7 -5
  11. package/src/Router.ts +1174 -0
  12. package/src/RouterError.ts +324 -0
  13. package/src/constants.ts +112 -0
  14. package/src/createRouter.ts +32 -0
  15. package/src/fsm/index.ts +5 -0
  16. package/src/fsm/routerFSM.ts +129 -0
  17. package/src/getNavigator.ts +15 -0
  18. package/src/helpers.ts +194 -0
  19. package/src/index.ts +46 -0
  20. package/src/namespaces/CloneNamespace/CloneNamespace.ts +120 -0
  21. package/src/namespaces/CloneNamespace/index.ts +3 -0
  22. package/src/namespaces/CloneNamespace/types.ts +46 -0
  23. package/src/namespaces/DependenciesNamespace/DependenciesNamespace.ts +250 -0
  24. package/src/namespaces/DependenciesNamespace/index.ts +3 -0
  25. package/src/namespaces/DependenciesNamespace/validators.ts +105 -0
  26. package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +272 -0
  27. package/src/namespaces/EventBusNamespace/index.ts +5 -0
  28. package/src/namespaces/EventBusNamespace/types.ts +11 -0
  29. package/src/namespaces/MiddlewareNamespace/MiddlewareNamespace.ts +206 -0
  30. package/src/namespaces/MiddlewareNamespace/index.ts +5 -0
  31. package/src/namespaces/MiddlewareNamespace/types.ts +28 -0
  32. package/src/namespaces/MiddlewareNamespace/validators.ts +96 -0
  33. package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +308 -0
  34. package/src/namespaces/NavigationNamespace/index.ts +5 -0
  35. package/src/namespaces/NavigationNamespace/transition/executeLifecycleHooks.ts +84 -0
  36. package/src/namespaces/NavigationNamespace/transition/executeMiddleware.ts +56 -0
  37. package/src/namespaces/NavigationNamespace/transition/index.ts +107 -0
  38. package/src/namespaces/NavigationNamespace/transition/makeError.ts +37 -0
  39. package/src/namespaces/NavigationNamespace/transition/mergeStates.ts +54 -0
  40. package/src/namespaces/NavigationNamespace/transition/processLifecycleResult.ts +81 -0
  41. package/src/namespaces/NavigationNamespace/transition/wrapSyncError.ts +82 -0
  42. package/src/namespaces/NavigationNamespace/types.ts +129 -0
  43. package/src/namespaces/NavigationNamespace/validators.ts +87 -0
  44. package/src/namespaces/OptionsNamespace/OptionsNamespace.ts +50 -0
  45. package/src/namespaces/OptionsNamespace/constants.ts +41 -0
  46. package/src/namespaces/OptionsNamespace/helpers.ts +51 -0
  47. package/src/namespaces/OptionsNamespace/index.ts +11 -0
  48. package/src/namespaces/OptionsNamespace/validators.ts +252 -0
  49. package/src/namespaces/PluginsNamespace/PluginsNamespace.ts +325 -0
  50. package/src/namespaces/PluginsNamespace/constants.ts +35 -0
  51. package/src/namespaces/PluginsNamespace/index.ts +7 -0
  52. package/src/namespaces/PluginsNamespace/types.ts +32 -0
  53. package/src/namespaces/PluginsNamespace/validators.ts +79 -0
  54. package/src/namespaces/RouteLifecycleNamespace/RouteLifecycleNamespace.ts +389 -0
  55. package/src/namespaces/RouteLifecycleNamespace/index.ts +5 -0
  56. package/src/namespaces/RouteLifecycleNamespace/types.ts +17 -0
  57. package/src/namespaces/RouteLifecycleNamespace/validators.ts +65 -0
  58. package/src/namespaces/RouterLifecycleNamespace/RouterLifecycleNamespace.ts +140 -0
  59. package/src/namespaces/RouterLifecycleNamespace/constants.ts +25 -0
  60. package/src/namespaces/RouterLifecycleNamespace/index.ts +5 -0
  61. package/src/namespaces/RouterLifecycleNamespace/types.ts +23 -0
  62. package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +1482 -0
  63. package/src/namespaces/RoutesNamespace/constants.ts +14 -0
  64. package/src/namespaces/RoutesNamespace/helpers.ts +532 -0
  65. package/src/namespaces/RoutesNamespace/index.ts +9 -0
  66. package/src/namespaces/RoutesNamespace/stateBuilder.ts +70 -0
  67. package/src/namespaces/RoutesNamespace/types.ts +82 -0
  68. package/src/namespaces/RoutesNamespace/validators.ts +331 -0
  69. package/src/namespaces/StateNamespace/StateNamespace.ts +317 -0
  70. package/src/namespaces/StateNamespace/helpers.ts +43 -0
  71. package/src/namespaces/StateNamespace/index.ts +5 -0
  72. package/src/namespaces/StateNamespace/types.ts +15 -0
  73. package/src/namespaces/index.ts +42 -0
  74. package/src/transitionPath.ts +441 -0
  75. package/src/typeGuards.ts +74 -0
  76. package/src/types.ts +194 -0
  77. package/src/wiring/RouterWiringBuilder.ts +235 -0
  78. package/src/wiring/index.ts +7 -0
  79. package/src/wiring/types.ts +53 -0
  80. package/src/wiring/wireRouter.ts +29 -0
@@ -0,0 +1,1482 @@
1
+ // packages/core/src/namespaces/RoutesNamespace/RoutesNamespace.ts
2
+
3
+ import { logger } from "@real-router/logger";
4
+ import {
5
+ createMatcher,
6
+ createRouteTree,
7
+ nodeToDefinition,
8
+ routeTreeToDefinitions,
9
+ } from "route-tree";
10
+ import { isString, validateRouteName } from "type-guards";
11
+
12
+ import { DEFAULT_ROUTE_NAME, validatedRouteNames } from "./constants";
13
+ import {
14
+ clearConfigEntries,
15
+ createEmptyConfig,
16
+ paramsMatch,
17
+ paramsMatchExcluding,
18
+ removeFromDefinitions,
19
+ resolveForwardChain,
20
+ sanitizeRoute,
21
+ } from "./helpers";
22
+ import { createRouteState } from "./stateBuilder";
23
+ import {
24
+ validateRemoveRouteArgs,
25
+ validateSetRootPathArgs,
26
+ validateAddRouteArgs,
27
+ validateParentOption,
28
+ validateIsActiveRouteArgs,
29
+ validateStateBuilderArgs,
30
+ validateUpdateRouteBasicArgs,
31
+ validateUpdateRoutePropertyTypes,
32
+ validateBuildPathArgs,
33
+ validateMatchPathArgs,
34
+ validateShouldUpdateNodeArgs,
35
+ validateRoutes,
36
+ } from "./validators";
37
+ import { constants } from "../../constants";
38
+ import { getTransitionPath } from "../../transitionPath";
39
+
40
+ import type { RouteConfig, RoutesDependencies } from "./types";
41
+ import type {
42
+ ActivationFnFactory,
43
+ BuildStateResultWithSegments,
44
+ Route,
45
+ RouteConfigUpdate,
46
+ } from "../../types";
47
+ import type { RouteLifecycleNamespace } from "../RouteLifecycleNamespace";
48
+ import type {
49
+ DefaultDependencies,
50
+ ForwardToCallback,
51
+ Options,
52
+ Params,
53
+ State,
54
+ } from "@real-router/types";
55
+ import type {
56
+ CreateMatcherOptions,
57
+ Matcher,
58
+ RouteDefinition,
59
+ RouteTree,
60
+ RouteTreeState,
61
+ } from "route-tree";
62
+
63
+ const EMPTY_OPTIONS = Object.freeze({});
64
+
65
+ /**
66
+ * Independent namespace for managing routes.
67
+ *
68
+ * Static methods handle validation (called by facade).
69
+ * Instance methods handle storage and business logic.
70
+ */
71
+ export class RoutesNamespace<
72
+ Dependencies extends DefaultDependencies = DefaultDependencies,
73
+ > {
74
+ // =========================================================================
75
+ // Private instance fields
76
+ // =========================================================================
77
+
78
+ readonly #definitions: RouteDefinition[] = [];
79
+ readonly #config: RouteConfig = createEmptyConfig();
80
+ readonly #resolvedForwardMap: Record<string, string> = Object.create(
81
+ null,
82
+ ) as Record<string, string>;
83
+
84
+ // Pending canActivate handlers that need to be registered after router is set
85
+ // Key: route name, Value: canActivate factory
86
+ readonly #pendingCanActivate = new Map<
87
+ string,
88
+ ActivationFnFactory<Dependencies>
89
+ >();
90
+
91
+ // Pending canDeactivate handlers that need to be registered after router is set
92
+ // Key: route name, Value: canDeactivate factory
93
+ readonly #pendingCanDeactivate = new Map<
94
+ string,
95
+ ActivationFnFactory<Dependencies>
96
+ >();
97
+
98
+ #rootPath = "";
99
+ #tree: RouteTree;
100
+ #matcher: Matcher;
101
+ readonly #matcherOptions: CreateMatcherOptions | undefined;
102
+
103
+ // Dependencies injected via setDependencies (for facade method calls)
104
+ #depsStore: RoutesDependencies<Dependencies> | undefined;
105
+
106
+ // Lifecycle handlers reference (set after construction)
107
+ #lifecycleNamespace!: RouteLifecycleNamespace<Dependencies>;
108
+
109
+ // When true, skips validation for production performance
110
+ readonly #noValidate: boolean;
111
+
112
+ /**
113
+ * Gets dependencies or throws if not initialized.
114
+ */
115
+ get #deps(): RoutesDependencies<Dependencies> {
116
+ /* v8 ignore next 3 -- @preserve: defensive guard, unreachable via public API (RouterWiringBuilder always calls setDependencies) */
117
+ if (!this.#depsStore) {
118
+ throw new Error(
119
+ "[real-router] RoutesNamespace: dependencies not initialized",
120
+ );
121
+ }
122
+
123
+ return this.#depsStore;
124
+ }
125
+
126
+ // =========================================================================
127
+ // Constructor
128
+ // =========================================================================
129
+
130
+ constructor(
131
+ routes: Route<Dependencies>[] = [],
132
+ noValidate = false,
133
+ matcherOptions?: CreateMatcherOptions,
134
+ ) {
135
+ this.#noValidate = noValidate;
136
+ this.#matcherOptions = matcherOptions;
137
+
138
+ // Sanitize routes to store only essential properties
139
+ for (const route of routes) {
140
+ this.#definitions.push(sanitizeRoute(route));
141
+ }
142
+
143
+ // Create initial tree
144
+ this.#tree = createRouteTree(
145
+ DEFAULT_ROUTE_NAME,
146
+ this.#rootPath,
147
+ this.#definitions,
148
+ );
149
+
150
+ // Initialize matcher with options and register tree
151
+ this.#matcher = createMatcher(matcherOptions);
152
+ this.#matcher.registerTree(this.#tree);
153
+
154
+ // Register handlers for all routes (defaultParams, encoders, decoders, forwardTo)
155
+ // Note: canActivate handlers are registered later when #lifecycleNamespace is set
156
+ this.#registerAllRouteHandlers(routes);
157
+
158
+ // Validate and cache forwardTo chains (detect cycles)
159
+ // Skip validation in noValidate mode for production performance
160
+ if (noValidate) {
161
+ // Still need to cache resolved forwards, just skip validation
162
+ this.#cacheForwardMap();
163
+ } else {
164
+ this.#validateAndCacheForwardMap();
165
+ }
166
+ }
167
+
168
+ // =========================================================================
169
+ // Static validation methods (delegated to validators.ts)
170
+ // TypeScript requires explicit method declarations for assertion functions
171
+ // =========================================================================
172
+
173
+ static validateRemoveRouteArgs(name: unknown): asserts name is string {
174
+ validateRemoveRouteArgs(name);
175
+ }
176
+
177
+ static validateSetRootPathArgs(
178
+ rootPath: unknown,
179
+ ): asserts rootPath is string {
180
+ validateSetRootPathArgs(rootPath);
181
+ }
182
+
183
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Route type
184
+ static validateAddRouteArgs(routes: readonly Route<any>[]): void {
185
+ validateAddRouteArgs(routes);
186
+ }
187
+
188
+ static validateParentOption(parent: unknown): asserts parent is string {
189
+ validateParentOption(parent);
190
+ }
191
+
192
+ static validateIsActiveRouteArgs(
193
+ name: unknown,
194
+ params: unknown,
195
+ strictEquality: unknown,
196
+ ignoreQueryParams: unknown,
197
+ ): void {
198
+ validateIsActiveRouteArgs(name, params, strictEquality, ignoreQueryParams);
199
+ }
200
+
201
+ static validateStateBuilderArgs(
202
+ routeName: unknown,
203
+ routeParams: unknown,
204
+ methodName: string,
205
+ ): void {
206
+ validateStateBuilderArgs(routeName, routeParams, methodName);
207
+ }
208
+
209
+ static validateUpdateRouteBasicArgs<Deps extends DefaultDependencies>(
210
+ name: unknown,
211
+ updates: unknown,
212
+ ): asserts updates is RouteConfigUpdate<Deps> {
213
+ validateUpdateRouteBasicArgs<Deps>(name, updates);
214
+ }
215
+
216
+ static validateUpdateRoutePropertyTypes(
217
+ forwardTo: unknown,
218
+ defaultParams: unknown,
219
+ decodeParams: unknown,
220
+ encodeParams: unknown,
221
+ ): void {
222
+ validateUpdateRoutePropertyTypes(
223
+ forwardTo,
224
+ defaultParams,
225
+ decodeParams,
226
+ encodeParams,
227
+ );
228
+ }
229
+
230
+ static validateBuildPathArgs(route: unknown): asserts route is string {
231
+ validateBuildPathArgs(route);
232
+ }
233
+
234
+ static validateMatchPathArgs(path: unknown): asserts path is string {
235
+ validateMatchPathArgs(path);
236
+ }
237
+
238
+ static validateShouldUpdateNodeArgs(
239
+ nodeName: unknown,
240
+ ): asserts nodeName is string {
241
+ validateShouldUpdateNodeArgs(nodeName);
242
+ }
243
+
244
+ static validateRoutes<Deps extends DefaultDependencies>(
245
+ routes: Route<Deps>[],
246
+ tree?: RouteTree,
247
+ forwardMap?: Record<string, string>,
248
+ parentName?: string,
249
+ ): void {
250
+ validateRoutes(routes, tree, forwardMap, parentName);
251
+ }
252
+
253
+ // =========================================================================
254
+ // Dependency injection
255
+ // =========================================================================
256
+
257
+ /**
258
+ * Sets dependencies and registers pending canActivate handlers.
259
+ * canActivate handlers from initial routes are deferred until deps are set.
260
+ */
261
+ setDependencies(deps: RoutesDependencies<Dependencies>): void {
262
+ this.#depsStore = deps;
263
+
264
+ // Register pending canActivate handlers that were deferred during construction
265
+ for (const [routeName, handler] of this.#pendingCanActivate) {
266
+ deps.addActivateGuard(routeName, handler);
267
+ }
268
+
269
+ // Clear pending handlers after registration
270
+ this.#pendingCanActivate.clear();
271
+
272
+ // Register pending canDeactivate handlers that were deferred during construction
273
+ for (const [routeName, handler] of this.#pendingCanDeactivate) {
274
+ deps.addDeactivateGuard(routeName, handler);
275
+ }
276
+
277
+ // Clear pending handlers after registration
278
+ this.#pendingCanDeactivate.clear();
279
+ }
280
+
281
+ /**
282
+ * Sets the lifecycle namespace reference.
283
+ */
284
+ setLifecycleNamespace(
285
+ namespace: RouteLifecycleNamespace<Dependencies> | undefined,
286
+ ): void {
287
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
288
+ this.#lifecycleNamespace = namespace!;
289
+ }
290
+
291
+ // =========================================================================
292
+ // Route tree operations
293
+ // =========================================================================
294
+
295
+ /**
296
+ * Returns the root path.
297
+ */
298
+ getRootPath(): string {
299
+ return this.#rootPath;
300
+ }
301
+
302
+ /**
303
+ * Returns the route tree.
304
+ * Used by facade for state-dependent validation.
305
+ */
306
+ getTree(): RouteTree {
307
+ return this.#tree;
308
+ }
309
+
310
+ /**
311
+ * Returns the forward record (route name -> forward target).
312
+ * Used by facade for state-dependent validation.
313
+ */
314
+ getForwardRecord(): Record<string, string> {
315
+ return this.#config.forwardMap;
316
+ }
317
+
318
+ /**
319
+ * Sets the root path and rebuilds the tree.
320
+ */
321
+ setRootPath(newRootPath: string): void {
322
+ this.#rootPath = newRootPath;
323
+ this.#rebuildTree();
324
+ }
325
+
326
+ /**
327
+ * Checks if a route exists.
328
+ */
329
+ hasRoute(name: string): boolean {
330
+ return this.#matcher.hasRoute(name);
331
+ }
332
+
333
+ /**
334
+ * Gets a route by name with all its configuration.
335
+ */
336
+ getRoute(name: string): Route<Dependencies> | undefined {
337
+ const segments = this.#matcher.getSegmentsByName(name);
338
+
339
+ if (!segments) {
340
+ return undefined;
341
+ }
342
+
343
+ const targetNode = this.#getLastSegment(segments as readonly RouteTree[]);
344
+ const definition = nodeToDefinition(targetNode);
345
+
346
+ return this.#enrichRoute(definition, name);
347
+ }
348
+
349
+ /**
350
+ * Adds one or more routes to the router.
351
+ * Input already validated by facade (properties and state-dependent checks).
352
+ *
353
+ * @param routes - Routes to add
354
+ * @param parentName - Optional parent route fullName for nesting
355
+ */
356
+ addRoutes(routes: Route<Dependencies>[], parentName?: string): void {
357
+ // Add to definitions
358
+ if (parentName) {
359
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
360
+ const parentDef = this.#findDefinition(this.#definitions, parentName)!;
361
+
362
+ parentDef.children ??= [];
363
+ for (const route of routes) {
364
+ parentDef.children.push(sanitizeRoute(route));
365
+ }
366
+ } else {
367
+ for (const route of routes) {
368
+ this.#definitions.push(sanitizeRoute(route));
369
+ }
370
+ }
371
+
372
+ // Register handlers
373
+ this.#registerAllRouteHandlers(routes, parentName ?? "");
374
+
375
+ // Rebuild tree
376
+ this.#rebuildTree();
377
+
378
+ // Validate and cache forwardTo chains
379
+ this.#refreshForwardMap();
380
+ }
381
+
382
+ /**
383
+ * Removes a route and all its children.
384
+ *
385
+ * @param name - Route name (already validated)
386
+ * @returns true if removed, false if not found
387
+ */
388
+ removeRoute(name: string): boolean {
389
+ const wasRemoved = removeFromDefinitions(this.#definitions, name);
390
+
391
+ if (!wasRemoved) {
392
+ return false;
393
+ }
394
+
395
+ // Clear configurations for removed route
396
+ this.#clearRouteConfigurations(name);
397
+
398
+ // Rebuild tree
399
+ this.#rebuildTree();
400
+
401
+ // Revalidate forward chains
402
+ this.#refreshForwardMap();
403
+
404
+ return true;
405
+ }
406
+
407
+ /**
408
+ * Updates a route's configuration in place without rebuilding the tree.
409
+ * This is used by Router.updateRoute to directly modify config entries
410
+ * without destroying other routes' forwardMap references.
411
+ *
412
+ * @param name - Route name
413
+ * @param updates - Config updates to apply
414
+ * @param updates.forwardTo - Forward target route name (null to clear)
415
+ * @param updates.defaultParams - Default parameters (null to clear)
416
+ * @param updates.decodeParams - Params decoder function (null to clear)
417
+ * @param updates.encodeParams - Params encoder function (null to clear)
418
+ */
419
+
420
+ updateRouteConfig(
421
+ name: string,
422
+ updates: {
423
+ forwardTo?: string | ForwardToCallback<Dependencies> | null | undefined;
424
+ defaultParams?: Params | null | undefined;
425
+ decodeParams?: ((params: Params) => Params) | null | undefined;
426
+ encodeParams?: ((params: Params) => Params) | null | undefined;
427
+ },
428
+ ): void {
429
+ // Update forwardTo
430
+ if (updates.forwardTo !== undefined) {
431
+ this.#updateForwardTo(name, updates.forwardTo);
432
+ }
433
+
434
+ // Update defaultParams
435
+ if (updates.defaultParams !== undefined) {
436
+ if (updates.defaultParams === null) {
437
+ delete this.#config.defaultParams[name];
438
+ } else {
439
+ this.#config.defaultParams[name] = updates.defaultParams;
440
+ }
441
+ }
442
+
443
+ // Update decoders with fallback wrapper
444
+ // Runtime guard: fallback to params if decoder returns undefined (bad user code)
445
+ if (updates.decodeParams !== undefined) {
446
+ if (updates.decodeParams === null) {
447
+ delete this.#config.decoders[name];
448
+ } else {
449
+ const decoder = updates.decodeParams;
450
+
451
+ this.#config.decoders[name] = (params: Params): Params =>
452
+ (decoder(params) as Params | undefined) ?? params;
453
+ }
454
+ }
455
+
456
+ // Update encoders with fallback wrapper
457
+ // Runtime guard: fallback to params if encoder returns undefined (bad user code)
458
+ if (updates.encodeParams !== undefined) {
459
+ if (updates.encodeParams === null) {
460
+ delete this.#config.encoders[name];
461
+ } else {
462
+ const encoder = updates.encodeParams;
463
+
464
+ this.#config.encoders[name] = (params: Params): Params =>
465
+ (encoder(params) as Params | undefined) ?? params;
466
+ }
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Clears all routes from the router.
472
+ */
473
+ clearRoutes(): void {
474
+ this.#definitions.length = 0;
475
+
476
+ // Clear all config entries
477
+ for (const key in this.#config.decoders) {
478
+ delete this.#config.decoders[key];
479
+ }
480
+
481
+ for (const key in this.#config.encoders) {
482
+ delete this.#config.encoders[key];
483
+ }
484
+
485
+ for (const key in this.#config.defaultParams) {
486
+ delete this.#config.defaultParams[key];
487
+ }
488
+
489
+ for (const key in this.#config.forwardMap) {
490
+ delete this.#config.forwardMap[key];
491
+ }
492
+
493
+ for (const key in this.#config.forwardFnMap) {
494
+ delete this.#config.forwardFnMap[key];
495
+ }
496
+
497
+ // Clear forward cache
498
+ for (const key in this.#resolvedForwardMap) {
499
+ delete this.#resolvedForwardMap[key];
500
+ }
501
+
502
+ // Rebuild empty tree
503
+ this.#rebuildTree();
504
+ }
505
+
506
+ // =========================================================================
507
+ // Path operations
508
+ // =========================================================================
509
+
510
+ /**
511
+ * Builds a URL path for a route.
512
+ * Note: Argument validation is done by facade (Router.ts) via validateBuildPathArgs.
513
+ *
514
+ * @param route - Route name
515
+ * @param params - Route parameters
516
+ * @param options - Router options
517
+ */
518
+ buildPath(route: string, params?: Params, options?: Options): string {
519
+ if (route === constants.UNKNOWN_ROUTE) {
520
+ return isString(params?.path) ? params.path : "";
521
+ }
522
+
523
+ // R2 optimization: avoid spread when no defaultParams
524
+ const paramsWithDefault = Object.hasOwn(this.#config.defaultParams, route)
525
+ ? { ...this.#config.defaultParams[route], ...params }
526
+ : (params ?? {});
527
+
528
+ // Apply custom encoder if defined
529
+ const encodedParams =
530
+ typeof this.#config.encoders[route] === "function"
531
+ ? this.#config.encoders[route]({ ...paramsWithDefault })
532
+ : paramsWithDefault;
533
+
534
+ // Map core trailingSlash to matcher: "preserve"/"strict" → default (no change)
535
+ const ts = options?.trailingSlash;
536
+ const trailingSlash = ts === "never" || ts === "always" ? ts : undefined;
537
+
538
+ return this.#matcher.buildPath(route, encodedParams, {
539
+ trailingSlash,
540
+ queryParamsMode: options?.queryParamsMode,
541
+ });
542
+ }
543
+
544
+ /**
545
+ * Matches a URL path to a route in the tree.
546
+ * Note: Argument validation is done by facade (Router.ts) via validateMatchPathArgs.
547
+ */
548
+ matchPath<P extends Params = Params, MP extends Params = Params>(
549
+ path: string,
550
+ options?: Options,
551
+ ): State<P, MP> | undefined {
552
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Router.ts always passes options
553
+ const opts = options!;
554
+
555
+ const matchResult = this.#matcher.match(path);
556
+
557
+ if (!matchResult) {
558
+ return undefined;
559
+ }
560
+
561
+ const routeState = createRouteState(matchResult);
562
+ const { name, params, meta } = routeState;
563
+
564
+ const decodedParams =
565
+ typeof this.#config.decoders[name] === "function"
566
+ ? this.#config.decoders[name](params as Params)
567
+ : params;
568
+
569
+ const { name: routeName, params: routeParams } = this.#deps.forwardState<P>(
570
+ name,
571
+ decodedParams as P,
572
+ );
573
+
574
+ let builtPath = path;
575
+
576
+ if (opts.rewritePathOnMatch) {
577
+ const buildParams =
578
+ typeof this.#config.encoders[routeName] === "function"
579
+ ? this.#config.encoders[routeName]({
580
+ ...(routeParams as Params),
581
+ })
582
+ : (routeParams as Record<string, unknown>);
583
+
584
+ const ts = opts.trailingSlash;
585
+
586
+ builtPath = this.#matcher.buildPath(routeName, buildParams, {
587
+ trailingSlash: ts === "never" || ts === "always" ? ts : undefined,
588
+ queryParamsMode: opts.queryParamsMode,
589
+ });
590
+ }
591
+
592
+ return this.#deps.makeState<P, MP>(routeName, routeParams, builtPath, {
593
+ params: meta as MP,
594
+ options: EMPTY_OPTIONS,
595
+ });
596
+ }
597
+
598
+ /**
599
+ * Applies forwardTo and returns resolved state with merged defaultParams.
600
+ *
601
+ * Merges params in order:
602
+ * 1. Source route defaultParams
603
+ * 2. Provided params
604
+ * 3. Target route defaultParams (after resolving forwardTo)
605
+ */
606
+ forwardState<P extends Params = Params>(
607
+ name: string,
608
+ params: P,
609
+ ): { name: string; params: P } {
610
+ // Path 1: Dynamic forward
611
+ if (Object.hasOwn(this.#config.forwardFnMap, name)) {
612
+ const paramsWithSourceDefaults = this.#mergeDefaultParams(name, params);
613
+ const dynamicForward = this.#config.forwardFnMap[name];
614
+ const resolved = this.#resolveDynamicForward(
615
+ name,
616
+ dynamicForward,
617
+ params,
618
+ );
619
+
620
+ return {
621
+ name: resolved,
622
+ params: this.#mergeDefaultParams(resolved, paramsWithSourceDefaults),
623
+ };
624
+ }
625
+
626
+ // Path 2: Static forward (O(1) cached)
627
+ const staticForward = this.#resolvedForwardMap[name] ?? name;
628
+
629
+ // Path 3: Mixed chain (static target has dynamic forward)
630
+ if (
631
+ staticForward !== name &&
632
+ Object.hasOwn(this.#config.forwardFnMap, staticForward)
633
+ ) {
634
+ const paramsWithSourceDefaults = this.#mergeDefaultParams(name, params);
635
+ const targetDynamicForward = this.#config.forwardFnMap[staticForward];
636
+ const resolved = this.#resolveDynamicForward(
637
+ staticForward,
638
+ targetDynamicForward,
639
+ params,
640
+ );
641
+
642
+ return {
643
+ name: resolved,
644
+ params: this.#mergeDefaultParams(resolved, paramsWithSourceDefaults),
645
+ };
646
+ }
647
+
648
+ // Path 4: Static forward only
649
+ if (staticForward !== name) {
650
+ const paramsWithSourceDefaults = this.#mergeDefaultParams(name, params);
651
+
652
+ return {
653
+ name: staticForward,
654
+ params: this.#mergeDefaultParams(
655
+ staticForward,
656
+ paramsWithSourceDefaults,
657
+ ),
658
+ };
659
+ }
660
+
661
+ // No forward - merge own defaults
662
+ return { name, params: this.#mergeDefaultParams(name, params) };
663
+ }
664
+
665
+ /**
666
+ * Builds a RouteTreeState from already-resolved route name and params.
667
+ * Called by Router.buildState after forwardState is applied at facade level.
668
+ * This allows plugins to intercept forwardState.
669
+ */
670
+ buildStateResolved(
671
+ resolvedName: string,
672
+ resolvedParams: Params,
673
+ ): RouteTreeState | undefined {
674
+ const segments = this.#matcher.getSegmentsByName(resolvedName);
675
+
676
+ if (!segments) {
677
+ return undefined;
678
+ }
679
+
680
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
681
+ const meta = this.#matcher.getMetaByName(resolvedName)!;
682
+
683
+ return createRouteState(
684
+ { segments, params: resolvedParams, meta },
685
+ resolvedName,
686
+ );
687
+ }
688
+
689
+ /**
690
+ * Builds a RouteTreeState with segments from already-resolved route name and params.
691
+ * Called by Router.buildStateWithSegments after forwardState is applied at facade level.
692
+ * This allows plugins to intercept forwardState.
693
+ */
694
+ buildStateWithSegmentsResolved<P extends Params = Params>(
695
+ resolvedName: string,
696
+ resolvedParams: P,
697
+ ): BuildStateResultWithSegments<P> | undefined {
698
+ const segments = this.#matcher.getSegmentsByName(resolvedName);
699
+
700
+ if (!segments) {
701
+ return undefined;
702
+ }
703
+
704
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
705
+ const meta = this.#matcher.getMetaByName(resolvedName)!;
706
+ const state = createRouteState<P>(
707
+ {
708
+ segments: segments as readonly RouteTree[],
709
+ params: resolvedParams,
710
+ meta,
711
+ },
712
+ resolvedName,
713
+ );
714
+
715
+ return { state, segments: segments as readonly RouteTree[] };
716
+ }
717
+
718
+ // =========================================================================
719
+ // Query operations
720
+ // =========================================================================
721
+
722
+ /**
723
+ * Checks if a route is currently active.
724
+ */
725
+ isActiveRoute(
726
+ name: string,
727
+ params: Params = {},
728
+ strictEquality = false,
729
+ ignoreQueryParams = true,
730
+ ): boolean {
731
+ // Fast path: skip regex validation for already-validated route names
732
+ if (!validatedRouteNames.has(name)) {
733
+ validateRouteName(name, "isActiveRoute");
734
+ validatedRouteNames.add(name);
735
+ }
736
+
737
+ // Note: empty string check is handled by Router.ts facade
738
+ const activeState = this.#deps.getState();
739
+
740
+ if (!activeState) {
741
+ return false;
742
+ }
743
+
744
+ const activeName = activeState.name;
745
+
746
+ // Fast path: check if routes are related before expensive operations
747
+ if (
748
+ activeName !== name &&
749
+ !activeName.startsWith(`${name}.`) &&
750
+ !name.startsWith(`${activeName}.`)
751
+ ) {
752
+ return false;
753
+ }
754
+
755
+ const defaultParams = this.#config.defaultParams[name] as
756
+ | Params
757
+ | undefined;
758
+
759
+ // Exact match case
760
+ if (strictEquality || activeName === name) {
761
+ const effectiveParams = defaultParams
762
+ ? { ...defaultParams, ...params }
763
+ : params;
764
+
765
+ const targetState: State = {
766
+ name,
767
+ params: effectiveParams,
768
+ path: "",
769
+ };
770
+
771
+ return this.#deps.areStatesEqual(
772
+ targetState,
773
+ activeState,
774
+ ignoreQueryParams,
775
+ );
776
+ }
777
+
778
+ // Hierarchical check: activeState is a descendant of target (name)
779
+ const activeParams = activeState.params;
780
+
781
+ if (!paramsMatch(params, activeParams)) {
782
+ return false;
783
+ }
784
+
785
+ // Check defaultParams (skip keys already in params)
786
+ return (
787
+ !defaultParams ||
788
+ paramsMatchExcluding(defaultParams, activeParams, params)
789
+ );
790
+ }
791
+
792
+ /**
793
+ * Creates a predicate function to check if a route node should be updated.
794
+ * Note: Argument validation is done by facade (Router.ts) via validateShouldUpdateNodeArgs.
795
+ */
796
+ shouldUpdateNode(
797
+ nodeName: string,
798
+ ): (toState: State, fromState?: State) => boolean {
799
+ return (toState: State, fromState?: State): boolean => {
800
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
801
+ if (!(toState && typeof toState === "object" && "name" in toState)) {
802
+ throw new TypeError(
803
+ "[router.shouldUpdateNode] toState must be valid State object",
804
+ );
805
+ }
806
+
807
+ if (toState.meta?.options.reload) {
808
+ return true;
809
+ }
810
+
811
+ if (nodeName === DEFAULT_ROUTE_NAME && !fromState) {
812
+ return true;
813
+ }
814
+
815
+ const { intersection, toActivate, toDeactivate } = getTransitionPath(
816
+ toState,
817
+ fromState,
818
+ );
819
+
820
+ if (nodeName === intersection) {
821
+ return true;
822
+ }
823
+
824
+ if (toActivate.includes(nodeName)) {
825
+ return true;
826
+ }
827
+
828
+ return toDeactivate.includes(nodeName);
829
+ };
830
+ }
831
+
832
+ /**
833
+ * Returns the config object.
834
+ */
835
+ getConfig(): RouteConfig {
836
+ return this.#config;
837
+ }
838
+
839
+ /**
840
+ * Returns URL params for a route.
841
+ * Used by StateNamespace.
842
+ */
843
+ getUrlParams(name: string): string[] {
844
+ const segments = this.#matcher.getSegmentsByName(name);
845
+
846
+ if (!segments) {
847
+ return [];
848
+ }
849
+
850
+ return this.#collectUrlParamsArray(segments as readonly RouteTree[]);
851
+ }
852
+
853
+ /**
854
+ * Returns the resolved forward map.
855
+ */
856
+ getResolvedForwardMap(): Record<string, string> {
857
+ return this.#resolvedForwardMap;
858
+ }
859
+
860
+ /**
861
+ * Sets resolved forward map (used by clone).
862
+ */
863
+ setResolvedForwardMap(map: Record<string, string>): void {
864
+ Object.assign(this.#resolvedForwardMap, map);
865
+ }
866
+
867
+ /**
868
+ * Applies cloned route config from source router.
869
+ * Used by clone to copy decoders, encoders, defaultParams, forwardMap,
870
+ * forwardFnMap and resolvedForwardMap into this namespace's config.
871
+ */
872
+ applyClonedConfig(
873
+ config: RouteConfig,
874
+ resolvedForwardMap: Record<string, string>,
875
+ ): void {
876
+ Object.assign(this.#config.decoders, config.decoders);
877
+ Object.assign(this.#config.encoders, config.encoders);
878
+ Object.assign(this.#config.defaultParams, config.defaultParams);
879
+ Object.assign(this.#config.forwardMap, config.forwardMap);
880
+ Object.assign(this.#config.forwardFnMap, config.forwardFnMap);
881
+ this.setResolvedForwardMap({ ...resolvedForwardMap });
882
+ }
883
+
884
+ /**
885
+ * Creates a clone of the routes for a new router (from tree).
886
+ */
887
+ cloneRoutes(): Route<Dependencies>[] {
888
+ return routeTreeToDefinitions(this.#tree) as Route<Dependencies>[];
889
+ }
890
+
891
+ // =========================================================================
892
+ // Public validation methods (used by Router facade)
893
+ // =========================================================================
894
+
895
+ /**
896
+ * Validates that forwardTo target doesn't require params that source doesn't have.
897
+ * Used by updateRoute for forwardTo validation.
898
+ */
899
+ validateForwardToParamCompatibility(
900
+ sourceName: string,
901
+ targetName: string,
902
+ ): void {
903
+ const sourceSegments = this.#getSegmentsOrThrow(sourceName);
904
+ const targetSegments = this.#getSegmentsOrThrow(targetName);
905
+
906
+ // Get source and target URL params using helper
907
+ const sourceParams = this.#collectUrlParams(sourceSegments);
908
+ const targetParams = this.#collectUrlParamsArray(targetSegments);
909
+
910
+ // Check if target requires params that source doesn't have
911
+ const missingParams = targetParams.filter(
912
+ (param) => !sourceParams.has(param),
913
+ );
914
+
915
+ if (missingParams.length > 0) {
916
+ throw new Error(
917
+ `[real-router] forwardTo target "${targetName}" requires params ` +
918
+ `[${missingParams.join(", ")}] that are not available in source route "${sourceName}"`,
919
+ );
920
+ }
921
+ }
922
+
923
+ /**
924
+ * Validates that adding forwardTo doesn't create a cycle.
925
+ * Creates a test map with the new entry and uses resolveForwardChain
926
+ * to detect cycles before any mutation happens.
927
+ * Used by updateRoute for forwardTo validation.
928
+ */
929
+ validateForwardToCycle(sourceName: string, targetName: string): void {
930
+ // Create a test map with the new entry to validate BEFORE mutation
931
+ const testMap = {
932
+ ...this.#config.forwardMap,
933
+ [sourceName]: targetName,
934
+ };
935
+
936
+ // resolveForwardChain will throw if cycle is detected or max depth exceeded
937
+ resolveForwardChain(sourceName, testMap);
938
+ }
939
+
940
+ /**
941
+ * Validates removeRoute constraints.
942
+ * Returns false if removal should be blocked (route is active).
943
+ * Logs warnings for edge cases.
944
+ *
945
+ * @param name - Route name to remove
946
+ * @param currentStateName - Current active route name (or undefined)
947
+ * @param isNavigating - Whether navigation is in progress
948
+ * @returns true if removal can proceed, false if blocked
949
+ */
950
+ validateRemoveRoute(
951
+ name: string,
952
+ currentStateName: string | undefined,
953
+ isNavigating: boolean,
954
+ ): boolean {
955
+ // Check if trying to remove currently active route (or its parent)
956
+ if (currentStateName) {
957
+ const isExactMatch = currentStateName === name;
958
+ const isParentOfCurrent = currentStateName.startsWith(`${name}.`);
959
+
960
+ if (isExactMatch || isParentOfCurrent) {
961
+ const suffix = isExactMatch ? "" : ` (current: "${currentStateName}")`;
962
+
963
+ logger.warn(
964
+ "router.removeRoute",
965
+ `Cannot remove route "${name}" — it is currently active${suffix}. Navigate away first.`,
966
+ );
967
+
968
+ return false;
969
+ }
970
+ }
971
+
972
+ // Warn if navigation is in progress (but allow removal)
973
+ if (isNavigating) {
974
+ logger.warn(
975
+ "router.removeRoute",
976
+ `Route "${name}" removed while navigation is in progress. This may cause unexpected behavior.`,
977
+ );
978
+ }
979
+
980
+ return true;
981
+ }
982
+
983
+ /**
984
+ * Validates clearRoutes operation.
985
+ * Returns false if operation should be blocked (navigation in progress).
986
+ *
987
+ * @param isNavigating - Whether navigation is in progress
988
+ * @returns true if clearRoutes can proceed, false if blocked
989
+ */
990
+ validateClearRoutes(isNavigating: boolean): boolean {
991
+ if (isNavigating) {
992
+ logger.error(
993
+ "router.clearRoutes",
994
+ "Cannot clear routes while navigation is in progress. Wait for navigation to complete.",
995
+ );
996
+
997
+ return false;
998
+ }
999
+
1000
+ return true;
1001
+ }
1002
+
1003
+ /**
1004
+ * Validates updateRoute instance-level constraints (route existence, forwardTo).
1005
+ * Called after static validation passes.
1006
+ *
1007
+ * @param name - Route name (already validated by static method)
1008
+ * @param forwardTo - Cached forwardTo value (to avoid calling getter twice)
1009
+ */
1010
+ validateUpdateRoute(
1011
+ name: string,
1012
+ forwardTo: string | ForwardToCallback<Dependencies> | null | undefined,
1013
+ ): void {
1014
+ // Validate route exists
1015
+ if (!this.hasRoute(name)) {
1016
+ throw new ReferenceError(
1017
+ `[real-router] updateRoute: route "${name}" does not exist`,
1018
+ );
1019
+ }
1020
+
1021
+ // Validate forwardTo target exists and is valid (only for string forwardTo)
1022
+ if (
1023
+ forwardTo !== undefined &&
1024
+ forwardTo !== null &&
1025
+ typeof forwardTo === "string"
1026
+ ) {
1027
+ if (!this.hasRoute(forwardTo)) {
1028
+ throw new Error(
1029
+ `[real-router] updateRoute: forwardTo target "${forwardTo}" does not exist`,
1030
+ );
1031
+ }
1032
+
1033
+ // Check forwardTo param compatibility
1034
+ this.validateForwardToParamCompatibility(name, forwardTo);
1035
+
1036
+ // Check for cycle detection
1037
+ this.validateForwardToCycle(name, forwardTo);
1038
+ }
1039
+ }
1040
+
1041
+ // =========================================================================
1042
+ // Private methods
1043
+ // =========================================================================
1044
+
1045
+ #updateForwardTo(
1046
+ name: string,
1047
+ forwardTo: string | ForwardToCallback<Dependencies> | null,
1048
+ ): void {
1049
+ if (forwardTo === null) {
1050
+ delete this.#config.forwardMap[name];
1051
+ delete this.#config.forwardFnMap[name];
1052
+ } else if (typeof forwardTo === "string") {
1053
+ delete this.#config.forwardFnMap[name];
1054
+ this.#config.forwardMap[name] = forwardTo;
1055
+ } else {
1056
+ delete this.#config.forwardMap[name];
1057
+ this.#config.forwardFnMap[name] = forwardTo;
1058
+ }
1059
+
1060
+ this.#refreshForwardMap();
1061
+ }
1062
+
1063
+ /**
1064
+ * Merges route's defaultParams with provided params.
1065
+ */
1066
+ #mergeDefaultParams<P extends Params = Params>(
1067
+ routeName: string,
1068
+ params: P,
1069
+ ): P {
1070
+ if (Object.hasOwn(this.#config.defaultParams, routeName)) {
1071
+ return { ...this.#config.defaultParams[routeName], ...params } as P;
1072
+ }
1073
+
1074
+ return params;
1075
+ }
1076
+
1077
+ /**
1078
+ * Resolves dynamic forwardTo chain with cycle detection and max depth.
1079
+ * Throws if cycle detected, max depth exceeded, or invalid return type.
1080
+ */
1081
+ #resolveDynamicForward(
1082
+ startName: string,
1083
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1084
+ startFn: ForwardToCallback<any>,
1085
+ params: Params,
1086
+ ): string {
1087
+ const visited = new Set<string>([startName]);
1088
+
1089
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
1090
+ let current = startFn(this.#deps.getDependency as any, params);
1091
+ let depth = 0;
1092
+ const MAX_DEPTH = 100;
1093
+
1094
+ // Validate initial return type
1095
+ if (typeof current !== "string") {
1096
+ throw new TypeError(
1097
+ `forwardTo callback must return a string, got ${typeof current}`,
1098
+ );
1099
+ }
1100
+
1101
+ while (depth < MAX_DEPTH) {
1102
+ // Check if target route exists
1103
+
1104
+ if (this.#matcher.getSegmentsByName(current) === undefined) {
1105
+ throw new Error(`Route "${current}" does not exist`);
1106
+ }
1107
+
1108
+ // Check for cycle
1109
+ if (visited.has(current)) {
1110
+ const chain = [...visited, current].join(" → ");
1111
+
1112
+ throw new Error(`Circular forwardTo detected: ${chain}`);
1113
+ }
1114
+
1115
+ visited.add(current);
1116
+
1117
+ // Check if current has dynamic forward
1118
+ if (Object.hasOwn(this.#config.forwardFnMap, current)) {
1119
+ const fn = this.#config.forwardFnMap[current];
1120
+
1121
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
1122
+ current = fn(this.#deps.getDependency as any, params);
1123
+
1124
+ depth++;
1125
+ continue;
1126
+ }
1127
+
1128
+ // Check if current has static forward
1129
+ const staticForward = this.#config.forwardMap[current];
1130
+
1131
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1132
+ if (staticForward !== undefined) {
1133
+ current = staticForward;
1134
+ depth++;
1135
+ continue;
1136
+ }
1137
+
1138
+ // No more forwards - return current
1139
+ return current;
1140
+ }
1141
+
1142
+ throw new Error(`forwardTo exceeds maximum depth of ${MAX_DEPTH}`);
1143
+ }
1144
+
1145
+ #rebuildTree(): void {
1146
+ this.#tree = createRouteTree(
1147
+ DEFAULT_ROUTE_NAME,
1148
+ this.#rootPath,
1149
+ this.#definitions,
1150
+ );
1151
+
1152
+ // Re-register tree in matcher (creates new instance, preserving options if set)
1153
+ this.#matcher = createMatcher(this.#matcherOptions);
1154
+ this.#matcher.registerTree(this.#tree);
1155
+ }
1156
+
1157
+ /**
1158
+ * Gets segments by name or throws if not found.
1159
+ * Use when route existence has been validated by hasRoute() beforehand.
1160
+ */
1161
+ #getSegmentsOrThrow(name: string): readonly RouteTree[] {
1162
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1163
+ return this.#matcher.getSegmentsByName(name)! as readonly RouteTree[];
1164
+ }
1165
+
1166
+ /**
1167
+ * Gets last segment from segments array.
1168
+ * Use when segments array is guaranteed to be non-empty.
1169
+ */
1170
+ #getLastSegment(segments: readonly RouteTree[]): RouteTree {
1171
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1172
+ return segments.at(-1)!;
1173
+ }
1174
+
1175
+ /**
1176
+ * Collects URL params from segments into a Set.
1177
+ */
1178
+ #collectUrlParams(segments: readonly RouteTree[]): Set<string> {
1179
+ const params = new Set<string>();
1180
+
1181
+ for (const segment of segments) {
1182
+ // Named routes always have parsers (null only for root without path)
1183
+ for (const param of segment.paramMeta.urlParams) {
1184
+ params.add(param);
1185
+ }
1186
+ }
1187
+
1188
+ return params;
1189
+ }
1190
+
1191
+ /**
1192
+ * Collects URL params from segments into an array.
1193
+ *
1194
+ * @param segments - Non-null segments (caller must validate existence first)
1195
+ */
1196
+ #collectUrlParamsArray(segments: readonly RouteTree[]): string[] {
1197
+ const params: string[] = [];
1198
+
1199
+ for (const segment of segments) {
1200
+ // Named routes always have parsers (null only for root without path)
1201
+ for (const param of segment.paramMeta.urlParams) {
1202
+ params.push(param);
1203
+ }
1204
+ }
1205
+
1206
+ return params;
1207
+ }
1208
+
1209
+ /**
1210
+ * Refreshes forward map cache, conditionally validating based on noValidate flag.
1211
+ */
1212
+ #refreshForwardMap(): void {
1213
+ if (this.#noValidate) {
1214
+ this.#cacheForwardMap();
1215
+ } else {
1216
+ this.#validateAndCacheForwardMap();
1217
+ }
1218
+ }
1219
+
1220
+ #validateAndCacheForwardMap(): void {
1221
+ // Clear existing cache
1222
+ for (const key in this.#resolvedForwardMap) {
1223
+ delete this.#resolvedForwardMap[key];
1224
+ }
1225
+
1226
+ // Resolve all chains
1227
+ for (const fromRoute of Object.keys(this.#config.forwardMap)) {
1228
+ this.#resolvedForwardMap[fromRoute] = resolveForwardChain(
1229
+ fromRoute,
1230
+ this.#config.forwardMap,
1231
+ );
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Caches forward chains without validation (noValidate mode).
1237
+ * Simply resolves chains without cycle detection or max depth checks.
1238
+ */
1239
+ #cacheForwardMap(): void {
1240
+ // Clear existing cache
1241
+ for (const key in this.#resolvedForwardMap) {
1242
+ delete this.#resolvedForwardMap[key];
1243
+ }
1244
+
1245
+ // Resolve chains without validation
1246
+ for (const fromRoute of Object.keys(this.#config.forwardMap)) {
1247
+ let current = fromRoute;
1248
+
1249
+ while (this.#config.forwardMap[current]) {
1250
+ current = this.#config.forwardMap[current];
1251
+ }
1252
+
1253
+ this.#resolvedForwardMap[fromRoute] = current;
1254
+ }
1255
+ }
1256
+
1257
+ #clearRouteConfigurations(routeName: string): void {
1258
+ const shouldClear = (n: string): boolean =>
1259
+ n === routeName || n.startsWith(`${routeName}.`);
1260
+
1261
+ clearConfigEntries(this.#config.decoders, shouldClear);
1262
+ clearConfigEntries(this.#config.encoders, shouldClear);
1263
+ clearConfigEntries(this.#config.defaultParams, shouldClear);
1264
+ clearConfigEntries(this.#config.forwardMap, shouldClear);
1265
+ clearConfigEntries(this.#config.forwardFnMap, shouldClear);
1266
+
1267
+ // Clear forwardMap entries pointing TO deleted route
1268
+ clearConfigEntries(this.#config.forwardMap, (key) =>
1269
+ shouldClear(this.#config.forwardMap[key]),
1270
+ );
1271
+
1272
+ // Clear lifecycle handlers
1273
+ const [canDeactivateFactories, canActivateFactories] =
1274
+ this.#lifecycleNamespace.getFactories();
1275
+
1276
+ for (const n of Object.keys(canActivateFactories)) {
1277
+ if (shouldClear(n)) {
1278
+ this.#lifecycleNamespace.clearCanActivate(n);
1279
+ }
1280
+ }
1281
+
1282
+ for (const n of Object.keys(canDeactivateFactories)) {
1283
+ if (shouldClear(n)) {
1284
+ this.#lifecycleNamespace.clearCanDeactivate(n);
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ #registerAllRouteHandlers(
1290
+ routes: readonly Route<Dependencies>[],
1291
+ parentName = "",
1292
+ ): void {
1293
+ for (const route of routes) {
1294
+ const fullName = parentName ? `${parentName}.${route.name}` : route.name;
1295
+
1296
+ this.#registerSingleRouteHandlers(route, fullName);
1297
+
1298
+ if (route.children) {
1299
+ this.#registerAllRouteHandlers(route.children, fullName);
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ #registerSingleRouteHandlers(
1305
+ route: Route<Dependencies>,
1306
+ fullName: string,
1307
+ ): void {
1308
+ // Register canActivate via deps.canActivate (allows tests to spy on router.canActivate)
1309
+ if (route.canActivate) {
1310
+ // Note: Uses #depsStore directly because this method is called from constructor
1311
+ // before setDependencies(). The getter #deps would throw if deps not set.
1312
+ if (this.#depsStore) {
1313
+ // Deps available, register immediately
1314
+ this.#depsStore.addActivateGuard(fullName, route.canActivate);
1315
+ } else {
1316
+ // Deps not set yet, store for later registration
1317
+ this.#pendingCanActivate.set(fullName, route.canActivate);
1318
+ }
1319
+ }
1320
+
1321
+ // Register canDeactivate via deps.canDeactivate (allows tests to spy on router.canDeactivate)
1322
+ if (route.canDeactivate) {
1323
+ // Note: Uses #depsStore directly because this method is called from constructor
1324
+ // before setDependencies(). The getter #deps would throw if deps not set.
1325
+ if (this.#depsStore) {
1326
+ // Deps available, register immediately
1327
+ this.#depsStore.addDeactivateGuard(fullName, route.canDeactivate);
1328
+ } else {
1329
+ // Deps not set yet, store for later registration
1330
+ this.#pendingCanDeactivate.set(fullName, route.canDeactivate);
1331
+ }
1332
+ }
1333
+
1334
+ // Register forwardTo
1335
+ if (route.forwardTo) {
1336
+ this.#registerForwardTo(route, fullName);
1337
+ }
1338
+
1339
+ // Register transformers with fallback wrapper
1340
+ if (route.decodeParams) {
1341
+ this.#config.decoders[fullName] = (params: Params): Params =>
1342
+ route.decodeParams?.(params) ?? params;
1343
+ }
1344
+
1345
+ if (route.encodeParams) {
1346
+ this.#config.encoders[fullName] = (params: Params): Params =>
1347
+ route.encodeParams?.(params) ?? params;
1348
+ }
1349
+
1350
+ // Register defaults
1351
+ if (route.defaultParams) {
1352
+ this.#config.defaultParams[fullName] = route.defaultParams;
1353
+ }
1354
+ }
1355
+
1356
+ #findDefinition(
1357
+ definitions: RouteDefinition[],
1358
+ fullName: string,
1359
+ parentPrefix = "",
1360
+ ): RouteDefinition | undefined {
1361
+ for (const def of definitions) {
1362
+ const currentFullName = parentPrefix
1363
+ ? `${parentPrefix}.${def.name}`
1364
+ : def.name;
1365
+
1366
+ if (currentFullName === fullName) {
1367
+ return def;
1368
+ }
1369
+ if (def.children && fullName.startsWith(`${currentFullName}.`)) {
1370
+ return this.#findDefinition(def.children, fullName, currentFullName);
1371
+ }
1372
+ }
1373
+
1374
+ /* v8 ignore next -- @preserve: defensive return, callers validate route exists before calling */
1375
+ return undefined;
1376
+ }
1377
+
1378
+ #registerForwardTo(route: Route<Dependencies>, fullName: string): void {
1379
+ if (route.canActivate) {
1380
+ /* v8 ignore next -- @preserve: edge case, both string and function tested separately */
1381
+ const forwardTarget =
1382
+ typeof route.forwardTo === "string" ? route.forwardTo : "[dynamic]";
1383
+
1384
+ logger.warn(
1385
+ "real-router",
1386
+ `Route "${fullName}" has both forwardTo and canActivate. ` +
1387
+ `canActivate will be ignored because forwardTo creates a redirect (industry standard). ` +
1388
+ `Move canActivate to the target route "${forwardTarget}".`,
1389
+ );
1390
+ }
1391
+
1392
+ if (route.canDeactivate) {
1393
+ /* v8 ignore next -- @preserve: edge case, both string and function tested separately */
1394
+ const forwardTarget =
1395
+ typeof route.forwardTo === "string" ? route.forwardTo : "[dynamic]";
1396
+
1397
+ logger.warn(
1398
+ "real-router",
1399
+ `Route "${fullName}" has both forwardTo and canDeactivate. ` +
1400
+ `canDeactivate will be ignored because forwardTo creates a redirect (industry standard). ` +
1401
+ `Move canDeactivate to the target route "${forwardTarget}".`,
1402
+ );
1403
+ }
1404
+
1405
+ // Async validation ALWAYS runs (even with noValidate=true)
1406
+ if (typeof route.forwardTo === "function") {
1407
+ const isNativeAsync =
1408
+ (route.forwardTo as { constructor: { name: string } }).constructor
1409
+ .name === "AsyncFunction";
1410
+ const isTranspiledAsync = route.forwardTo
1411
+ .toString()
1412
+ .includes("__awaiter");
1413
+
1414
+ if (isNativeAsync || isTranspiledAsync) {
1415
+ throw new TypeError(
1416
+ `forwardTo callback cannot be async for route "${fullName}". ` +
1417
+ `Async functions break matchPath/buildPath.`,
1418
+ );
1419
+ }
1420
+ }
1421
+
1422
+ // forwardTo is guaranteed to exist at this point
1423
+ if (typeof route.forwardTo === "string") {
1424
+ this.#config.forwardMap[fullName] = route.forwardTo;
1425
+ } else {
1426
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1427
+ this.#config.forwardFnMap[fullName] = route.forwardTo!;
1428
+ }
1429
+ }
1430
+
1431
+ #enrichRoute(
1432
+ routeDef: RouteDefinition,
1433
+ routeName: string,
1434
+ ): Route<Dependencies> {
1435
+ const route: Route<Dependencies> = {
1436
+ name: routeDef.name,
1437
+ path: routeDef.path,
1438
+ };
1439
+
1440
+ const forwardToFn = this.#config.forwardFnMap[routeName];
1441
+ const forwardToStr = this.#config.forwardMap[routeName];
1442
+
1443
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1444
+ if (forwardToFn !== undefined) {
1445
+ route.forwardTo = forwardToFn;
1446
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1447
+ } else if (forwardToStr !== undefined) {
1448
+ route.forwardTo = forwardToStr;
1449
+ }
1450
+
1451
+ if (routeName in this.#config.defaultParams) {
1452
+ route.defaultParams = this.#config.defaultParams[routeName];
1453
+ }
1454
+
1455
+ if (routeName in this.#config.decoders) {
1456
+ route.decodeParams = this.#config.decoders[routeName];
1457
+ }
1458
+
1459
+ if (routeName in this.#config.encoders) {
1460
+ route.encodeParams = this.#config.encoders[routeName];
1461
+ }
1462
+
1463
+ const [canDeactivateFactories, canActivateFactories] =
1464
+ this.#lifecycleNamespace.getFactories();
1465
+
1466
+ if (routeName in canActivateFactories) {
1467
+ route.canActivate = canActivateFactories[routeName];
1468
+ }
1469
+
1470
+ if (routeName in canDeactivateFactories) {
1471
+ route.canDeactivate = canDeactivateFactories[routeName];
1472
+ }
1473
+
1474
+ if (routeDef.children) {
1475
+ route.children = routeDef.children.map((child) =>
1476
+ this.#enrichRoute(child, `${routeName}.${child.name}`),
1477
+ );
1478
+ }
1479
+
1480
+ return route;
1481
+ }
1482
+ }