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