@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.
- package/README.md +1 -3
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/metafile-cjs.json +1 -1
- package/dist/esm/index.d.mts +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/metafile-esm.json +1 -1
- package/package.json +7 -5
- package/src/Router.ts +1174 -0
- package/src/RouterError.ts +324 -0
- package/src/constants.ts +112 -0
- package/src/createRouter.ts +32 -0
- package/src/fsm/index.ts +5 -0
- package/src/fsm/routerFSM.ts +129 -0
- package/src/getNavigator.ts +15 -0
- package/src/helpers.ts +194 -0
- package/src/index.ts +46 -0
- package/src/namespaces/CloneNamespace/CloneNamespace.ts +120 -0
- package/src/namespaces/CloneNamespace/index.ts +3 -0
- package/src/namespaces/CloneNamespace/types.ts +46 -0
- package/src/namespaces/DependenciesNamespace/DependenciesNamespace.ts +250 -0
- package/src/namespaces/DependenciesNamespace/index.ts +3 -0
- package/src/namespaces/DependenciesNamespace/validators.ts +105 -0
- package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +272 -0
- package/src/namespaces/EventBusNamespace/index.ts +5 -0
- package/src/namespaces/EventBusNamespace/types.ts +11 -0
- package/src/namespaces/MiddlewareNamespace/MiddlewareNamespace.ts +206 -0
- package/src/namespaces/MiddlewareNamespace/index.ts +5 -0
- package/src/namespaces/MiddlewareNamespace/types.ts +28 -0
- package/src/namespaces/MiddlewareNamespace/validators.ts +96 -0
- package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +308 -0
- package/src/namespaces/NavigationNamespace/index.ts +5 -0
- package/src/namespaces/NavigationNamespace/transition/executeLifecycleHooks.ts +84 -0
- package/src/namespaces/NavigationNamespace/transition/executeMiddleware.ts +56 -0
- package/src/namespaces/NavigationNamespace/transition/index.ts +107 -0
- package/src/namespaces/NavigationNamespace/transition/makeError.ts +37 -0
- package/src/namespaces/NavigationNamespace/transition/mergeStates.ts +54 -0
- package/src/namespaces/NavigationNamespace/transition/processLifecycleResult.ts +81 -0
- package/src/namespaces/NavigationNamespace/transition/wrapSyncError.ts +82 -0
- package/src/namespaces/NavigationNamespace/types.ts +129 -0
- package/src/namespaces/NavigationNamespace/validators.ts +87 -0
- package/src/namespaces/OptionsNamespace/OptionsNamespace.ts +50 -0
- package/src/namespaces/OptionsNamespace/constants.ts +41 -0
- package/src/namespaces/OptionsNamespace/helpers.ts +51 -0
- package/src/namespaces/OptionsNamespace/index.ts +11 -0
- package/src/namespaces/OptionsNamespace/validators.ts +252 -0
- package/src/namespaces/PluginsNamespace/PluginsNamespace.ts +325 -0
- package/src/namespaces/PluginsNamespace/constants.ts +35 -0
- package/src/namespaces/PluginsNamespace/index.ts +7 -0
- package/src/namespaces/PluginsNamespace/types.ts +32 -0
- package/src/namespaces/PluginsNamespace/validators.ts +79 -0
- package/src/namespaces/RouteLifecycleNamespace/RouteLifecycleNamespace.ts +389 -0
- package/src/namespaces/RouteLifecycleNamespace/index.ts +5 -0
- package/src/namespaces/RouteLifecycleNamespace/types.ts +17 -0
- package/src/namespaces/RouteLifecycleNamespace/validators.ts +65 -0
- package/src/namespaces/RouterLifecycleNamespace/RouterLifecycleNamespace.ts +140 -0
- package/src/namespaces/RouterLifecycleNamespace/constants.ts +25 -0
- package/src/namespaces/RouterLifecycleNamespace/index.ts +5 -0
- package/src/namespaces/RouterLifecycleNamespace/types.ts +23 -0
- package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +1482 -0
- package/src/namespaces/RoutesNamespace/constants.ts +14 -0
- package/src/namespaces/RoutesNamespace/helpers.ts +532 -0
- package/src/namespaces/RoutesNamespace/index.ts +9 -0
- package/src/namespaces/RoutesNamespace/stateBuilder.ts +70 -0
- package/src/namespaces/RoutesNamespace/types.ts +82 -0
- package/src/namespaces/RoutesNamespace/validators.ts +331 -0
- package/src/namespaces/StateNamespace/StateNamespace.ts +317 -0
- package/src/namespaces/StateNamespace/helpers.ts +43 -0
- package/src/namespaces/StateNamespace/index.ts +5 -0
- package/src/namespaces/StateNamespace/types.ts +15 -0
- package/src/namespaces/index.ts +42 -0
- package/src/transitionPath.ts +441 -0
- package/src/typeGuards.ts +74 -0
- package/src/types.ts +194 -0
- package/src/wiring/RouterWiringBuilder.ts +235 -0
- package/src/wiring/index.ts +7 -0
- package/src/wiring/types.ts +53 -0
- 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
|
+
}
|