@real-router/core 0.54.6 → 0.56.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/dist/cjs/Router-IEGavTKk.js +6 -0
- package/dist/cjs/Router-IEGavTKk.js.map +1 -0
- package/dist/cjs/{Router-CJihdrWA.d.ts → Router-hW6ivqrX.d.ts} +2 -2
- package/dist/cjs/Router-hW6ivqrX.d.ts.map +1 -0
- package/dist/cjs/api.d.ts +2 -2
- package/dist/cjs/api.d.ts.map +1 -1
- package/dist/cjs/api.js +1 -1
- package/dist/cjs/api.js.map +1 -1
- package/dist/cjs/{cloneRouter-zhA3NNoI.js → cloneRouter-DRieJvam.js} +2 -2
- package/dist/cjs/{cloneRouter-zhA3NNoI.js.map → cloneRouter-DRieJvam.js.map} +1 -1
- package/dist/cjs/{index-EwbhzRQw.d.ts → index-C-i6vx5Y.d.ts} +1 -1
- package/dist/cjs/index-C-i6vx5Y.d.ts.map +1 -0
- package/dist/cjs/{RouterError-Bm9YnZ6e.d.ts → index-CYpAZCoc.d.ts} +19 -2
- package/dist/cjs/index-CYpAZCoc.d.ts.map +1 -0
- package/dist/cjs/{index-8oPDJBQc.d.ts → index-D2WRiyWS.d.ts} +2 -2
- package/dist/cjs/index-D2WRiyWS.d.ts.map +1 -0
- package/dist/cjs/index.d.ts +5 -5
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/{internals-CM6oaz9n.js → internals-DJjgSePy.js} +2 -2
- package/dist/cjs/internals-DJjgSePy.js.map +1 -0
- package/dist/cjs/utils.d.ts +1 -1
- package/dist/cjs/utils.js +1 -1
- package/dist/cjs/utils.js.map +1 -1
- package/dist/cjs/validation.d.ts +17 -5
- package/dist/cjs/validation.d.ts.map +1 -1
- package/dist/cjs/validation.js +1 -1
- package/dist/esm/Router-B3aeavRb.mjs +6 -0
- package/dist/esm/Router-B3aeavRb.mjs.map +1 -0
- package/dist/esm/{Router-BmhiDQUJ.d.mts → Router-hW6ivqrX.d.mts} +2 -2
- package/dist/esm/Router-hW6ivqrX.d.mts.map +1 -0
- package/dist/esm/api.d.mts +2 -2
- package/dist/esm/api.d.mts.map +1 -1
- package/dist/esm/api.mjs +1 -1
- package/dist/esm/api.mjs.map +1 -1
- package/dist/esm/cloneRouter-DHrH6D_z.mjs +2 -0
- package/dist/esm/{cloneRouter-U8NeEoPX.mjs.map → cloneRouter-DHrH6D_z.mjs.map} +1 -1
- package/dist/esm/{index-DNjaY7KH.d.mts → index-C-i6vx5Y.d.mts} +1 -1
- package/dist/esm/index-C-i6vx5Y.d.mts.map +1 -0
- package/dist/esm/{RouterError-hhfSVGtY.d.mts → index-CYpAZCoc.d.mts} +19 -2
- package/dist/esm/index-CYpAZCoc.d.mts.map +1 -0
- package/dist/esm/{index-r_JTvSBH.d.mts → index-CjWKWPY6.d.mts} +2 -2
- package/dist/esm/index-CjWKWPY6.d.mts.map +1 -0
- package/dist/esm/index.d.mts +5 -5
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/{internals-C59msvHY.mjs → internals-C8mRvTxc.mjs} +2 -2
- package/dist/esm/internals-C8mRvTxc.mjs.map +1 -0
- package/dist/esm/utils.d.mts +1 -1
- package/dist/esm/utils.mjs +1 -1
- package/dist/esm/utils.mjs.map +1 -1
- package/dist/esm/validation.d.mts +17 -5
- package/dist/esm/validation.d.mts.map +1 -1
- package/dist/esm/validation.mjs +1 -1
- package/package.json +4 -4
- package/src/Router.ts +20 -8
- package/src/api/getRoutesApi.ts +368 -124
- package/src/index.ts +16 -0
- package/src/internals.ts +29 -1
- package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +37 -0
- package/src/namespaces/NavigationNamespace/types.ts +1 -1
- package/src/namespaces/RoutesNamespace/routesStore.ts +272 -52
- package/src/transitionPath.ts +7 -3
- package/src/types.ts +9 -1
- package/dist/cjs/Router-CJihdrWA.d.ts.map +0 -1
- package/dist/cjs/Router-DnIAA87f.js +0 -6
- package/dist/cjs/Router-DnIAA87f.js.map +0 -1
- package/dist/cjs/RouterError-Bm9YnZ6e.d.ts.map +0 -1
- package/dist/cjs/index-8oPDJBQc.d.ts.map +0 -1
- package/dist/cjs/index-EwbhzRQw.d.ts.map +0 -1
- package/dist/cjs/internals-CM6oaz9n.js.map +0 -1
- package/dist/esm/Router-BmhiDQUJ.d.mts.map +0 -1
- package/dist/esm/Router-pwd8YBWr.mjs +0 -6
- package/dist/esm/Router-pwd8YBWr.mjs.map +0 -1
- package/dist/esm/RouterError-hhfSVGtY.d.mts.map +0 -1
- package/dist/esm/cloneRouter-U8NeEoPX.mjs +0 -2
- package/dist/esm/index-DNjaY7KH.d.mts.map +0 -1
- package/dist/esm/index-r_JTvSBH.d.mts.map +0 -1
- package/dist/esm/internals-C59msvHY.mjs.map +0 -1
package/src/api/getRoutesApi.ts
CHANGED
|
@@ -2,20 +2,21 @@ import { logger } from "@real-router/logger";
|
|
|
2
2
|
|
|
3
3
|
import { throwIfDisposed } from "./helpers";
|
|
4
4
|
import { guardRouteStructure } from "../guards";
|
|
5
|
-
import {
|
|
5
|
+
import { getInternals } from "../internals";
|
|
6
6
|
import {
|
|
7
7
|
clearConfigEntries,
|
|
8
8
|
removeFromDefinitions,
|
|
9
|
-
sanitizeRoute,
|
|
10
9
|
} from "../namespaces/RoutesNamespace/helpers";
|
|
11
10
|
import {
|
|
12
11
|
validateClearRoutes,
|
|
13
12
|
validateRemoveRoute,
|
|
14
13
|
} from "../namespaces/RoutesNamespace/routeGuards";
|
|
15
14
|
import {
|
|
16
|
-
|
|
15
|
+
adoptRouteArtifacts,
|
|
16
|
+
assertAddable,
|
|
17
|
+
buildAddArtifacts,
|
|
18
|
+
buildReplaceArtifacts,
|
|
17
19
|
refreshForwardMap,
|
|
18
|
-
registerAllRouteHandlers,
|
|
19
20
|
} from "../namespaces/RoutesNamespace/routesStore";
|
|
20
21
|
|
|
21
22
|
import type { RoutesApi } from "./types";
|
|
@@ -29,6 +30,8 @@ import type {
|
|
|
29
30
|
Params,
|
|
30
31
|
Router,
|
|
31
32
|
TransitionMeta,
|
|
33
|
+
TreeChangedEvent,
|
|
34
|
+
TreeStructuralPatch,
|
|
32
35
|
} from "@real-router/types";
|
|
33
36
|
import type { RouteDefinition, RouteTree } from "route-tree";
|
|
34
37
|
|
|
@@ -36,32 +39,6 @@ import type { RouteDefinition, RouteTree } from "route-tree";
|
|
|
36
39
|
// Helpers
|
|
37
40
|
// ============================================================================
|
|
38
41
|
|
|
39
|
-
/**
|
|
40
|
-
* Recursively finds a route definition by its full dotted name.
|
|
41
|
-
*/
|
|
42
|
-
function findDefinition(
|
|
43
|
-
definitions: RouteDefinition[],
|
|
44
|
-
fullName: string,
|
|
45
|
-
parentPrefix = "",
|
|
46
|
-
): RouteDefinition | undefined {
|
|
47
|
-
for (const def of definitions) {
|
|
48
|
-
const currentFullName = parentPrefix
|
|
49
|
-
? `${parentPrefix}.${def.name}`
|
|
50
|
-
: def.name;
|
|
51
|
-
|
|
52
|
-
if (currentFullName === fullName) {
|
|
53
|
-
return def;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (def.children && fullName.startsWith(`${currentFullName}.`)) {
|
|
57
|
-
return findDefinition(def.children, fullName, currentFullName);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/* v8 ignore next -- @preserve: defensive return, callers validate route exists before calling */
|
|
62
|
-
return undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
42
|
/**
|
|
66
43
|
* Clears all config entries and lifecycle handlers for a removed route
|
|
67
44
|
* (and all its descendants).
|
|
@@ -116,47 +93,58 @@ function updateForwardTo<
|
|
|
116
93
|
name: string,
|
|
117
94
|
forwardTo: string | ForwardToCallback<Dependencies> | null,
|
|
118
95
|
config: RouteConfig,
|
|
119
|
-
refreshForwardMapFn: (config: RouteConfig) => Record<string, string>,
|
|
120
96
|
): Record<string, string> {
|
|
97
|
+
// Prepare-then-commit (issue #698): apply the change to CLONES of the forward
|
|
98
|
+
// maps, resolve the chain (a cycle throws here), and only then swap the clones
|
|
99
|
+
// in — so a rejected update never leaves config.forwardMap poisoned.
|
|
100
|
+
const forwardMap = Object.assign(
|
|
101
|
+
Object.create(null) as RouteConfig["forwardMap"],
|
|
102
|
+
config.forwardMap,
|
|
103
|
+
);
|
|
104
|
+
const forwardFnMap = Object.assign(
|
|
105
|
+
Object.create(null) as RouteConfig["forwardFnMap"],
|
|
106
|
+
config.forwardFnMap,
|
|
107
|
+
);
|
|
108
|
+
|
|
121
109
|
if (forwardTo === null) {
|
|
122
|
-
delete
|
|
123
|
-
delete
|
|
110
|
+
delete forwardMap[name];
|
|
111
|
+
delete forwardFnMap[name];
|
|
124
112
|
} else if (typeof forwardTo === "string") {
|
|
125
|
-
delete
|
|
126
|
-
|
|
113
|
+
delete forwardFnMap[name];
|
|
114
|
+
forwardMap[name] = forwardTo;
|
|
127
115
|
} else {
|
|
128
|
-
delete
|
|
129
|
-
|
|
116
|
+
delete forwardMap[name];
|
|
117
|
+
forwardFnMap[name] = forwardTo;
|
|
130
118
|
}
|
|
131
119
|
|
|
132
|
-
|
|
120
|
+
const resolved = refreshForwardMap({ ...config, forwardMap });
|
|
121
|
+
|
|
122
|
+
config.forwardMap = forwardMap;
|
|
123
|
+
config.forwardFnMap = forwardFnMap;
|
|
124
|
+
|
|
125
|
+
return resolved;
|
|
133
126
|
}
|
|
134
127
|
|
|
135
128
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
129
|
+
* Re-attaches the stored config (forwardTo / defaultParams / encode-decode) and
|
|
130
|
+
* lifecycle guards for `lookupName` onto `route`, then returns it (mutates in
|
|
131
|
+
* place). Shared by {@link enrichRoute} (nested, bare `name`) and
|
|
132
|
+
* {@link buildFlatRoute} (flat, full dotted `name`) — one source of truth for
|
|
133
|
+
* the route-config field set.
|
|
141
134
|
*/
|
|
142
|
-
function
|
|
135
|
+
function assignRouteConfig<
|
|
143
136
|
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
144
137
|
>(
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
route: Route<Dependencies>,
|
|
139
|
+
lookupName: string,
|
|
147
140
|
config: RouteConfig,
|
|
148
141
|
factories: [
|
|
149
142
|
Record<string, GuardFnFactory<Dependencies>>,
|
|
150
143
|
Record<string, GuardFnFactory<Dependencies>>,
|
|
151
144
|
],
|
|
152
145
|
): Route<Dependencies> {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
path: routeDef.path,
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const forwardToFn = config.forwardFnMap[routeName];
|
|
159
|
-
const forwardToStr = config.forwardMap[routeName];
|
|
146
|
+
const forwardToFn = config.forwardFnMap[lookupName];
|
|
147
|
+
const forwardToStr = config.forwardMap[lookupName];
|
|
160
148
|
|
|
161
149
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
162
150
|
if (forwardToFn !== undefined) {
|
|
@@ -166,28 +154,56 @@ function enrichRoute<
|
|
|
166
154
|
route.forwardTo = forwardToStr;
|
|
167
155
|
}
|
|
168
156
|
|
|
169
|
-
if (
|
|
170
|
-
route.defaultParams = config.defaultParams[
|
|
157
|
+
if (lookupName in config.defaultParams) {
|
|
158
|
+
route.defaultParams = config.defaultParams[lookupName];
|
|
171
159
|
}
|
|
172
160
|
|
|
173
|
-
if (
|
|
174
|
-
route.decodeParams = config.decoders[
|
|
161
|
+
if (lookupName in config.decoders) {
|
|
162
|
+
route.decodeParams = config.decoders[lookupName];
|
|
175
163
|
}
|
|
176
164
|
|
|
177
|
-
if (
|
|
178
|
-
route.encodeParams = config.encoders[
|
|
165
|
+
if (lookupName in config.encoders) {
|
|
166
|
+
route.encodeParams = config.encoders[lookupName];
|
|
179
167
|
}
|
|
180
168
|
|
|
181
169
|
const [canDeactivateFactories, canActivateFactories] = factories;
|
|
182
170
|
|
|
183
|
-
if (
|
|
184
|
-
route.canActivate = canActivateFactories[
|
|
171
|
+
if (lookupName in canActivateFactories) {
|
|
172
|
+
route.canActivate = canActivateFactories[lookupName];
|
|
185
173
|
}
|
|
186
174
|
|
|
187
|
-
if (
|
|
188
|
-
route.canDeactivate = canDeactivateFactories[
|
|
175
|
+
if (lookupName in canDeactivateFactories) {
|
|
176
|
+
route.canDeactivate = canDeactivateFactories[lookupName];
|
|
189
177
|
}
|
|
190
178
|
|
|
179
|
+
return route;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Builds a full Route object from a bare RouteDefinition by re-attaching
|
|
184
|
+
* config entries and lifecycle factories.
|
|
185
|
+
*
|
|
186
|
+
* RECURSIVE — call with the factories tuple obtained ONCE from
|
|
187
|
+
* `lifecycleNamespace.getFactories()` and pass it through to children.
|
|
188
|
+
*/
|
|
189
|
+
function enrichRoute<
|
|
190
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
191
|
+
>(
|
|
192
|
+
routeDef: RouteDefinition,
|
|
193
|
+
routeName: string,
|
|
194
|
+
config: RouteConfig,
|
|
195
|
+
factories: [
|
|
196
|
+
Record<string, GuardFnFactory<Dependencies>>,
|
|
197
|
+
Record<string, GuardFnFactory<Dependencies>>,
|
|
198
|
+
],
|
|
199
|
+
): Route<Dependencies> {
|
|
200
|
+
const route: Route<Dependencies> = {
|
|
201
|
+
name: routeDef.name,
|
|
202
|
+
path: routeDef.path,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
assignRouteConfig(route, routeName, config, factories);
|
|
206
|
+
|
|
191
207
|
if (routeDef.children) {
|
|
192
208
|
route.children = routeDef.children.map((child) =>
|
|
193
209
|
enrichRoute(child, `${routeName}.${child.name}`, config, factories),
|
|
@@ -198,51 +214,226 @@ function enrichRoute<
|
|
|
198
214
|
}
|
|
199
215
|
|
|
200
216
|
// ============================================================================
|
|
201
|
-
//
|
|
217
|
+
// TREE_CHANGED payload helpers
|
|
202
218
|
// ============================================================================
|
|
203
219
|
|
|
204
220
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
221
|
+
* Builds a single FLAT `Route` for `fullName` from the store config + lifecycle
|
|
222
|
+
* factories — `name` is the FULL dotted name and there is no `children` array
|
|
223
|
+
* (consumers want a flat, by-name list). Frozen on construction.
|
|
207
224
|
*/
|
|
208
|
-
function
|
|
225
|
+
function buildFlatRoute<
|
|
226
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
227
|
+
>(
|
|
228
|
+
fullName: string,
|
|
229
|
+
path: string,
|
|
230
|
+
config: RouteConfig,
|
|
231
|
+
factories: [
|
|
232
|
+
Record<string, GuardFnFactory<Dependencies>>,
|
|
233
|
+
Record<string, GuardFnFactory<Dependencies>>,
|
|
234
|
+
],
|
|
235
|
+
): Route<Dependencies> {
|
|
236
|
+
const route: Route<Dependencies> = { name: fullName, path };
|
|
237
|
+
|
|
238
|
+
assignRouteConfig(route, fullName, config, factories);
|
|
239
|
+
|
|
240
|
+
return Object.freeze(route);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Walks the store's definitions depth-first, building a FLAT
|
|
245
|
+
* `Map<fullName, Route>` for every node whose full dotted name satisfies
|
|
246
|
+
* `include`. Reads the live store, so call it at the right moment relative to
|
|
247
|
+
* the mutation (before for removed, after for added).
|
|
248
|
+
*/
|
|
249
|
+
function collectFlatRoutes<
|
|
209
250
|
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
210
251
|
>(
|
|
211
252
|
store: RoutesStore<Dependencies>,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
253
|
+
include: (fullName: string) => boolean,
|
|
254
|
+
): Map<string, Route<Dependencies>> {
|
|
255
|
+
const result = new Map<string, Route<Dependencies>>();
|
|
256
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed set after wiring
|
|
257
|
+
const factories = store.lifecycleNamespace!.getFactories();
|
|
258
|
+
|
|
259
|
+
const walk = (defs: readonly RouteDefinition[], parentName: string): void => {
|
|
260
|
+
for (const def of defs) {
|
|
261
|
+
const fullName = parentName ? `${parentName}.${def.name}` : def.name;
|
|
218
262
|
|
|
219
|
-
|
|
263
|
+
if (include(fullName)) {
|
|
264
|
+
result.set(
|
|
265
|
+
fullName,
|
|
266
|
+
buildFlatRoute(fullName, def.path, store.config, factories),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
220
269
|
|
|
221
|
-
|
|
222
|
-
|
|
270
|
+
if (def.children) {
|
|
271
|
+
walk(def.children, fullName);
|
|
272
|
+
}
|
|
223
273
|
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
walk(store.definitions, "");
|
|
277
|
+
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Collects the route `name` and all of its descendants as a FLAT, frozen array.
|
|
283
|
+
* MUST be called BEFORE the removal mutation — the nodes are gone afterwards.
|
|
284
|
+
*/
|
|
285
|
+
function collectSubtree<
|
|
286
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
287
|
+
>(
|
|
288
|
+
store: RoutesStore<Dependencies>,
|
|
289
|
+
name: string,
|
|
290
|
+
): readonly Route<Dependencies>[] {
|
|
291
|
+
const prefix = `${name}.`;
|
|
292
|
+
const subtree = collectFlatRoutes(
|
|
293
|
+
store,
|
|
294
|
+
(fullName) => fullName === name || fullName.startsWith(prefix),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
return Object.freeze([...subtree.values()]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Builds the FLAT, frozen payload array for an `add`, walking only the input
|
|
302
|
+
* routes — O(added), not O(tree). `path` is taken from the input verbatim
|
|
303
|
+
* (`sanitizeRoute` never rewrites it); config fields are read from the
|
|
304
|
+
* post-commit store by full name. `add` never removes, so the input subtree is
|
|
305
|
+
* exactly what changed.
|
|
306
|
+
*/
|
|
307
|
+
function collectAddedRoutes<
|
|
308
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
309
|
+
>(
|
|
310
|
+
routes: readonly Route<Dependencies>[],
|
|
311
|
+
parentName: string | undefined,
|
|
312
|
+
store: RoutesStore<Dependencies>,
|
|
313
|
+
): readonly Route<Dependencies>[] {
|
|
314
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed set after wiring
|
|
315
|
+
const factories = store.lifecycleNamespace!.getFactories();
|
|
316
|
+
const result: Route<Dependencies>[] = [];
|
|
317
|
+
|
|
318
|
+
const walk = (
|
|
319
|
+
input: readonly Route<Dependencies>[],
|
|
320
|
+
parent: string,
|
|
321
|
+
): void => {
|
|
322
|
+
for (const route of input) {
|
|
323
|
+
const fullName = parent ? `${parent}.${route.name}` : route.name;
|
|
324
|
+
|
|
325
|
+
result.push(
|
|
326
|
+
buildFlatRoute(fullName, route.path, store.config, factories),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
if (route.children) {
|
|
330
|
+
walk(route.children, fullName);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
walk(routes, parentName ?? "");
|
|
336
|
+
|
|
337
|
+
return Object.freeze(result);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Diffs two flat route maps by full name into frozen removed/added arrays. */
|
|
341
|
+
function diffFlatRoutes<
|
|
342
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
343
|
+
>(
|
|
344
|
+
before: ReadonlyMap<string, Route<Dependencies>>,
|
|
345
|
+
after: ReadonlyMap<string, Route<Dependencies>>,
|
|
346
|
+
): {
|
|
347
|
+
removed: readonly Route<Dependencies>[];
|
|
348
|
+
added: readonly Route<Dependencies>[];
|
|
349
|
+
} {
|
|
350
|
+
const removed: Route<Dependencies>[] = [];
|
|
351
|
+
const added: Route<Dependencies>[] = [];
|
|
352
|
+
|
|
353
|
+
for (const [fullName, route] of before) {
|
|
354
|
+
if (!after.has(fullName)) {
|
|
355
|
+
removed.push(route);
|
|
227
356
|
}
|
|
228
357
|
}
|
|
229
358
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
store.pendingCanDeactivate,
|
|
236
|
-
store.depsStore,
|
|
237
|
-
parentName ?? "",
|
|
238
|
-
);
|
|
359
|
+
for (const [fullName, route] of after) {
|
|
360
|
+
if (!before.has(fullName)) {
|
|
361
|
+
added.push(route);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
239
364
|
|
|
240
|
-
|
|
365
|
+
return { removed: Object.freeze(removed), added: Object.freeze(added) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Builds the structural subset of an `update()` patch (forwardTo /
|
|
370
|
+
* defaultParams / encodeParams / decodeParams) from the already-destructured
|
|
371
|
+
* update fields — so user getters are not re-invoked. A guard-only patch yields
|
|
372
|
+
* an empty object → the caller emits no TREE_CHANGED (О-7: guards are
|
|
373
|
+
* invoked-on-demand, not cached, so they need no observation channel).
|
|
374
|
+
*
|
|
375
|
+
* The returned envelope is a fresh object (caller's patch untouched) and is
|
|
376
|
+
* frozen on construction. Nested values (e.g. `defaultParams`) are kept by
|
|
377
|
+
* reference — the same objects the router stored — so exotic inputs (circular
|
|
378
|
+
* refs, class instances) are tolerated, matching `update()`'s existing contract.
|
|
379
|
+
*/
|
|
380
|
+
function buildStructuralPatch<
|
|
381
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
382
|
+
>(fields: {
|
|
383
|
+
forwardTo?: string | ForwardToCallback<Dependencies> | null | undefined;
|
|
384
|
+
defaultParams?: Params | null | undefined;
|
|
385
|
+
decodeParams?: ((params: Params) => Params) | null | undefined;
|
|
386
|
+
encodeParams?: ((params: Params) => Params) | null | undefined;
|
|
387
|
+
}): Readonly<TreeStructuralPatch<Dependencies>> {
|
|
388
|
+
const patch: TreeStructuralPatch<Dependencies> = {};
|
|
389
|
+
|
|
390
|
+
if (fields.forwardTo !== undefined) {
|
|
391
|
+
patch.forwardTo = fields.forwardTo;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (fields.defaultParams !== undefined) {
|
|
395
|
+
patch.defaultParams = fields.defaultParams;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (fields.encodeParams !== undefined) {
|
|
399
|
+
patch.encodeParams = fields.encodeParams;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (fields.decodeParams !== undefined) {
|
|
403
|
+
patch.decodeParams = fields.decodeParams;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return Object.freeze(patch);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ============================================================================
|
|
410
|
+
// CRUD operations
|
|
411
|
+
// ============================================================================
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Adds one or more routes to the router.
|
|
415
|
+
* Input already validated by facade.
|
|
416
|
+
*/
|
|
417
|
+
function addRoutes<
|
|
418
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
419
|
+
>(
|
|
420
|
+
store: RoutesStore<Dependencies>,
|
|
421
|
+
routes: Route<Dependencies>[],
|
|
422
|
+
parentName?: string,
|
|
423
|
+
): void {
|
|
424
|
+
// Prepare-then-commit (issue #698): reject the silent-corruption cases
|
|
425
|
+
// up front (dup name vs existing, missing parent), build the merged tree /
|
|
426
|
+
// config into locals (async/circular forwardTo + invalid constraint throw
|
|
427
|
+
// here), then swap atomically. A rejected add leaves the store untouched.
|
|
428
|
+
assertAddable(store, routes, parentName);
|
|
429
|
+
adoptRouteArtifacts(store, buildAddArtifacts(store, routes, parentName));
|
|
241
430
|
}
|
|
242
431
|
|
|
243
432
|
/**
|
|
244
|
-
* Atomically replaces all routes with a new set.
|
|
245
|
-
*
|
|
433
|
+
* Atomically replaces all routes with a new set (HMR / code-splitting).
|
|
434
|
+
* Prepare-then-commit (issue #698): the new set is fully built into locals
|
|
435
|
+
* first — a circular/async forwardTo or invalid path throws here, leaving the
|
|
436
|
+
* existing tree intact — then committed.
|
|
246
437
|
*/
|
|
247
438
|
function replaceRoutes<
|
|
248
439
|
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
@@ -252,33 +443,25 @@ function replaceRoutes<
|
|
|
252
443
|
ctx: RouterInternals<Dependencies>,
|
|
253
444
|
currentPath: string | undefined,
|
|
254
445
|
previousTransition: TransitionMeta | undefined,
|
|
446
|
+
onCommitted?: () => void,
|
|
255
447
|
): void {
|
|
256
|
-
//
|
|
257
|
-
|
|
448
|
+
// Build the whole new set BEFORE touching the store.
|
|
449
|
+
const artifacts = buildReplaceArtifacts(
|
|
450
|
+
routes,
|
|
451
|
+
store.rootPath,
|
|
452
|
+
store.matcherOptions,
|
|
453
|
+
);
|
|
258
454
|
|
|
259
|
-
//
|
|
455
|
+
// Clear definition lifecycle handlers (preserve external guards), then swap.
|
|
260
456
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed set after wiring
|
|
261
457
|
store.lifecycleNamespace!.clearDefinitionGuards();
|
|
458
|
+
adoptRouteArtifacts(store, artifacts);
|
|
262
459
|
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
registerAllRouteHandlers(
|
|
269
|
-
routes,
|
|
270
|
-
store.config,
|
|
271
|
-
store.routeCustomFields,
|
|
272
|
-
store.pendingCanActivate,
|
|
273
|
-
store.pendingCanDeactivate,
|
|
274
|
-
store.depsStore,
|
|
275
|
-
"",
|
|
276
|
-
);
|
|
460
|
+
// TREE_CHANGED fires here (О-5): the new tree is committed but state is not
|
|
461
|
+
// yet revalidated, so the handler sees the new tree and the still-old state.
|
|
462
|
+
onCommitted?.();
|
|
277
463
|
|
|
278
|
-
//
|
|
279
|
-
store.treeOperations.commitTreeChanges(store);
|
|
280
|
-
|
|
281
|
-
// Step 6: Revalidate state (preserve transition from previous state)
|
|
464
|
+
// Revalidate state (preserve transition from previous state)
|
|
282
465
|
if (currentPath !== undefined) {
|
|
283
466
|
const revalidated = ctx.matchPath(currentPath, ctx.getOptions());
|
|
284
467
|
|
|
@@ -341,7 +524,6 @@ function updateRouteConfig<
|
|
|
341
524
|
name,
|
|
342
525
|
updates.forwardTo,
|
|
343
526
|
store.config,
|
|
344
|
-
(config) => refreshForwardMap(config),
|
|
345
527
|
);
|
|
346
528
|
}
|
|
347
529
|
|
|
@@ -413,13 +595,12 @@ export function getRoutesApi<
|
|
|
413
595
|
|
|
414
596
|
const store = ctx.routeGetStore();
|
|
415
597
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
);
|
|
598
|
+
// Single cast site: the channel is typed with default Dependencies on
|
|
599
|
+
// RouterInternals (RouterEventMap is non-generic), but payloads are built
|
|
600
|
+
// with this api's Dependencies. The runtime shape is identical.
|
|
601
|
+
const emitChange = (event: TreeChangedEvent<Dependencies>): void => {
|
|
602
|
+
ctx.treeChanged.emit(event as TreeChangedEvent);
|
|
603
|
+
};
|
|
423
604
|
|
|
424
605
|
return {
|
|
425
606
|
add: (routes, options) => {
|
|
@@ -438,10 +619,18 @@ export function getRoutesApi<
|
|
|
438
619
|
ctx.validator?.routes.validateAddRouteArgs(routeArray);
|
|
439
620
|
ctx.validator?.routes.validateRoutes(routeArray, store);
|
|
440
621
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
)
|
|
622
|
+
addRoutes(store, routeArray, parentName);
|
|
623
|
+
|
|
624
|
+
// Built from the post-commit store (О-1), only when someone is listening.
|
|
625
|
+
if (ctx.treeChanged.listenerCount() > 0) {
|
|
626
|
+
const added = collectAddedRoutes(routeArray, parentName, store);
|
|
627
|
+
|
|
628
|
+
emitChange(
|
|
629
|
+
parentName === undefined
|
|
630
|
+
? { op: "add", added }
|
|
631
|
+
: { op: "add", added, parent: parentName },
|
|
632
|
+
);
|
|
633
|
+
}
|
|
445
634
|
},
|
|
446
635
|
|
|
447
636
|
remove: (name) => {
|
|
@@ -460,6 +649,11 @@ export function getRoutesApi<
|
|
|
460
649
|
return;
|
|
461
650
|
}
|
|
462
651
|
|
|
652
|
+
// Snapshot the subtree BEFORE the mutation — the nodes are gone after.
|
|
653
|
+
const removedSubtree =
|
|
654
|
+
ctx.treeChanged.listenerCount() > 0
|
|
655
|
+
? collectSubtree(store, name)
|
|
656
|
+
: undefined;
|
|
463
657
|
const wasRemoved = removeRoute(store, name);
|
|
464
658
|
|
|
465
659
|
if (!wasRemoved) {
|
|
@@ -467,6 +661,12 @@ export function getRoutesApi<
|
|
|
467
661
|
"router.removeRoute",
|
|
468
662
|
`Route "${name}" not found. No changes made.`,
|
|
469
663
|
);
|
|
664
|
+
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (removedSubtree !== undefined) {
|
|
669
|
+
emitChange({ op: "remove", name, removedSubtree });
|
|
470
670
|
}
|
|
471
671
|
},
|
|
472
672
|
|
|
@@ -523,6 +723,22 @@ export function getRoutesApi<
|
|
|
523
723
|
store.lifecycleNamespace!.addCanDeactivate(name, canDeactivate, true);
|
|
524
724
|
}
|
|
525
725
|
}
|
|
726
|
+
|
|
727
|
+
// Conditional emit: structural fields only, built from the destructured
|
|
728
|
+
// locals (so user getters are not re-invoked). A guard-only or empty
|
|
729
|
+
// patch produces no event (О-7 + empty-patch rule).
|
|
730
|
+
if (ctx.treeChanged.listenerCount() > 0) {
|
|
731
|
+
const patch = buildStructuralPatch<Dependencies>({
|
|
732
|
+
forwardTo,
|
|
733
|
+
defaultParams,
|
|
734
|
+
encodeParams,
|
|
735
|
+
decodeParams,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
if (Object.keys(patch).length > 0) {
|
|
739
|
+
emitChange({ op: "update", name, patch });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
526
742
|
},
|
|
527
743
|
|
|
528
744
|
clear: () => {
|
|
@@ -535,10 +751,21 @@ export function getRoutesApi<
|
|
|
535
751
|
return;
|
|
536
752
|
}
|
|
537
753
|
|
|
754
|
+
// Snapshot the routes BEFORE the reset empties them. Emitted whenever
|
|
755
|
+
// there is a listener — even for an empty clear (О-4).
|
|
756
|
+
const removed =
|
|
757
|
+
ctx.treeChanged.listenerCount() > 0
|
|
758
|
+
? Object.freeze([...collectFlatRoutes(store, () => true).values()])
|
|
759
|
+
: undefined;
|
|
760
|
+
|
|
538
761
|
store.treeOperations.resetStore(store);
|
|
539
762
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed set after wiring
|
|
540
763
|
store.lifecycleNamespace!.clearAll();
|
|
541
764
|
ctx.clearState();
|
|
765
|
+
|
|
766
|
+
if (removed !== undefined) {
|
|
767
|
+
emitChange({ op: "clear", removed });
|
|
768
|
+
}
|
|
542
769
|
},
|
|
543
770
|
|
|
544
771
|
has: (name) => {
|
|
@@ -575,13 +802,30 @@ export function getRoutesApi<
|
|
|
575
802
|
|
|
576
803
|
const currentState = router.getState();
|
|
577
804
|
|
|
805
|
+
// The flat removed/added diff is O(N) — compute it only when someone is
|
|
806
|
+
// listening (Решение 3.B). Snapshot the old tree BEFORE the swap.
|
|
807
|
+
const before =
|
|
808
|
+
ctx.treeChanged.listenerCount() > 0
|
|
809
|
+
? collectFlatRoutes(store, () => true)
|
|
810
|
+
: undefined;
|
|
811
|
+
|
|
578
812
|
replaceRoutes(
|
|
579
813
|
store,
|
|
580
814
|
routeArray,
|
|
581
815
|
ctx,
|
|
582
816
|
currentState?.path,
|
|
583
817
|
currentState?.transition,
|
|
818
|
+
before === undefined
|
|
819
|
+
? undefined
|
|
820
|
+
: () => {
|
|
821
|
+
const after = collectFlatRoutes(store, () => true);
|
|
822
|
+
const { removed, added } = diffFlatRoutes(before, after);
|
|
823
|
+
|
|
824
|
+
emitChange({ op: "replace", removed, added });
|
|
825
|
+
},
|
|
584
826
|
);
|
|
585
827
|
},
|
|
828
|
+
|
|
829
|
+
subscribeChanges: (handler) => ctx.treeChanged.subscribe(handler),
|
|
586
830
|
};
|
|
587
831
|
}
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,17 @@ export type {
|
|
|
34
34
|
Unsubscribe,
|
|
35
35
|
} from "@real-router/types";
|
|
36
36
|
|
|
37
|
+
// Route-tree mutation event payloads (observed via getRoutesApi().subscribeChanges)
|
|
38
|
+
export type {
|
|
39
|
+
TreeChangedEvent,
|
|
40
|
+
TreeChangedAdd,
|
|
41
|
+
TreeChangedRemove,
|
|
42
|
+
TreeChangedUpdate,
|
|
43
|
+
TreeChangedReplace,
|
|
44
|
+
TreeChangedClear,
|
|
45
|
+
TreeStructuralPatch,
|
|
46
|
+
} from "@real-router/types";
|
|
47
|
+
|
|
37
48
|
export type { ErrorCodes, Constants } from "./constants";
|
|
38
49
|
|
|
39
50
|
export { events, constants, errorCodes, UNKNOWN_ROUTE } from "./constants";
|
|
@@ -41,6 +52,11 @@ export { events, constants, errorCodes, UNKNOWN_ROUTE } from "./constants";
|
|
|
41
52
|
// RouterError class (migrated from router-error package)
|
|
42
53
|
export { RouterError } from "./RouterError";
|
|
43
54
|
|
|
55
|
+
// Re-exported so end users can `instanceof RecursionDepthError` at CRUD call
|
|
56
|
+
// sites — the only error that escapes a `subscribeChanges` handler (depth-limit
|
|
57
|
+
// overflow propagates, unlike ordinary listener errors which are isolated).
|
|
58
|
+
export { RecursionDepthError } from "event-emitter";
|
|
59
|
+
|
|
44
60
|
export { createRouter } from "./createRouter";
|
|
45
61
|
|
|
46
62
|
export { getNavigator } from "./getNavigator";
|