@real-router/core 0.54.5 → 0.55.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 (58) hide show
  1. package/dist/cjs/Router-C7eE1kIK.js +6 -0
  2. package/dist/cjs/Router-C7eE1kIK.js.map +1 -0
  3. package/dist/cjs/{Router-CJihdrWA.d.ts → Router-Dg-zk8AS.d.ts} +1 -1
  4. package/dist/cjs/{Router-CJihdrWA.d.ts.map → Router-Dg-zk8AS.d.ts.map} +1 -1
  5. package/dist/cjs/{RouterError-Bm9YnZ6e.d.ts → RouterError-WhCzIWuc.d.ts} +1 -1
  6. package/dist/cjs/{RouterError-Bm9YnZ6e.d.ts.map → RouterError-WhCzIWuc.d.ts.map} +1 -1
  7. package/dist/cjs/api.d.ts +2 -2
  8. package/dist/cjs/api.d.ts.map +1 -1
  9. package/dist/cjs/api.js +1 -1
  10. package/dist/cjs/api.js.map +1 -1
  11. package/dist/cjs/{cloneRouter-D5bMudso.js → cloneRouter-C9Rth_8U.js} +2 -2
  12. package/dist/cjs/{cloneRouter-D5bMudso.js.map → cloneRouter-C9Rth_8U.js.map} +1 -1
  13. package/dist/cjs/{index-EwbhzRQw.d.ts → index-C-i6vx5Y.d.ts} +1 -1
  14. package/dist/cjs/{index-EwbhzRQw.d.ts.map → index-C-i6vx5Y.d.ts.map} +1 -1
  15. package/dist/cjs/{index-8oPDJBQc.d.ts → index-K1U_fqfJ.d.ts} +2 -2
  16. package/dist/cjs/{index-8oPDJBQc.d.ts.map → index-K1U_fqfJ.d.ts.map} +1 -1
  17. package/dist/cjs/index.d.ts +3 -3
  18. package/dist/cjs/index.js +1 -1
  19. package/dist/cjs/{internals-CM6oaz9n.js → internals-CWMOL1B8.js} +2 -2
  20. package/dist/cjs/{internals-CM6oaz9n.js.map → internals-CWMOL1B8.js.map} +1 -1
  21. package/dist/cjs/utils.d.ts +1 -1
  22. package/dist/cjs/utils.js +1 -1
  23. package/dist/cjs/validation.d.ts +4 -4
  24. package/dist/cjs/validation.js +1 -1
  25. package/dist/esm/{Router-BmhiDQUJ.d.mts → Router-Dg-zk8AS.d.mts} +1 -1
  26. package/dist/esm/{Router-BmhiDQUJ.d.mts.map → Router-Dg-zk8AS.d.mts.map} +1 -1
  27. package/dist/esm/Router-DiZbYMLx.mjs +6 -0
  28. package/dist/esm/Router-DiZbYMLx.mjs.map +1 -0
  29. package/dist/esm/{RouterError-hhfSVGtY.d.mts → RouterError-WhCzIWuc.d.mts} +1 -1
  30. package/dist/esm/{RouterError-hhfSVGtY.d.mts.map → RouterError-WhCzIWuc.d.mts.map} +1 -1
  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-BYNiwchg.mjs +2 -0
  36. package/dist/esm/{cloneRouter-DM59kihh.mjs.map → cloneRouter-BYNiwchg.mjs.map} +1 -1
  37. package/dist/esm/{index-DNjaY7KH.d.mts → index-C-i6vx5Y.d.mts} +1 -1
  38. package/dist/esm/{index-DNjaY7KH.d.mts.map → index-C-i6vx5Y.d.mts.map} +1 -1
  39. package/dist/esm/{index-r_JTvSBH.d.mts → index-DKzxav48.d.mts} +2 -2
  40. package/dist/esm/{index-r_JTvSBH.d.mts.map → index-DKzxav48.d.mts.map} +1 -1
  41. package/dist/esm/index.d.mts +3 -3
  42. package/dist/esm/index.mjs +1 -1
  43. package/dist/esm/{internals-C59msvHY.mjs → internals-DT4mneSz.mjs} +1 -1
  44. package/dist/esm/{internals-C59msvHY.mjs.map → internals-DT4mneSz.mjs.map} +1 -1
  45. package/dist/esm/utils.d.mts +1 -1
  46. package/dist/esm/utils.mjs +1 -1
  47. package/dist/esm/validation.d.mts +4 -4
  48. package/dist/esm/validation.mjs +1 -1
  49. package/package.json +3 -3
  50. package/src/api/getRoutesApi.ts +47 -88
  51. package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +68 -3
  52. package/src/namespaces/NavigationNamespace/types.ts +1 -1
  53. package/src/namespaces/RoutesNamespace/routesStore.ts +272 -52
  54. package/dist/cjs/Router-BzV5P1lA.js +0 -6
  55. package/dist/cjs/Router-BzV5P1lA.js.map +0 -1
  56. package/dist/esm/Router-_BDnheMY.mjs +0 -6
  57. package/dist/esm/Router-_BDnheMY.mjs.map +0 -1
  58. package/dist/esm/cloneRouter-DM59kihh.mjs +0 -2
@@ -6,16 +6,17 @@ import { createInterceptable, 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";
@@ -36,32 +37,6 @@ import type { RouteDefinition, RouteTree } from "route-tree";
36
37
  // Helpers
37
38
  // ============================================================================
38
39
 
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
40
  /**
66
41
  * Clears all config entries and lifecycle handlers for a removed route
67
42
  * (and all its descendants).
@@ -116,20 +91,36 @@ function updateForwardTo<
116
91
  name: string,
117
92
  forwardTo: string | ForwardToCallback<Dependencies> | null,
118
93
  config: RouteConfig,
119
- refreshForwardMapFn: (config: RouteConfig) => Record<string, string>,
120
94
  ): Record<string, string> {
95
+ // Prepare-then-commit (issue #698): apply the change to CLONES of the forward
96
+ // maps, resolve the chain (a cycle throws here), and only then swap the clones
97
+ // in — so a rejected update never leaves config.forwardMap poisoned.
98
+ const forwardMap = Object.assign(
99
+ Object.create(null) as RouteConfig["forwardMap"],
100
+ config.forwardMap,
101
+ );
102
+ const forwardFnMap = Object.assign(
103
+ Object.create(null) as RouteConfig["forwardFnMap"],
104
+ config.forwardFnMap,
105
+ );
106
+
121
107
  if (forwardTo === null) {
122
- delete config.forwardMap[name];
123
- delete config.forwardFnMap[name];
108
+ delete forwardMap[name];
109
+ delete forwardFnMap[name];
124
110
  } else if (typeof forwardTo === "string") {
125
- delete config.forwardFnMap[name];
126
- config.forwardMap[name] = forwardTo;
111
+ delete forwardFnMap[name];
112
+ forwardMap[name] = forwardTo;
127
113
  } else {
128
- delete config.forwardMap[name];
129
- config.forwardFnMap[name] = forwardTo;
114
+ delete forwardMap[name];
115
+ forwardFnMap[name] = forwardTo;
130
116
  }
131
117
 
132
- return refreshForwardMapFn(config);
118
+ const resolved = refreshForwardMap({ ...config, forwardMap });
119
+
120
+ config.forwardMap = forwardMap;
121
+ config.forwardFnMap = forwardFnMap;
122
+
123
+ return resolved;
133
124
  }
134
125
 
135
126
  /**
@@ -212,37 +203,19 @@ function addRoutes<
212
203
  routes: Route<Dependencies>[],
213
204
  parentName?: string,
214
205
  ): void {
215
- if (parentName) {
216
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
217
- const parentDef = findDefinition(store.definitions, parentName)!;
218
-
219
- parentDef.children ??= [];
220
-
221
- for (const route of routes) {
222
- parentDef.children.push(sanitizeRoute(route));
223
- }
224
- } else {
225
- for (const route of routes) {
226
- store.definitions.push(sanitizeRoute(route));
227
- }
228
- }
229
-
230
- registerAllRouteHandlers(
231
- routes,
232
- store.config,
233
- store.routeCustomFields,
234
- store.pendingCanActivate,
235
- store.pendingCanDeactivate,
236
- store.depsStore,
237
- parentName ?? "",
238
- );
239
-
240
- store.treeOperations.commitTreeChanges(store);
206
+ // Prepare-then-commit (issue #698): reject the silent-corruption cases
207
+ // up front (dup name vs existing, missing parent), build the merged tree /
208
+ // config into locals (async/circular forwardTo + invalid constraint throw
209
+ // here), then swap atomically. A rejected add leaves the store untouched.
210
+ assertAddable(store, routes, parentName);
211
+ adoptRouteArtifacts(store, buildAddArtifacts(store, routes, parentName));
241
212
  }
242
213
 
243
214
  /**
244
- * Atomically replaces all routes with a new set.
245
- * Follows RFC 6-step semantics for HMR support.
215
+ * Atomically replaces all routes with a new set (HMR / code-splitting).
216
+ * Prepare-then-commit (issue #698): the new set is fully built into locals
217
+ * first — a circular/async forwardTo or invalid path throws here, leaving the
218
+ * existing tree intact — then committed.
246
219
  */
247
220
  function replaceRoutes<
248
221
  Dependencies extends DefaultDependencies = DefaultDependencies,
@@ -253,32 +226,19 @@ function replaceRoutes<
253
226
  currentPath: string | undefined,
254
227
  previousTransition: TransitionMeta | undefined,
255
228
  ): void {
256
- // Step 2: Clear route data (WITHOUT tree rebuild)
257
- clearRouteData(store);
258
-
259
- // Step 3: Clear definition lifecycle handlers (preserve external guards)
260
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed set after wiring
261
- store.lifecycleNamespace!.clearDefinitionGuards();
262
-
263
- // Step 4: Register new routes
264
- for (const route of routes) {
265
- store.definitions.push(sanitizeRoute(route));
266
- }
267
-
268
- registerAllRouteHandlers(
229
+ // Build the whole new set BEFORE touching the store.
230
+ const artifacts = buildReplaceArtifacts(
269
231
  routes,
270
- store.config,
271
- store.routeCustomFields,
272
- store.pendingCanActivate,
273
- store.pendingCanDeactivate,
274
- store.depsStore,
275
- "",
232
+ store.rootPath,
233
+ store.matcherOptions,
276
234
  );
277
235
 
278
- // Step 5: One tree rebuild
279
- store.treeOperations.commitTreeChanges(store);
236
+ // Clear definition lifecycle handlers (preserve external guards), then swap.
237
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guaranteed set after wiring
238
+ store.lifecycleNamespace!.clearDefinitionGuards();
239
+ adoptRouteArtifacts(store, artifacts);
280
240
 
281
- // Step 6: Revalidate state (preserve transition from previous state)
241
+ // Revalidate state (preserve transition from previous state)
282
242
  if (currentPath !== undefined) {
283
243
  const revalidated = ctx.matchPath(currentPath, ctx.getOptions());
284
244
 
@@ -341,7 +301,6 @@ function updateRouteConfig<
341
301
  name,
342
302
  updates.forwardTo,
343
303
  store.config,
344
- (config) => refreshForwardMap(config),
345
304
  );
346
305
  }
347
306
 
@@ -264,6 +264,19 @@ export class EventBusNamespace {
264
264
  return this.#currentToState;
265
265
  }
266
266
 
267
+ /**
268
+ * Plugin-author API for subscribing to internal router events.
269
+ *
270
+ * @remarks
271
+ *
272
+ * **Duplicate-registration semantics — strict (throws).** Passing the same
273
+ * callback reference twice for the same event throws
274
+ * `Error("Duplicate listener for ...")` from the underlying `EventEmitter`.
275
+ * This is loud-on-misuse by design: plugin code is expected to register
276
+ * each callback once. The contract differs from {@link subscribe} /
277
+ * {@link subscribeLeave}, which are end-user surfaces and silently accept
278
+ * duplicates.
279
+ */
267
280
  addEventListener<E extends EventName>(
268
281
  eventName: E,
269
282
  cb: Plugin[EventMethodMap[E]],
@@ -274,6 +287,22 @@ export class EventBusNamespace {
274
287
  );
275
288
  }
276
289
 
290
+ /**
291
+ * End-user / UI-binding API for subscribing to successful transitions.
292
+ *
293
+ * @remarks
294
+ *
295
+ * **Duplicate-registration semantics — independent.** Each call wraps
296
+ * `listener` in a fresh closure and registers it as a distinct internal
297
+ * slot. `router.subscribe(fn)` twice produces **two** active subscriptions;
298
+ * `fn` fires twice per `TRANSITION_SUCCESS`. The returned `Unsubscribe` is
299
+ * paired with its specific call — invoking it removes exactly that
300
+ * registration.
301
+ *
302
+ * This contract differs from {@link addEventListener} (plugin API, throws
303
+ * on duplicate). End-user code that wants idempotent registration must
304
+ * gate itself, e.g. `if (!unsub) unsub = router.subscribe(fn);`.
305
+ */
277
306
  subscribe(listener: SubscribeFn): Unsubscribe {
278
307
  return this.#emitter.on(
279
308
  events.TRANSITION_SUCCESS,
@@ -283,6 +312,31 @@ export class EventBusNamespace {
283
312
  );
284
313
  }
285
314
 
315
+ /**
316
+ * End-user / UI-binding API for subscribing to confirmed route departures
317
+ * (`LEAVE_APPROVED` phase). Async listeners block the activation phase.
318
+ *
319
+ * @remarks
320
+ *
321
+ * **Duplicate-registration semantics — independent (with internal quirk).**
322
+ * Each call pushes `listener` onto the internal array; `router.subscribeLeave(fn)`
323
+ * twice produces two array entries. `fn` fires **once** per leave when
324
+ * iteration snapshots the array (a snapshot is taken on entry to
325
+ * `awaitLeaveListeners`), but the function reference is invoked once per
326
+ * array slot — so in practice the wrapper fires twice through the same
327
+ * closure (no observable difference for stateless `fn`).
328
+ *
329
+ * The returned `Unsubscribe` removes the **first** array entry matching the
330
+ * function reference (`indexOf` semantic), not the most recently added one.
331
+ * Net effect of N subscribes + M unsubscribes is correct (N - M entries
332
+ * remain), but the specific physical entry that survives is reverse of the
333
+ * unsubscribe-call order. Irrelevant in practice — the function reference
334
+ * is the same; observable behaviour is identical regardless of which
335
+ * physical entry is removed.
336
+ *
337
+ * Contract differs from {@link addEventListener} (throws on duplicate).
338
+ * For idempotent registration, gate at the call site.
339
+ */
286
340
  subscribeLeave(listener: LeaveFn): Unsubscribe {
287
341
  this.#leaveListeners.push(listener);
288
342
 
@@ -308,16 +362,27 @@ export class EventBusNamespace {
308
362
  return undefined;
309
363
  }
310
364
 
311
- const leaveState: LeaveState = {
365
+ // Freeze the payload wrapper so listeners cannot mutate it (`payload.route`
366
+ // is already deep-frozen via the State immutability invariant; this closes
367
+ // the wrapper-mutation gap surfaced by audit `probe-05-payload-frozen`).
368
+ const leaveState: LeaveState = Object.freeze({
312
369
  route: fromState,
313
370
  nextRoute: toState,
314
371
  signal,
315
- };
372
+ });
316
373
 
317
374
  let promises: Promise<void>[] | undefined;
318
375
  let firstSyncError: unknown;
319
376
 
320
- for (const listener of this.#leaveListeners) {
377
+ // Snapshot before iteration — a listener that reentrantly calls
378
+ // `subscribeLeave(newFn)` or its own `unsubscribe()` must not affect the
379
+ // current emit cycle. Symmetric with the EventEmitter snapshot invariant
380
+ // (PR #666 / #659). Use `Array.from` rather than `[...array]` to keep the
381
+ // intent explicit (some lint rules treat spread-of-own-array as
382
+ // redundant and silently revert it).
383
+ const snapshot = [...this.#leaveListeners];
384
+
385
+ for (const listener of snapshot) {
321
386
  try {
322
387
  const result = listener(leaveState);
323
388
 
@@ -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;