@real-router/core 0.55.0 → 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/{esm/Router-Dg-zk8AS.d.mts → cjs/Router-hW6ivqrX.d.ts} +2 -2
- package/dist/cjs/Router-hW6ivqrX.d.ts.map +1 -0
- package/dist/cjs/api.d.ts +1 -1
- 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-C9Rth_8U.js → cloneRouter-DRieJvam.js} +2 -2
- package/dist/cjs/{cloneRouter-C9Rth_8U.js.map → cloneRouter-DRieJvam.js.map} +1 -1
- package/dist/cjs/index-C-i6vx5Y.d.ts.map +1 -1
- package/dist/cjs/{RouterError-WhCzIWuc.d.ts → index-CYpAZCoc.d.ts} +19 -2
- package/dist/cjs/index-CYpAZCoc.d.ts.map +1 -0
- package/dist/cjs/{index-K1U_fqfJ.d.ts → index-D2WRiyWS.d.ts} +2 -2
- package/dist/cjs/index-D2WRiyWS.d.ts.map +1 -0
- package/dist/cjs/index.d.ts +4 -4
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/{internals-CWMOL1B8.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 +16 -4
- 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/{cjs/Router-Dg-zk8AS.d.ts → esm/Router-hW6ivqrX.d.mts} +2 -2
- package/dist/esm/Router-hW6ivqrX.d.mts.map +1 -0
- package/dist/esm/api.d.mts +1 -1
- 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-BYNiwchg.mjs → cloneRouter-DHrH6D_z.mjs} +2 -2
- package/dist/esm/{cloneRouter-BYNiwchg.mjs.map → cloneRouter-DHrH6D_z.mjs.map} +1 -1
- package/dist/esm/index-C-i6vx5Y.d.mts.map +1 -1
- package/dist/esm/{RouterError-WhCzIWuc.d.mts → index-CYpAZCoc.d.mts} +19 -2
- package/dist/esm/index-CYpAZCoc.d.mts.map +1 -0
- package/dist/esm/{index-DKzxav48.d.mts → index-CjWKWPY6.d.mts} +2 -2
- package/dist/esm/index-CjWKWPY6.d.mts.map +1 -0
- package/dist/esm/index.d.mts +4 -4
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/{internals-DT4mneSz.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 +16 -4
- package/dist/esm/validation.d.mts.map +1 -1
- package/dist/esm/validation.mjs +1 -1
- package/package.json +2 -2
- package/src/Router.ts +20 -8
- package/src/api/getRoutesApi.ts +322 -37
- package/src/index.ts +16 -0
- package/src/internals.ts +29 -1
- package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +37 -0
- package/src/transitionPath.ts +7 -3
- package/src/types.ts +9 -1
- package/dist/cjs/Router-C7eE1kIK.js +0 -6
- package/dist/cjs/Router-C7eE1kIK.js.map +0 -1
- package/dist/cjs/Router-Dg-zk8AS.d.ts.map +0 -1
- package/dist/cjs/RouterError-WhCzIWuc.d.ts.map +0 -1
- package/dist/cjs/index-K1U_fqfJ.d.ts.map +0 -1
- package/dist/cjs/internals-CWMOL1B8.js.map +0 -1
- package/dist/esm/Router-Dg-zk8AS.d.mts.map +0 -1
- package/dist/esm/Router-DiZbYMLx.mjs +0 -6
- package/dist/esm/Router-DiZbYMLx.mjs.map +0 -1
- package/dist/esm/RouterError-WhCzIWuc.d.mts.map +0 -1
- package/dist/esm/index-DKzxav48.d.mts.map +0 -1
- package/dist/esm/internals-DT4mneSz.mjs.map +0 -1
package/src/api/getRoutesApi.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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,
|
|
@@ -30,6 +30,8 @@ import type {
|
|
|
30
30
|
Params,
|
|
31
31
|
Router,
|
|
32
32
|
TransitionMeta,
|
|
33
|
+
TreeChangedEvent,
|
|
34
|
+
TreeStructuralPatch,
|
|
33
35
|
} from "@real-router/types";
|
|
34
36
|
import type { RouteDefinition, RouteTree } from "route-tree";
|
|
35
37
|
|
|
@@ -124,30 +126,25 @@ function updateForwardTo<
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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.
|
|
132
134
|
*/
|
|
133
|
-
function
|
|
135
|
+
function assignRouteConfig<
|
|
134
136
|
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
135
137
|
>(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
route: Route<Dependencies>,
|
|
139
|
+
lookupName: string,
|
|
138
140
|
config: RouteConfig,
|
|
139
141
|
factories: [
|
|
140
142
|
Record<string, GuardFnFactory<Dependencies>>,
|
|
141
143
|
Record<string, GuardFnFactory<Dependencies>>,
|
|
142
144
|
],
|
|
143
145
|
): Route<Dependencies> {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
path: routeDef.path,
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const forwardToFn = config.forwardFnMap[routeName];
|
|
150
|
-
const forwardToStr = config.forwardMap[routeName];
|
|
146
|
+
const forwardToFn = config.forwardFnMap[lookupName];
|
|
147
|
+
const forwardToStr = config.forwardMap[lookupName];
|
|
151
148
|
|
|
152
149
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
153
150
|
if (forwardToFn !== undefined) {
|
|
@@ -157,28 +154,56 @@ function enrichRoute<
|
|
|
157
154
|
route.forwardTo = forwardToStr;
|
|
158
155
|
}
|
|
159
156
|
|
|
160
|
-
if (
|
|
161
|
-
route.defaultParams = config.defaultParams[
|
|
157
|
+
if (lookupName in config.defaultParams) {
|
|
158
|
+
route.defaultParams = config.defaultParams[lookupName];
|
|
162
159
|
}
|
|
163
160
|
|
|
164
|
-
if (
|
|
165
|
-
route.decodeParams = config.decoders[
|
|
161
|
+
if (lookupName in config.decoders) {
|
|
162
|
+
route.decodeParams = config.decoders[lookupName];
|
|
166
163
|
}
|
|
167
164
|
|
|
168
|
-
if (
|
|
169
|
-
route.encodeParams = config.encoders[
|
|
165
|
+
if (lookupName in config.encoders) {
|
|
166
|
+
route.encodeParams = config.encoders[lookupName];
|
|
170
167
|
}
|
|
171
168
|
|
|
172
169
|
const [canDeactivateFactories, canActivateFactories] = factories;
|
|
173
170
|
|
|
174
|
-
if (
|
|
175
|
-
route.canActivate = canActivateFactories[
|
|
171
|
+
if (lookupName in canActivateFactories) {
|
|
172
|
+
route.canActivate = canActivateFactories[lookupName];
|
|
176
173
|
}
|
|
177
174
|
|
|
178
|
-
if (
|
|
179
|
-
route.canDeactivate = canDeactivateFactories[
|
|
175
|
+
if (lookupName in canDeactivateFactories) {
|
|
176
|
+
route.canDeactivate = canDeactivateFactories[lookupName];
|
|
180
177
|
}
|
|
181
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
|
+
|
|
182
207
|
if (routeDef.children) {
|
|
183
208
|
route.children = routeDef.children.map((child) =>
|
|
184
209
|
enrichRoute(child, `${routeName}.${child.name}`, config, factories),
|
|
@@ -188,6 +213,199 @@ function enrichRoute<
|
|
|
188
213
|
return route;
|
|
189
214
|
}
|
|
190
215
|
|
|
216
|
+
// ============================================================================
|
|
217
|
+
// TREE_CHANGED payload helpers
|
|
218
|
+
// ============================================================================
|
|
219
|
+
|
|
220
|
+
/**
|
|
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.
|
|
224
|
+
*/
|
|
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<
|
|
250
|
+
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
251
|
+
>(
|
|
252
|
+
store: RoutesStore<Dependencies>,
|
|
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;
|
|
262
|
+
|
|
263
|
+
if (include(fullName)) {
|
|
264
|
+
result.set(
|
|
265
|
+
fullName,
|
|
266
|
+
buildFlatRoute(fullName, def.path, store.config, factories),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (def.children) {
|
|
271
|
+
walk(def.children, fullName);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
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);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const [fullName, route] of after) {
|
|
360
|
+
if (!before.has(fullName)) {
|
|
361
|
+
added.push(route);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
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
|
+
|
|
191
409
|
// ============================================================================
|
|
192
410
|
// CRUD operations
|
|
193
411
|
// ============================================================================
|
|
@@ -225,6 +443,7 @@ function replaceRoutes<
|
|
|
225
443
|
ctx: RouterInternals<Dependencies>,
|
|
226
444
|
currentPath: string | undefined,
|
|
227
445
|
previousTransition: TransitionMeta | undefined,
|
|
446
|
+
onCommitted?: () => void,
|
|
228
447
|
): void {
|
|
229
448
|
// Build the whole new set BEFORE touching the store.
|
|
230
449
|
const artifacts = buildReplaceArtifacts(
|
|
@@ -238,6 +457,10 @@ function replaceRoutes<
|
|
|
238
457
|
store.lifecycleNamespace!.clearDefinitionGuards();
|
|
239
458
|
adoptRouteArtifacts(store, artifacts);
|
|
240
459
|
|
|
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?.();
|
|
463
|
+
|
|
241
464
|
// Revalidate state (preserve transition from previous state)
|
|
242
465
|
if (currentPath !== undefined) {
|
|
243
466
|
const revalidated = ctx.matchPath(currentPath, ctx.getOptions());
|
|
@@ -372,13 +595,12 @@ export function getRoutesApi<
|
|
|
372
595
|
|
|
373
596
|
const store = ctx.routeGetStore();
|
|
374
597
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
);
|
|
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
|
+
};
|
|
382
604
|
|
|
383
605
|
return {
|
|
384
606
|
add: (routes, options) => {
|
|
@@ -397,10 +619,18 @@ export function getRoutesApi<
|
|
|
397
619
|
ctx.validator?.routes.validateAddRouteArgs(routeArray);
|
|
398
620
|
ctx.validator?.routes.validateRoutes(routeArray, store);
|
|
399
621
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
)
|
|
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
|
+
}
|
|
404
634
|
},
|
|
405
635
|
|
|
406
636
|
remove: (name) => {
|
|
@@ -419,6 +649,11 @@ export function getRoutesApi<
|
|
|
419
649
|
return;
|
|
420
650
|
}
|
|
421
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;
|
|
422
657
|
const wasRemoved = removeRoute(store, name);
|
|
423
658
|
|
|
424
659
|
if (!wasRemoved) {
|
|
@@ -426,6 +661,12 @@ export function getRoutesApi<
|
|
|
426
661
|
"router.removeRoute",
|
|
427
662
|
`Route "${name}" not found. No changes made.`,
|
|
428
663
|
);
|
|
664
|
+
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (removedSubtree !== undefined) {
|
|
669
|
+
emitChange({ op: "remove", name, removedSubtree });
|
|
429
670
|
}
|
|
430
671
|
},
|
|
431
672
|
|
|
@@ -482,6 +723,22 @@ export function getRoutesApi<
|
|
|
482
723
|
store.lifecycleNamespace!.addCanDeactivate(name, canDeactivate, true);
|
|
483
724
|
}
|
|
484
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
|
+
}
|
|
485
742
|
},
|
|
486
743
|
|
|
487
744
|
clear: () => {
|
|
@@ -494,10 +751,21 @@ export function getRoutesApi<
|
|
|
494
751
|
return;
|
|
495
752
|
}
|
|
496
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
|
+
|
|
497
761
|
store.treeOperations.resetStore(store);
|
|
498
762
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed set after wiring
|
|
499
763
|
store.lifecycleNamespace!.clearAll();
|
|
500
764
|
ctx.clearState();
|
|
765
|
+
|
|
766
|
+
if (removed !== undefined) {
|
|
767
|
+
emitChange({ op: "clear", removed });
|
|
768
|
+
}
|
|
501
769
|
},
|
|
502
770
|
|
|
503
771
|
has: (name) => {
|
|
@@ -534,13 +802,30 @@ export function getRoutesApi<
|
|
|
534
802
|
|
|
535
803
|
const currentState = router.getState();
|
|
536
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
|
+
|
|
537
812
|
replaceRoutes(
|
|
538
813
|
store,
|
|
539
814
|
routeArray,
|
|
540
815
|
ctx,
|
|
541
816
|
currentState?.path,
|
|
542
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
|
+
},
|
|
543
826
|
);
|
|
544
827
|
},
|
|
828
|
+
|
|
829
|
+
subscribeChanges: (handler) => ctx.treeChanged.subscribe(handler),
|
|
545
830
|
};
|
|
546
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";
|
package/src/internals.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
RouteTreeState,
|
|
16
16
|
SimpleState,
|
|
17
17
|
State,
|
|
18
|
+
TreeChangedEvent,
|
|
18
19
|
Unsubscribe,
|
|
19
20
|
} from "@real-router/types";
|
|
20
21
|
import type { RouteTree } from "route-tree";
|
|
@@ -51,6 +52,21 @@ export interface RouterInternals<
|
|
|
51
52
|
cb: Plugin[EventMethodMap[E]],
|
|
52
53
|
) => Unsubscribe;
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Route-tree mutation channel — internal access for the `getRoutesApi`
|
|
57
|
+
* wrapper. A dedicated bridge is required because the public
|
|
58
|
+
* `addEventListener<E extends EventName>` structurally rejects
|
|
59
|
+
* `"TREE_CHANGED"` (it is not in the public `EventName` union), is strict on
|
|
60
|
+
* duplicates, and exposes neither `emit` nor `listenerCount`.
|
|
61
|
+
*/
|
|
62
|
+
readonly treeChanged: {
|
|
63
|
+
readonly emit: (event: TreeChangedEvent) => void;
|
|
64
|
+
readonly subscribe: (
|
|
65
|
+
handler: (event: TreeChangedEvent) => void,
|
|
66
|
+
) => Unsubscribe;
|
|
67
|
+
readonly listenerCount: () => number;
|
|
68
|
+
};
|
|
69
|
+
|
|
54
70
|
readonly buildPath: (route: string, params?: Params) => string;
|
|
55
71
|
|
|
56
72
|
readonly emitTransitionError: (error: Error) => void;
|
|
@@ -160,6 +176,12 @@ function executeInterceptorChain<T>(
|
|
|
160
176
|
return chain(...args) as T;
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Variadic interceptor wrapper — wraps a function of any arity, returning the
|
|
181
|
+
* same callable type `T`. Use {@link createBinaryInterceptable} instead when the
|
|
182
|
+
* wrapped method takes exactly two args and the caller needs the precise
|
|
183
|
+
* `(a, b) => r` signature preserved (the variadic form widens args to `any[]`).
|
|
184
|
+
*/
|
|
163
185
|
export function createInterceptable<T extends (...args: any[]) => any>(
|
|
164
186
|
name: string,
|
|
165
187
|
original: T,
|
|
@@ -179,7 +201,13 @@ export function createInterceptable<T extends (...args: any[]) => any>(
|
|
|
179
201
|
}) as T;
|
|
180
202
|
}
|
|
181
203
|
|
|
182
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Two-argument interceptor wrapper — preserves the exact `(a: A, b: B) => R`
|
|
206
|
+
* signature, which the variadic {@link createInterceptable} cannot express
|
|
207
|
+
* (it widens args to `any[]`). Used for the binary interceptable methods
|
|
208
|
+
* `forwardState(routeName, routeParams)` and `buildPath(route, params)`.
|
|
209
|
+
*/
|
|
210
|
+
export function createBinaryInterceptable<A, B, R>(
|
|
183
211
|
name: string,
|
|
184
212
|
original: (a: A, b: B) => R,
|
|
185
213
|
interceptors: Map<
|
|
@@ -16,10 +16,19 @@ import type {
|
|
|
16
16
|
Plugin,
|
|
17
17
|
State,
|
|
18
18
|
SubscribeFn,
|
|
19
|
+
TreeChangedEvent,
|
|
19
20
|
Unsubscribe,
|
|
20
21
|
} from "@real-router/types";
|
|
21
22
|
import type { EventEmitter } from "event-emitter";
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Internal-only event key for route-tree mutations. Lives on the same
|
|
26
|
+
* `EventEmitter` as the 7 transition events but never enters the public
|
|
27
|
+
* `EventName` union — reachable only through
|
|
28
|
+
* `getRoutesApi(router).subscribeChanges()`.
|
|
29
|
+
*/
|
|
30
|
+
const TREE_CHANGED = "TREE_CHANGED";
|
|
31
|
+
|
|
23
32
|
function ensureError(value: unknown): Error {
|
|
24
33
|
/* v8 ignore next -- @preserve: defensive guard — listeners should always throw Error objects */
|
|
25
34
|
return value instanceof Error ? value : new Error(String(value));
|
|
@@ -141,6 +150,34 @@ export class EventBusNamespace {
|
|
|
141
150
|
this.#emitter.emit(events.TRANSITION_LEAVE_APPROVE, toState, fromState);
|
|
142
151
|
}
|
|
143
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Emits the internal `TREE_CHANGED` event after a structural route-tree
|
|
155
|
+
* mutation. Reuses the shared `EventEmitter` — so depth tracking
|
|
156
|
+
* (`maxEventDepth`) and per-listener error isolation (`onListenerError`)
|
|
157
|
+
* apply automatically.
|
|
158
|
+
*/
|
|
159
|
+
emitTreeChanged(event: TreeChangedEvent): void {
|
|
160
|
+
this.#emitter.emit(TREE_CHANGED, event);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Subscribes to `TREE_CHANGED`. **Lenient** duplicate semantics (mirrors
|
|
165
|
+
* {@link subscribe}): each call wraps the handler in a fresh closure, so N
|
|
166
|
+
* registrations of the same reference produce N independent subscriptions.
|
|
167
|
+
*/
|
|
168
|
+
subscribeTreeChanged(
|
|
169
|
+
handler: (event: TreeChangedEvent) => void,
|
|
170
|
+
): Unsubscribe {
|
|
171
|
+
return this.#emitter.on(TREE_CHANGED, (event: TreeChangedEvent) => {
|
|
172
|
+
handler(event);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Number of active `TREE_CHANGED` listeners (drives conditional emit). */
|
|
177
|
+
treeChangedListenerCount(): number {
|
|
178
|
+
return this.#emitter.listenerCount(TREE_CHANGED);
|
|
179
|
+
}
|
|
180
|
+
|
|
144
181
|
sendStart(): void {
|
|
145
182
|
this.#fsm.send(routerEvents.START);
|
|
146
183
|
}
|
package/src/transitionPath.ts
CHANGED
|
@@ -86,10 +86,14 @@ function nameToIDsGeneral(name: string): string[] {
|
|
|
86
86
|
return ids;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
const PRIMITIVE_TYPES: ReadonlySet<string> = new Set([
|
|
90
|
+
"string",
|
|
91
|
+
"number",
|
|
92
|
+
"boolean",
|
|
93
|
+
]);
|
|
91
94
|
|
|
92
|
-
|
|
95
|
+
function isPrimitive(value: unknown): value is PrimitiveParam {
|
|
96
|
+
return PRIMITIVE_TYPES.has(typeof value);
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
/**
|
package/src/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
RouterError as RouterErrorType,
|
|
16
16
|
RouteTreeState,
|
|
17
17
|
State,
|
|
18
|
+
TreeChangedEvent,
|
|
18
19
|
} from "@real-router/types";
|
|
19
20
|
|
|
20
21
|
// Re-export from @real-router/types (canonical source)
|
|
@@ -27,10 +28,16 @@ export type {
|
|
|
27
28
|
} from "@real-router/types";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* Event argument tuples for the router's 7 events
|
|
31
|
+
* Event argument tuples for the router's 7 transition events plus the internal
|
|
32
|
+
* `TREE_CHANGED` channel.
|
|
31
33
|
*
|
|
32
34
|
* Uses explicit `| undefined` unions (not optional `?`) to satisfy
|
|
33
35
|
* `exactOptionalPropertyTypes` when passing undefined args from FSM payloads.
|
|
36
|
+
*
|
|
37
|
+
* `TREE_CHANGED` is an **internal-only** key: it is deliberately absent from the
|
|
38
|
+
* public `EventName` union / `events.*` registry / `Plugin` interface. It
|
|
39
|
+
* reuses the same `EventEmitter` (depth tracking, error isolation) but is only
|
|
40
|
+
* reachable via `getRoutesApi(router).subscribeChanges()`.
|
|
34
41
|
*/
|
|
35
42
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- must be `type` for Record<string, unknown[]> constraint
|
|
36
43
|
export type RouterEventMap = {
|
|
@@ -49,6 +56,7 @@ export type RouterEventMap = {
|
|
|
49
56
|
error: RouterErrorType | undefined,
|
|
50
57
|
];
|
|
51
58
|
$$cancel: [toState: State, fromState: State | undefined];
|
|
59
|
+
TREE_CHANGED: [event: TreeChangedEvent];
|
|
52
60
|
};
|
|
53
61
|
|
|
54
62
|
/**
|