@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.
Files changed (78) hide show
  1. package/dist/cjs/Router-IEGavTKk.js +6 -0
  2. package/dist/cjs/Router-IEGavTKk.js.map +1 -0
  3. package/dist/cjs/{Router-CJihdrWA.d.ts → Router-hW6ivqrX.d.ts} +2 -2
  4. package/dist/cjs/Router-hW6ivqrX.d.ts.map +1 -0
  5. package/dist/cjs/api.d.ts +2 -2
  6. package/dist/cjs/api.d.ts.map +1 -1
  7. package/dist/cjs/api.js +1 -1
  8. package/dist/cjs/api.js.map +1 -1
  9. package/dist/cjs/{cloneRouter-zhA3NNoI.js → cloneRouter-DRieJvam.js} +2 -2
  10. package/dist/cjs/{cloneRouter-zhA3NNoI.js.map → cloneRouter-DRieJvam.js.map} +1 -1
  11. package/dist/cjs/{index-EwbhzRQw.d.ts → index-C-i6vx5Y.d.ts} +1 -1
  12. package/dist/cjs/index-C-i6vx5Y.d.ts.map +1 -0
  13. package/dist/cjs/{RouterError-Bm9YnZ6e.d.ts → index-CYpAZCoc.d.ts} +19 -2
  14. package/dist/cjs/index-CYpAZCoc.d.ts.map +1 -0
  15. package/dist/cjs/{index-8oPDJBQc.d.ts → index-D2WRiyWS.d.ts} +2 -2
  16. package/dist/cjs/index-D2WRiyWS.d.ts.map +1 -0
  17. package/dist/cjs/index.d.ts +5 -5
  18. package/dist/cjs/index.js +1 -1
  19. package/dist/cjs/{internals-CM6oaz9n.js → internals-DJjgSePy.js} +2 -2
  20. package/dist/cjs/internals-DJjgSePy.js.map +1 -0
  21. package/dist/cjs/utils.d.ts +1 -1
  22. package/dist/cjs/utils.js +1 -1
  23. package/dist/cjs/utils.js.map +1 -1
  24. package/dist/cjs/validation.d.ts +17 -5
  25. package/dist/cjs/validation.d.ts.map +1 -1
  26. package/dist/cjs/validation.js +1 -1
  27. package/dist/esm/Router-B3aeavRb.mjs +6 -0
  28. package/dist/esm/Router-B3aeavRb.mjs.map +1 -0
  29. package/dist/esm/{Router-BmhiDQUJ.d.mts → Router-hW6ivqrX.d.mts} +2 -2
  30. package/dist/esm/Router-hW6ivqrX.d.mts.map +1 -0
  31. package/dist/esm/api.d.mts +2 -2
  32. package/dist/esm/api.d.mts.map +1 -1
  33. package/dist/esm/api.mjs +1 -1
  34. package/dist/esm/api.mjs.map +1 -1
  35. package/dist/esm/cloneRouter-DHrH6D_z.mjs +2 -0
  36. package/dist/esm/{cloneRouter-U8NeEoPX.mjs.map → cloneRouter-DHrH6D_z.mjs.map} +1 -1
  37. package/dist/esm/{index-DNjaY7KH.d.mts → index-C-i6vx5Y.d.mts} +1 -1
  38. package/dist/esm/index-C-i6vx5Y.d.mts.map +1 -0
  39. package/dist/esm/{RouterError-hhfSVGtY.d.mts → index-CYpAZCoc.d.mts} +19 -2
  40. package/dist/esm/index-CYpAZCoc.d.mts.map +1 -0
  41. package/dist/esm/{index-r_JTvSBH.d.mts → index-CjWKWPY6.d.mts} +2 -2
  42. package/dist/esm/index-CjWKWPY6.d.mts.map +1 -0
  43. package/dist/esm/index.d.mts +5 -5
  44. package/dist/esm/index.mjs +1 -1
  45. package/dist/esm/index.mjs.map +1 -1
  46. package/dist/esm/{internals-C59msvHY.mjs → internals-C8mRvTxc.mjs} +2 -2
  47. package/dist/esm/internals-C8mRvTxc.mjs.map +1 -0
  48. package/dist/esm/utils.d.mts +1 -1
  49. package/dist/esm/utils.mjs +1 -1
  50. package/dist/esm/utils.mjs.map +1 -1
  51. package/dist/esm/validation.d.mts +17 -5
  52. package/dist/esm/validation.d.mts.map +1 -1
  53. package/dist/esm/validation.mjs +1 -1
  54. package/package.json +4 -4
  55. package/src/Router.ts +20 -8
  56. package/src/api/getRoutesApi.ts +368 -124
  57. package/src/index.ts +16 -0
  58. package/src/internals.ts +29 -1
  59. package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +37 -0
  60. package/src/namespaces/NavigationNamespace/types.ts +1 -1
  61. package/src/namespaces/RoutesNamespace/routesStore.ts +272 -52
  62. package/src/transitionPath.ts +7 -3
  63. package/src/types.ts +9 -1
  64. package/dist/cjs/Router-CJihdrWA.d.ts.map +0 -1
  65. package/dist/cjs/Router-DnIAA87f.js +0 -6
  66. package/dist/cjs/Router-DnIAA87f.js.map +0 -1
  67. package/dist/cjs/RouterError-Bm9YnZ6e.d.ts.map +0 -1
  68. package/dist/cjs/index-8oPDJBQc.d.ts.map +0 -1
  69. package/dist/cjs/index-EwbhzRQw.d.ts.map +0 -1
  70. package/dist/cjs/internals-CM6oaz9n.js.map +0 -1
  71. package/dist/esm/Router-BmhiDQUJ.d.mts.map +0 -1
  72. package/dist/esm/Router-pwd8YBWr.mjs +0 -6
  73. package/dist/esm/Router-pwd8YBWr.mjs.map +0 -1
  74. package/dist/esm/RouterError-hhfSVGtY.d.mts.map +0 -1
  75. package/dist/esm/cloneRouter-U8NeEoPX.mjs +0 -2
  76. package/dist/esm/index-DNjaY7KH.d.mts.map +0 -1
  77. package/dist/esm/index-r_JTvSBH.d.mts.map +0 -1
  78. package/dist/esm/internals-C59msvHY.mjs.map +0 -1
@@ -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 { createInterceptable, getInternals } from "../internals";
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
- clearRouteData,
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 config.forwardMap[name];
123
- delete config.forwardFnMap[name];
110
+ delete forwardMap[name];
111
+ delete forwardFnMap[name];
124
112
  } else if (typeof forwardTo === "string") {
125
- delete config.forwardFnMap[name];
126
- config.forwardMap[name] = forwardTo;
113
+ delete forwardFnMap[name];
114
+ forwardMap[name] = forwardTo;
127
115
  } else {
128
- delete config.forwardMap[name];
129
- config.forwardFnMap[name] = forwardTo;
116
+ delete forwardMap[name];
117
+ forwardFnMap[name] = forwardTo;
130
118
  }
131
119
 
132
- return refreshForwardMapFn(config);
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
- * Builds a full Route object from a bare RouteDefinition by re-attaching
137
- * config entries and lifecycle factories.
138
- *
139
- * RECURSIVE call with the factories tuple obtained ONCE from
140
- * `lifecycleNamespace.getFactories()` and pass it through to children.
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 enrichRoute<
135
+ function assignRouteConfig<
143
136
  Dependencies extends DefaultDependencies = DefaultDependencies,
144
137
  >(
145
- routeDef: RouteDefinition,
146
- routeName: string,
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 route: Route<Dependencies> = {
154
- name: routeDef.name,
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 (routeName in config.defaultParams) {
170
- route.defaultParams = config.defaultParams[routeName];
157
+ if (lookupName in config.defaultParams) {
158
+ route.defaultParams = config.defaultParams[lookupName];
171
159
  }
172
160
 
173
- if (routeName in config.decoders) {
174
- route.decodeParams = config.decoders[routeName];
161
+ if (lookupName in config.decoders) {
162
+ route.decodeParams = config.decoders[lookupName];
175
163
  }
176
164
 
177
- if (routeName in config.encoders) {
178
- route.encodeParams = config.encoders[routeName];
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 (routeName in canActivateFactories) {
184
- route.canActivate = canActivateFactories[routeName];
171
+ if (lookupName in canActivateFactories) {
172
+ route.canActivate = canActivateFactories[lookupName];
185
173
  }
186
174
 
187
- if (routeName in canDeactivateFactories) {
188
- route.canDeactivate = canDeactivateFactories[routeName];
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
- // CRUD operations
217
+ // TREE_CHANGED payload helpers
202
218
  // ============================================================================
203
219
 
204
220
  /**
205
- * Adds one or more routes to the router.
206
- * Input already validated by facade.
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 addRoutes<
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
- routes: Route<Dependencies>[],
213
- parentName?: string,
214
- ): void {
215
- if (parentName) {
216
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
217
- const parentDef = findDefinition(store.definitions, parentName)!;
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
- parentDef.children ??= [];
263
+ if (include(fullName)) {
264
+ result.set(
265
+ fullName,
266
+ buildFlatRoute(fullName, def.path, store.config, factories),
267
+ );
268
+ }
220
269
 
221
- for (const route of routes) {
222
- parentDef.children.push(sanitizeRoute(route));
270
+ if (def.children) {
271
+ walk(def.children, fullName);
272
+ }
223
273
  }
224
- } else {
225
- for (const route of routes) {
226
- store.definitions.push(sanitizeRoute(route));
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
- registerAllRouteHandlers(
231
- routes,
232
- store.config,
233
- store.routeCustomFields,
234
- store.pendingCanActivate,
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
- store.treeOperations.commitTreeChanges(store);
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
- * Follows RFC 6-step semantics for HMR support.
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
- // Step 2: Clear route data (WITHOUT tree rebuild)
257
- clearRouteData(store);
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
- // Step 3: Clear definition lifecycle handlers (preserve external guards)
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
- // Step 4: Register new routes
264
- for (const route of routes) {
265
- store.definitions.push(sanitizeRoute(route));
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
- // Step 5: One tree rebuild
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
- const interceptableAdd = createInterceptable(
417
- "add",
418
- (routeArray: Route<Dependencies>[], options?: { parent?: string }) => {
419
- addRoutes(store, routeArray, options?.parent);
420
- },
421
- ctx.interceptors,
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
- interceptableAdd(
442
- routeArray,
443
- parentName === undefined ? undefined : { parent: parentName },
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";