@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
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
  }
@@ -23,7 +23,7 @@ export interface NavigationContext {
23
23
  *
24
24
  * These are function references from other namespaces/facade,
25
25
  * avoiding the need to pass the entire Router object.
26
- **/
26
+ */
27
27
  export interface NavigationDependencies {
28
28
  /** Get router options */
29
29
  getOptions: () => Options;
@@ -132,6 +132,32 @@ export function refreshForwardMap(config: RouteConfig): Record<string, string> {
132
132
  // Route handler registration
133
133
  // =============================================================================
134
134
 
135
+ /**
136
+ * Throws if `forwardTo` is an async function (native or transpiled). Async
137
+ * forwardTo callbacks break the synchronous matchPath/buildPath contract.
138
+ * Runs inside `registerForwardTo`, which the prepare-phase build invokes via
139
+ * `registerAllRouteHandlers` — so the check fires before any store mutation.
140
+ */
141
+ function assertForwardToNotAsync(forwardTo: unknown, fullName: string): void {
142
+ if (typeof forwardTo !== "function") {
143
+ return;
144
+ }
145
+
146
+ const isNativeAsync =
147
+ (forwardTo as { constructor: { name: string } }).constructor.name ===
148
+ "AsyncFunction";
149
+ const isTranspiledAsync = (forwardTo as { toString: () => string })
150
+ .toString()
151
+ .includes("__awaiter");
152
+
153
+ if (isNativeAsync || isTranspiledAsync) {
154
+ throw new TypeError(
155
+ `forwardTo callback cannot be async for route "${fullName}". ` +
156
+ `Async functions break matchPath/buildPath.`,
157
+ );
158
+ }
159
+ }
160
+
135
161
  function registerForwardTo<Dependencies extends DefaultDependencies>(
136
162
  route: Route<Dependencies>,
137
163
  fullName: string,
@@ -163,19 +189,7 @@ function registerForwardTo<Dependencies extends DefaultDependencies>(
163
189
  );
164
190
  }
165
191
 
166
- if (typeof route.forwardTo === "function") {
167
- const isNativeAsync =
168
- (route.forwardTo as { constructor: { name: string } }).constructor
169
- .name === "AsyncFunction";
170
- const isTranspiledAsync = route.forwardTo.toString().includes("__awaiter");
171
-
172
- if (isNativeAsync || isTranspiledAsync) {
173
- throw new TypeError(
174
- `forwardTo callback cannot be async for route "${fullName}". ` +
175
- `Async functions break matchPath/buildPath.`,
176
- );
177
- }
178
- }
192
+ assertForwardToNotAsync(route.forwardTo, fullName);
179
193
 
180
194
  // forwardTo is guaranteed to exist at this point
181
195
  if (typeof route.forwardTo === "string") {
@@ -193,7 +207,6 @@ function registerSingleRouteHandlers<Dependencies extends DefaultDependencies>(
193
207
  routeCustomFields: Record<string, Record<string, unknown>>,
194
208
  pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>,
195
209
  pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>,
196
- depsStore: RoutesDependencies<Dependencies> | undefined,
197
210
  ): void {
198
211
  const standardKeys = new Set([
199
212
  "name",
@@ -214,20 +227,15 @@ function registerSingleRouteHandlers<Dependencies extends DefaultDependencies>(
214
227
  routeCustomFields[fullName] = customFields;
215
228
  }
216
229
 
230
+ // Guards are collected here and registered into the lifecycle later — by
231
+ // `adoptRouteArtifacts` (add/replace) or `setDependencies` (initial routes) —
232
+ // so the build stays a pure, side-effect-free preparation step.
217
233
  if (route.canActivate) {
218
- if (depsStore) {
219
- depsStore.addActivateGuard(fullName, route.canActivate);
220
- } else {
221
- pendingCanActivate.set(fullName, route.canActivate);
222
- }
234
+ pendingCanActivate.set(fullName, route.canActivate);
223
235
  }
224
236
 
225
237
  if (route.canDeactivate) {
226
- if (depsStore) {
227
- depsStore.addDeactivateGuard(fullName, route.canDeactivate);
228
- } else {
229
- pendingCanDeactivate.set(fullName, route.canDeactivate);
230
- }
238
+ pendingCanDeactivate.set(fullName, route.canDeactivate);
231
239
  }
232
240
 
233
241
  if (route.forwardTo) {
@@ -249,15 +257,12 @@ function registerSingleRouteHandlers<Dependencies extends DefaultDependencies>(
249
257
  }
250
258
  }
251
259
 
252
- export function registerAllRouteHandlers<
253
- Dependencies extends DefaultDependencies,
254
- >(
260
+ function registerAllRouteHandlers<Dependencies extends DefaultDependencies>(
255
261
  routes: readonly Route<Dependencies>[],
256
262
  config: RouteConfig,
257
263
  routeCustomFields: Record<string, Record<string, unknown>>,
258
264
  pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>,
259
265
  pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>,
260
- depsStore: RoutesDependencies<Dependencies> | undefined,
261
266
  parentName = "",
262
267
  ): void {
263
268
  for (const route of routes) {
@@ -270,7 +275,6 @@ export function registerAllRouteHandlers<
270
275
  routeCustomFields,
271
276
  pendingCanActivate,
272
277
  pendingCanDeactivate,
273
- depsStore,
274
278
  );
275
279
 
276
280
  if (route.children) {
@@ -280,7 +284,6 @@ export function registerAllRouteHandlers<
280
284
  routeCustomFields,
281
285
  pendingCanActivate,
282
286
  pendingCanDeactivate,
283
- depsStore,
284
287
  fullName,
285
288
  );
286
289
  }
@@ -288,55 +291,272 @@ export function registerAllRouteHandlers<
288
291
  }
289
292
 
290
293
  // =============================================================================
291
- // Factory
294
+ // Prepare-then-commit (issue #698)
295
+ //
296
+ // add()/replace() build the complete new store state into LOCAL structures, and
297
+ // only swap it into the store once every core-level error has surfaced from the
298
+ // build itself (async/circular forwardTo throw in registerAllRouteHandlers /
299
+ // refreshForwardMap; invalid path constraint throws in rebuildTree). The store
300
+ // is mutated only by `adoptRouteArtifacts`, which cannot throw — so a rejected
301
+ // build leaves the existing routes untouched. The two silent-corruption cases
302
+ // route-tree never throws on (duplicate name vs an existing route, missing
303
+ // parent) are caught up front by `assertAddable`.
292
304
  // =============================================================================
293
305
 
294
- export function createRoutesStore<
306
+ /**
307
+ * The fully-built, ready-to-swap result of preparing a route mutation. Holds
308
+ * everything `adoptRouteArtifacts` assigns into the store.
309
+ */
310
+ interface RouteArtifacts<
295
311
  Dependencies extends DefaultDependencies = DefaultDependencies,
296
- >(
297
- routes: Route<Dependencies>[],
298
- matcherOptions?: CreateMatcherOptions,
299
- ): RoutesStore<Dependencies> {
300
- const definitions: RouteDefinition[] = [];
301
- const config: RouteConfig = createEmptyConfig();
302
- const routeCustomFields: Record<
303
- string,
304
- Record<string, unknown>
305
- > = Object.create(null) as Record<string, Record<string, unknown>>;
306
- const pendingCanActivate = new Map<string, GuardFnFactory<Dependencies>>();
307
- const pendingCanDeactivate = new Map<string, GuardFnFactory<Dependencies>>();
312
+ > {
313
+ readonly definitions: RouteDefinition[];
314
+ readonly config: RouteConfig;
315
+ readonly routeCustomFields: Record<string, Record<string, unknown>>;
316
+ readonly pendingCanActivate: Map<string, GuardFnFactory<Dependencies>>;
317
+ readonly pendingCanDeactivate: Map<string, GuardFnFactory<Dependencies>>;
318
+ readonly tree: RouteTree;
319
+ readonly matcher: Matcher;
320
+ readonly resolvedForwardMap: Record<string, string>;
321
+ }
308
322
 
323
+ /** Null-proto shallow clone of a RouteConfig (preserves the 5 maps' contents). */
324
+ function cloneConfig(config: RouteConfig): RouteConfig {
325
+ const clone = createEmptyConfig();
326
+
327
+ Object.assign(clone.decoders, config.decoders);
328
+ Object.assign(clone.encoders, config.encoders);
329
+ Object.assign(clone.defaultParams, config.defaultParams);
330
+ Object.assign(clone.forwardMap, config.forwardMap);
331
+ Object.assign(clone.forwardFnMap, config.forwardFnMap);
332
+
333
+ return clone;
334
+ }
335
+
336
+ /**
337
+ * Returns a new definitions array with `added` inserted, without mutating the
338
+ * input. For a top-level add the existing definitions are shallow-copied and
339
+ * `added` appended. For a parented add the spine down to the parent is cloned
340
+ * (siblings/other branches are shared by reference) and `added` appended to the
341
+ * parent's children. Caller guarantees the parent path exists (see assertAddable).
342
+ */
343
+ function insertAddedDefinitions(
344
+ definitions: readonly RouteDefinition[],
345
+ added: RouteDefinition[],
346
+ parentSegments: readonly string[],
347
+ ): RouteDefinition[] {
348
+ if (parentSegments.length === 0) {
349
+ return [...definitions, ...added];
350
+ }
351
+
352
+ const [head, ...rest] = parentSegments;
353
+
354
+ return definitions.map((def) => {
355
+ if (def.name !== head) {
356
+ return def;
357
+ }
358
+
359
+ const children = def.children ?? [];
360
+
361
+ return {
362
+ ...def,
363
+ children:
364
+ rest.length === 0
365
+ ? [...children, ...added]
366
+ : insertAddedDefinitions(children, added, rest),
367
+ };
368
+ });
369
+ }
370
+
371
+ /** Depth-first walk yielding each route's full dotted name (no side effects). */
372
+ function walkRouteNames<Dependencies extends DefaultDependencies>(
373
+ routes: readonly Route<Dependencies>[],
374
+ parentName: string,
375
+ onName: (fullName: string) => void,
376
+ ): void {
309
377
  for (const route of routes) {
310
- definitions.push(sanitizeRoute(route));
378
+ const fullName = parentName ? `${parentName}.${route.name}` : route.name;
379
+
380
+ onName(fullName);
381
+
382
+ if (route.children) {
383
+ walkRouteNames(route.children, fullName, onName);
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Up-front guard for `add` against the two corruptions route-tree stays silent
390
+ * on: a missing `parent`, and a name that collides with an EXISTING route
391
+ * (which would otherwise be silently overwritten). Throws before any build.
392
+ */
393
+ export function assertAddable<Dependencies extends DefaultDependencies>(
394
+ store: RoutesStore<Dependencies>,
395
+ routes: readonly Route<Dependencies>[],
396
+ parentName: string | undefined,
397
+ ): void {
398
+ if (parentName !== undefined && !store.matcher.hasRoute(parentName)) {
399
+ throw new Error(
400
+ `[router.addRoute] Parent route "${parentName}" does not exist`,
401
+ );
311
402
  }
312
403
 
313
- const { tree, matcher } = rebuildTree(definitions, "", matcherOptions);
404
+ walkRouteNames(routes, parentName ?? "", (fullName) => {
405
+ if (store.matcher.hasRoute(fullName)) {
406
+ throw new Error(`[router.addRoute] Route "${fullName}" already exists`);
407
+ }
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Builds RouteArtifacts from a final definitions array and the routes whose
413
+ * handlers (config + guards) populate `config`/`routeCustomFields`. Guards are
414
+ * collected into the returned pending maps (depsStore is intentionally omitted
415
+ * so nothing compiles or touches the lifecycle here). THROWS on async/circular
416
+ * forwardTo and invalid path constraint — before the caller mutates the store.
417
+ */
418
+ function buildArtifacts<Dependencies extends DefaultDependencies>(
419
+ definitions: RouteDefinition[],
420
+ routesForHandlers: readonly Route<Dependencies>[],
421
+ config: RouteConfig,
422
+ routeCustomFields: Record<string, Record<string, unknown>>,
423
+ handlerParentName: string,
424
+ rootPath: string,
425
+ matcherOptions: CreateMatcherOptions | undefined,
426
+ ): RouteArtifacts<Dependencies> {
427
+ const pendingCanActivate = new Map<string, GuardFnFactory<Dependencies>>();
428
+ const pendingCanDeactivate = new Map<string, GuardFnFactory<Dependencies>>();
314
429
 
315
430
  registerAllRouteHandlers(
316
- routes,
431
+ routesForHandlers,
317
432
  config,
318
433
  routeCustomFields,
319
434
  pendingCanActivate,
320
435
  pendingCanDeactivate,
321
- undefined,
322
- "",
436
+ handlerParentName,
323
437
  );
324
438
 
325
439
  const resolvedForwardMap = refreshForwardMap(config);
440
+ const { tree, matcher } = rebuildTree(definitions, rootPath, matcherOptions);
326
441
 
327
442
  return {
328
443
  definitions,
329
444
  config,
445
+ routeCustomFields,
446
+ pendingCanActivate,
447
+ pendingCanDeactivate,
330
448
  tree,
331
449
  matcher,
332
450
  resolvedForwardMap,
333
- routeCustomFields,
451
+ };
452
+ }
453
+
454
+ /** Builds the merged artifacts for an incremental `add` (existing ∪ new). */
455
+ export function buildAddArtifacts<Dependencies extends DefaultDependencies>(
456
+ store: RoutesStore<Dependencies>,
457
+ routes: readonly Route<Dependencies>[],
458
+ parentName: string | undefined,
459
+ ): RouteArtifacts<Dependencies> {
460
+ const definitions = insertAddedDefinitions(
461
+ store.definitions,
462
+ routes.map((route) => sanitizeRoute(route)),
463
+ parentName === undefined ? [] : parentName.split("."),
464
+ );
465
+
466
+ return buildArtifacts(
467
+ definitions,
468
+ routes,
469
+ cloneConfig(store.config),
470
+ Object.assign(
471
+ Object.create(null) as Record<string, Record<string, unknown>>,
472
+ store.routeCustomFields,
473
+ ),
474
+ parentName ?? "",
475
+ store.rootPath,
476
+ store.matcherOptions,
477
+ );
478
+ }
479
+
480
+ /** Builds the fresh artifacts for a full `replace` (standalone new set). */
481
+ export function buildReplaceArtifacts<Dependencies extends DefaultDependencies>(
482
+ routes: readonly Route<Dependencies>[],
483
+ rootPath: string,
484
+ matcherOptions: CreateMatcherOptions | undefined,
485
+ ): RouteArtifacts<Dependencies> {
486
+ return buildArtifacts(
487
+ routes.map((route) => sanitizeRoute(route)),
488
+ routes,
489
+ createEmptyConfig(),
490
+ Object.create(null) as Record<string, Record<string, unknown>>,
491
+ "",
492
+ rootPath,
493
+ matcherOptions,
494
+ );
495
+ }
496
+
497
+ /**
498
+ * Commits prepared artifacts into the store in place. Pure assignment — never
499
+ * throws — so it is the single atomic swap point of the prepare-then-commit
500
+ * pipeline. Guard registration is deferred to here (the build collected guards
501
+ * without compiling); `depsStore` is always set on a wired router, which is the
502
+ * only path that reaches `add`/`replace`.
503
+ */
504
+ export function adoptRouteArtifacts<Dependencies extends DefaultDependencies>(
505
+ store: RoutesStore<Dependencies>,
506
+ artifacts: RouteArtifacts<Dependencies>,
507
+ ): void {
508
+ store.definitions.length = 0;
509
+
510
+ for (const def of artifacts.definitions) {
511
+ store.definitions.push(def);
512
+ }
513
+
514
+ Object.assign(store.config, artifacts.config);
515
+ store.routeCustomFields = artifacts.routeCustomFields;
516
+ store.tree = artifacts.tree;
517
+ store.matcher = artifacts.matcher;
518
+ store.resolvedForwardMap = artifacts.resolvedForwardMap;
519
+
520
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depsStore is set once the router is wired; add/replace only run on a wired router (constructor-time registration uses createRoutesStore)
521
+ const deps = store.depsStore!;
522
+
523
+ for (const [name, handler] of artifacts.pendingCanActivate) {
524
+ deps.addActivateGuard(name, handler);
525
+ }
526
+
527
+ for (const [name, handler] of artifacts.pendingCanDeactivate) {
528
+ deps.addDeactivateGuard(name, handler);
529
+ }
530
+ }
531
+
532
+ // =============================================================================
533
+ // Factory
534
+ // =============================================================================
535
+
536
+ export function createRoutesStore<
537
+ Dependencies extends DefaultDependencies = DefaultDependencies,
538
+ >(
539
+ routes: Route<Dependencies>[],
540
+ matcherOptions?: CreateMatcherOptions,
541
+ ): RoutesStore<Dependencies> {
542
+ // Initial routes are a standalone set at rootPath "" — same build the
543
+ // prepare-then-commit `replace` path uses. Guards land in the pending maps
544
+ // (depsStore is wired later via setDependencies, which flushes them).
545
+ const artifacts = buildReplaceArtifacts(routes, "", matcherOptions);
546
+
547
+ return {
548
+ definitions: artifacts.definitions,
549
+ config: artifacts.config,
550
+ tree: artifacts.tree,
551
+ matcher: artifacts.matcher,
552
+ resolvedForwardMap: artifacts.resolvedForwardMap,
553
+ routeCustomFields: artifacts.routeCustomFields,
334
554
  rootPath: "",
335
555
  matcherOptions,
336
556
  depsStore: undefined,
337
557
  lifecycleNamespace: undefined,
338
- pendingCanActivate,
339
- pendingCanDeactivate,
558
+ pendingCanActivate: artifacts.pendingCanActivate,
559
+ pendingCanDeactivate: artifacts.pendingCanDeactivate,
340
560
  treeOperations: {
341
561
  commitTreeChanges,
342
562
  resetStore,
@@ -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
  /**
@@ -1 +0,0 @@
1
- {"version":3,"file":"Router-CJihdrWA.d.ts","names":[],"sources":["../../src/types.ts","../../src/Router.ts"],"mappings":";;;;;;KAwDY,MAAA,GAAS,QAAQ,CAAC,YAAA;;;;;;;;UASb,4BAAA,WAAuC,MAAA,GAAS,MAAA;EAAA,SACtD,KAAA,EAAO,cAAA,CAAe,CAAA;EAAA,SACtB,QAAA;AAAA;;;;AAXX;;;;AAA0C;AAS1C;;;;;;;;;;cCOa,QAAA,sBACU,mBAAA,GAAsB,mBAAA,aAChC,MAAA,CAAgB,YAAA;EAAA;GAC1B,GAAA;EDTe;;;;AACC;cCoCf,MAAA,GAAQ,KAAA,CAAM,YAAA,KACd,OAAA,GAAS,OAAA,CAAQ,OAAA,GACjB,YAAA,GAAc,YAAA;EA8NhB,aAAA,CACE,IAAA,UACA,MAAA,GAAS,MAAA,EACT,cAAA,YACA,iBAAA;EAgCF,SAAA,CAAU,KAAA,UAAe,MAAA,GAAS,MAAA;EAalC,QAAA,WAAmB,MAAA,GAAS,MAAA,CAAA,CAAA,GAAW,KAAA,CAAM,CAAA;EAI7C,gBAAA,CAAA,GAAoB,KAAA;EAIpB,cAAA,CACE,MAAA,EAAQ,KAAA,cACR,MAAA,EAAQ,KAAA,cACR,iBAAA;EAWF,gBAAA,CACE,QAAA,YACE,OAAA,EAAS,KAAA,EAAO,SAAA,GAAY,KAAA;EAUhC,QAAA,CAAA;EAIA,KAAA,CAAM,SAAA,WAAoB,OAAA,CAAQ,KAAA;EAqClC,IAAA,CAAA;EAcA,OAAA,CAAA;EA8CA,aAAA,CAAc,IAAA,UAAc,MAAA,GAAS,MAAA;EA+BrC,SAAA,CAAA,GACK,OAAA,GAAU,aAAA,CAAc,YAAA,kCAC1B,WAAA;EA2BH,SAAA,CAAU,QAAA,EAAU,WAAA,GAAc,WAAA;EAMlC,cAAA,CAAe,QAAA,EAAU,OAAA,GAAU,WAAA;EAMnC,eAAA,CAAA;EAQA,QAAA,CACE,SAAA,UACA,WAAA,GAAc,MAAA,EACd,OAAA,GAAU,iBAAA,GACT,OAAA,CAAQ,KAAA;EA4BX,iBAAA,CAAkB,OAAA,GAAU,iBAAA,GAAoB,OAAA,CAAQ,KAAA;EAyBxD,kBAAA,CAAmB,IAAA,YAAgB,KAAA;AAAA"}