@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.
Files changed (71) hide show
  1. package/dist/cjs/Router-IEGavTKk.js +6 -0
  2. package/dist/cjs/Router-IEGavTKk.js.map +1 -0
  3. package/dist/{esm/Router-Dg-zk8AS.d.mts → cjs/Router-hW6ivqrX.d.ts} +2 -2
  4. package/dist/cjs/Router-hW6ivqrX.d.ts.map +1 -0
  5. package/dist/cjs/api.d.ts +1 -1
  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-C9Rth_8U.js → cloneRouter-DRieJvam.js} +2 -2
  10. package/dist/cjs/{cloneRouter-C9Rth_8U.js.map → cloneRouter-DRieJvam.js.map} +1 -1
  11. package/dist/cjs/index-C-i6vx5Y.d.ts.map +1 -1
  12. package/dist/cjs/{RouterError-WhCzIWuc.d.ts → index-CYpAZCoc.d.ts} +19 -2
  13. package/dist/cjs/index-CYpAZCoc.d.ts.map +1 -0
  14. package/dist/cjs/{index-K1U_fqfJ.d.ts → index-D2WRiyWS.d.ts} +2 -2
  15. package/dist/cjs/index-D2WRiyWS.d.ts.map +1 -0
  16. package/dist/cjs/index.d.ts +4 -4
  17. package/dist/cjs/index.js +1 -1
  18. package/dist/cjs/{internals-CWMOL1B8.js → internals-DJjgSePy.js} +2 -2
  19. package/dist/cjs/internals-DJjgSePy.js.map +1 -0
  20. package/dist/cjs/utils.d.ts +1 -1
  21. package/dist/cjs/utils.js +1 -1
  22. package/dist/cjs/utils.js.map +1 -1
  23. package/dist/cjs/validation.d.ts +16 -4
  24. package/dist/cjs/validation.d.ts.map +1 -1
  25. package/dist/cjs/validation.js +1 -1
  26. package/dist/esm/Router-B3aeavRb.mjs +6 -0
  27. package/dist/esm/Router-B3aeavRb.mjs.map +1 -0
  28. package/dist/{cjs/Router-Dg-zk8AS.d.ts → esm/Router-hW6ivqrX.d.mts} +2 -2
  29. package/dist/esm/Router-hW6ivqrX.d.mts.map +1 -0
  30. package/dist/esm/api.d.mts +1 -1
  31. package/dist/esm/api.d.mts.map +1 -1
  32. package/dist/esm/api.mjs +1 -1
  33. package/dist/esm/api.mjs.map +1 -1
  34. package/dist/esm/{cloneRouter-BYNiwchg.mjs → cloneRouter-DHrH6D_z.mjs} +2 -2
  35. package/dist/esm/{cloneRouter-BYNiwchg.mjs.map → cloneRouter-DHrH6D_z.mjs.map} +1 -1
  36. package/dist/esm/index-C-i6vx5Y.d.mts.map +1 -1
  37. package/dist/esm/{RouterError-WhCzIWuc.d.mts → index-CYpAZCoc.d.mts} +19 -2
  38. package/dist/esm/index-CYpAZCoc.d.mts.map +1 -0
  39. package/dist/esm/{index-DKzxav48.d.mts → index-CjWKWPY6.d.mts} +2 -2
  40. package/dist/esm/index-CjWKWPY6.d.mts.map +1 -0
  41. package/dist/esm/index.d.mts +4 -4
  42. package/dist/esm/index.mjs +1 -1
  43. package/dist/esm/index.mjs.map +1 -1
  44. package/dist/esm/{internals-DT4mneSz.mjs → internals-C8mRvTxc.mjs} +2 -2
  45. package/dist/esm/internals-C8mRvTxc.mjs.map +1 -0
  46. package/dist/esm/utils.d.mts +1 -1
  47. package/dist/esm/utils.mjs +1 -1
  48. package/dist/esm/utils.mjs.map +1 -1
  49. package/dist/esm/validation.d.mts +16 -4
  50. package/dist/esm/validation.d.mts.map +1 -1
  51. package/dist/esm/validation.mjs +1 -1
  52. package/package.json +2 -2
  53. package/src/Router.ts +20 -8
  54. package/src/api/getRoutesApi.ts +322 -37
  55. package/src/index.ts +16 -0
  56. package/src/internals.ts +29 -1
  57. package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +37 -0
  58. package/src/transitionPath.ts +7 -3
  59. package/src/types.ts +9 -1
  60. package/dist/cjs/Router-C7eE1kIK.js +0 -6
  61. package/dist/cjs/Router-C7eE1kIK.js.map +0 -1
  62. package/dist/cjs/Router-Dg-zk8AS.d.ts.map +0 -1
  63. package/dist/cjs/RouterError-WhCzIWuc.d.ts.map +0 -1
  64. package/dist/cjs/index-K1U_fqfJ.d.ts.map +0 -1
  65. package/dist/cjs/internals-CWMOL1B8.js.map +0 -1
  66. package/dist/esm/Router-Dg-zk8AS.d.mts.map +0 -1
  67. package/dist/esm/Router-DiZbYMLx.mjs +0 -6
  68. package/dist/esm/Router-DiZbYMLx.mjs.map +0 -1
  69. package/dist/esm/RouterError-WhCzIWuc.d.mts.map +0 -1
  70. package/dist/esm/index-DKzxav48.d.mts.map +0 -1
  71. package/dist/esm/internals-DT4mneSz.mjs.map +0 -1
@@ -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 { createInterceptable, getInternals } from "../internals";
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
- * Builds a full Route object from a bare RouteDefinition by re-attaching
128
- * config entries and lifecycle factories.
129
- *
130
- * RECURSIVE call with the factories tuple obtained ONCE from
131
- * `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.
132
134
  */
133
- function enrichRoute<
135
+ function assignRouteConfig<
134
136
  Dependencies extends DefaultDependencies = DefaultDependencies,
135
137
  >(
136
- routeDef: RouteDefinition,
137
- routeName: string,
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 route: Route<Dependencies> = {
145
- name: routeDef.name,
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 (routeName in config.defaultParams) {
161
- route.defaultParams = config.defaultParams[routeName];
157
+ if (lookupName in config.defaultParams) {
158
+ route.defaultParams = config.defaultParams[lookupName];
162
159
  }
163
160
 
164
- if (routeName in config.decoders) {
165
- route.decodeParams = config.decoders[routeName];
161
+ if (lookupName in config.decoders) {
162
+ route.decodeParams = config.decoders[lookupName];
166
163
  }
167
164
 
168
- if (routeName in config.encoders) {
169
- route.encodeParams = config.encoders[routeName];
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 (routeName in canActivateFactories) {
175
- route.canActivate = canActivateFactories[routeName];
171
+ if (lookupName in canActivateFactories) {
172
+ route.canActivate = canActivateFactories[lookupName];
176
173
  }
177
174
 
178
- if (routeName in canDeactivateFactories) {
179
- route.canDeactivate = canDeactivateFactories[routeName];
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
- const interceptableAdd = createInterceptable(
376
- "add",
377
- (routeArray: Route<Dependencies>[], options?: { parent?: string }) => {
378
- addRoutes(store, routeArray, options?.parent);
379
- },
380
- ctx.interceptors,
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
- interceptableAdd(
401
- routeArray,
402
- parentName === undefined ? undefined : { parent: parentName },
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
- export function createInterceptable2<A, B, R>(
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
  }
@@ -86,10 +86,14 @@ function nameToIDsGeneral(name: string): string[] {
86
86
  return ids;
87
87
  }
88
88
 
89
- function isPrimitive(value: unknown): value is PrimitiveParam {
90
- const type = typeof value;
89
+ const PRIMITIVE_TYPES: ReadonlySet<string> = new Set([
90
+ "string",
91
+ "number",
92
+ "boolean",
93
+ ]);
91
94
 
92
- return type === "string" || type === "number" || type === "boolean";
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
  /**