@real-router/core 0.46.0 → 0.48.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 (52) hide show
  1. package/dist/cjs/{Router-BtWR1xO-.js → Router-But6ZKYG.js} +3 -3
  2. package/dist/cjs/Router-But6ZKYG.js.map +1 -0
  3. package/dist/cjs/Router-Dh1xgFLI.d.ts.map +1 -1
  4. package/dist/cjs/RouterError-CWIGEyPI.js +2 -0
  5. package/dist/cjs/RouterError-CWIGEyPI.js.map +1 -0
  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/{getPluginApi-1VcDVGjf.js → getPluginApi-DMO1jdNK.js} +2 -2
  10. package/dist/cjs/getPluginApi-DMO1jdNK.js.map +1 -0
  11. package/dist/cjs/index.d.ts.map +1 -1
  12. package/dist/cjs/index.js +1 -1
  13. package/dist/cjs/internals-na15rxo_.js.map +1 -1
  14. package/dist/cjs/utils.js +1 -1
  15. package/dist/cjs/validation.d.ts +1 -0
  16. package/dist/cjs/validation.d.ts.map +1 -1
  17. package/dist/esm/Router-BPkXwb1J.d.mts.map +1 -1
  18. package/dist/esm/Router-BThyTcCs.mjs +6 -0
  19. package/dist/esm/Router-BThyTcCs.mjs.map +1 -0
  20. package/dist/esm/RouterError-2JY9OfZc.mjs +2 -0
  21. package/dist/esm/RouterError-2JY9OfZc.mjs.map +1 -0
  22. package/dist/esm/api.d.mts.map +1 -1
  23. package/dist/esm/api.mjs +1 -1
  24. package/dist/esm/api.mjs.map +1 -1
  25. package/dist/esm/{getPluginApi-BvOUPp3g.mjs → getPluginApi-Bwp0MNW9.mjs} +2 -2
  26. package/dist/esm/getPluginApi-Bwp0MNW9.mjs.map +1 -0
  27. package/dist/esm/index.d.mts.map +1 -1
  28. package/dist/esm/index.mjs +1 -1
  29. package/dist/esm/internals-CCymabFj.mjs.map +1 -1
  30. package/dist/esm/utils.mjs +1 -1
  31. package/dist/esm/validation.d.mts +1 -0
  32. package/dist/esm/validation.d.mts.map +1 -1
  33. package/package.json +9 -6
  34. package/src/Router.ts +4 -0
  35. package/src/api/getPluginApi.ts +27 -1
  36. package/src/api/getRoutesApi.ts +16 -4
  37. package/src/constants.ts +14 -0
  38. package/src/helpers.ts +14 -49
  39. package/src/internals.ts +1 -0
  40. package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +3 -2
  41. package/src/namespaces/NavigationNamespace/transition/completeTransition.ts +12 -8
  42. package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +4 -2
  43. package/src/namespaces/StateNamespace/StateNamespace.ts +19 -6
  44. package/dist/cjs/Router-BtWR1xO-.js.map +0 -1
  45. package/dist/cjs/RouterError-AQUx-VLW.js +0 -2
  46. package/dist/cjs/RouterError-AQUx-VLW.js.map +0 -1
  47. package/dist/cjs/getPluginApi-1VcDVGjf.js.map +0 -1
  48. package/dist/esm/Router-DmPynezS.mjs +0 -6
  49. package/dist/esm/Router-DmPynezS.mjs.map +0 -1
  50. package/dist/esm/RouterError-BOUkCIgf.mjs +0 -2
  51. package/dist/esm/RouterError-BOUkCIgf.mjs.map +0 -1
  52. package/dist/esm/getPluginApi-BvOUPp3g.mjs.map +0 -1
@@ -28,6 +28,7 @@ import type {
28
28
  ForwardToCallback,
29
29
  Params,
30
30
  Router,
31
+ TransitionMeta,
31
32
  } from "@real-router/types";
32
33
  import type { RouteDefinition, RouteTree } from "route-tree";
33
34
 
@@ -250,6 +251,7 @@ function replaceRoutes<
250
251
  routes: Route<Dependencies>[],
251
252
  ctx: RouterInternals<Dependencies>,
252
253
  currentPath: string | undefined,
254
+ previousTransition: TransitionMeta | undefined,
253
255
  ): void {
254
256
  // Step 2: Clear route data (WITHOUT tree rebuild)
255
257
  clearRouteData(store);
@@ -276,12 +278,16 @@ function replaceRoutes<
276
278
  // Step 5: One tree rebuild
277
279
  store.treeOperations.commitTreeChanges(store);
278
280
 
279
- // Step 6: Revalidate state
281
+ // Step 6: Revalidate state (preserve transition from previous state)
280
282
  if (currentPath !== undefined) {
281
283
  const revalidated = ctx.matchPath(currentPath, ctx.getOptions());
282
284
 
283
285
  if (revalidated) {
284
- ctx.setState(revalidated);
286
+ ctx.setState({
287
+ ...revalidated,
288
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- previousTransition is guaranteed defined: currentPath is only set when getState() returned a state, which always has transition
289
+ transition: previousTransition!,
290
+ });
285
291
  } else {
286
292
  ctx.clearState();
287
293
  }
@@ -565,9 +571,15 @@ export function getRoutesApi<
565
571
  ctx.validator?.routes.validateAddRouteArgs(routeArray);
566
572
  ctx.validator?.routes.validateRoutes(routeArray, store);
567
573
 
568
- const currentPath = router.getState()?.path;
574
+ const currentState = router.getState();
569
575
 
570
- replaceRoutes(store, routeArray, ctx, currentPath);
576
+ replaceRoutes(
577
+ store,
578
+ routeArray,
579
+ ctx,
580
+ currentState?.path,
581
+ currentState?.transition,
582
+ );
571
583
  },
572
584
  };
573
585
  }
package/src/constants.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  ErrorCodeToValueMap,
7
7
  ErrorCodeKeys,
8
8
  ErrorCodeValues,
9
+ TransitionMeta,
9
10
  } from "@real-router/types";
10
11
 
11
12
  export type ConstantsKeys = "UNKNOWN_ROUTE";
@@ -35,6 +36,7 @@ export const errorCodes: ErrorCodeToValueMap = Object.freeze({
35
36
  TRANSITION_CANCELLED: "CANCELLED", // Navigation cancelled by user or new navigation
36
37
  ROUTER_DISPOSED: "DISPOSED", // Router has been disposed
37
38
  PLUGIN_CONFLICT: "PLUGIN_CONFLICT", // Plugin tried to extend router with already-existing property
39
+ CONTEXT_NAMESPACE_ALREADY_CLAIMED: "CONTEXT_NAMESPACE_ALREADY_CLAIMED", // Plugin tried to claim a context namespace already owned by another plugin
38
40
  });
39
41
 
40
42
  /**
@@ -85,3 +87,15 @@ export const DEFAULT_LIMITS = {
85
87
  } as const;
86
88
 
87
89
  export const EMPTY_PARAMS: Readonly<Record<string, never>> = Object.freeze({});
90
+
91
+ const FROZEN_EMPTY_SEGMENTS = Object.freeze({
92
+ deactivated: Object.freeze([]) as unknown as string[],
93
+ activated: Object.freeze([]) as unknown as string[],
94
+ intersection: "",
95
+ });
96
+
97
+ export const DEFAULT_TRANSITION = Object.freeze({
98
+ phase: "activating",
99
+ reason: "success",
100
+ segments: FROZEN_EMPTY_SEGMENTS,
101
+ }) as TransitionMeta;
package/src/helpers.ts CHANGED
@@ -109,65 +109,30 @@ export function deepFreezeState<T extends State>(state: T): T {
109
109
  return clonedState;
110
110
  }
111
111
 
112
- // WeakSet to track already frozen root objects for O(1) re-freeze check
113
- const frozenRoots = new WeakSet<object>();
114
-
115
- // Module-scope recursive freeze function - better JIT optimization, no allocation per call
116
- function freezeRecursive(obj: unknown): void {
117
- // Skip primitives, null
118
- if (obj === null || typeof obj !== "object") {
119
- return;
120
- }
121
-
122
- // Skip already frozen objects (handles potential shared refs)
123
- if (Object.isFrozen(obj)) {
124
- return;
125
- }
126
-
127
- // Freeze the object/array
128
- Object.freeze(obj);
129
-
130
- // Iterate without Object.values() allocation
131
- if (Array.isArray(obj)) {
132
- for (const item of obj) {
133
- freezeRecursive(item);
134
- }
135
- } else {
136
- for (const key in obj) {
137
- freezeRecursive((obj as Record<string, unknown>)[key]);
138
- }
139
- }
140
- }
141
-
142
112
  /**
143
- * Freezes State object in-place without cloning.
144
- * Optimized for hot paths where state is known to be a fresh object.
113
+ * Shallow-freezes a State object in place.
114
+ *
115
+ * Freezes only the top-level State object (blocks reassignment of `name`,
116
+ * `params`, `path`, `transition`, `context`). Nested objects (`params`,
117
+ * `transition`, `transition.segments`, `transition.segments.{deactivated,activated}`)
118
+ * are expected to be **already frozen at creation time** by their producers:
119
+ *
120
+ * - `params` frozen in `makeState()` / `navigateToNotFound()`
121
+ * - `transition`, `segments`, `deactivated`, `activated` frozen in
122
+ * `buildTransitionMeta()` (or inline in `navigateToNotFound()`)
145
123
  *
146
- * IMPORTANT: Only use this when you know the state is a fresh object
147
- * that hasn't been exposed to external code yet (e.g., from makeState()).
124
+ * `state.context` is **intentionally not frozen** plugins write to it via
125
+ * `claim.write(state, value)` after state creation.
148
126
  *
149
- * @param state - The State object to freeze (must be a fresh object)
150
- * @returns The same state object, now frozen
151
127
  * @internal
152
128
  */
153
129
  export function freezeStateInPlace<T extends State>(state: T): T {
154
- // Early return for null/undefined - state from makeState() is never null
155
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
130
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive guard against external misuse
156
131
  if (!state) {
157
132
  return state;
158
133
  }
159
134
 
160
- // Fast path: already processed root object - O(1) check
161
- if (frozenRoots.has(state)) {
162
- return state;
163
- }
164
-
165
- freezeRecursive(state);
166
-
167
- // Mark root as processed for future calls
168
- frozenRoots.add(state);
169
-
170
- return state;
135
+ return Object.freeze(state);
171
136
  }
172
137
 
173
138
  /**
package/src/internals.ts CHANGED
@@ -90,6 +90,7 @@ export interface RouterInternals<
90
90
  readonly clearState: () => void;
91
91
  readonly setState: (state: State) => void;
92
92
  readonly routerExtensions: { keys: string[] }[];
93
+ readonly contextClaimRecords: Set<string>;
93
94
  }
94
95
 
95
96
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- existential type: stores RouterInternals for all Dependencies types
@@ -10,7 +10,7 @@ import {
10
10
  import { completeTransition } from "./transition/completeTransition";
11
11
  import { routeTransitionError } from "./transition/errorHandling";
12
12
  import { executeGuardPipeline } from "./transition/guardPhase";
13
- import { errorCodes, constants } from "../../constants";
13
+ import { EMPTY_PARAMS, errorCodes, constants } from "../../constants";
14
14
  import { RouterError } from "../../RouterError";
15
15
  import { getTransitionPath, nameToIDs } from "../../transitionPath";
16
16
 
@@ -310,9 +310,10 @@ export class NavigationNamespace {
310
310
 
311
311
  const state: State = {
312
312
  name: constants.UNKNOWN_ROUTE,
313
- params: {} as Params,
313
+ params: EMPTY_PARAMS as Params,
314
314
  path,
315
315
  transition: transitionMeta,
316
+ context: {},
316
317
  };
317
318
 
318
319
  Object.freeze(state);
@@ -1,5 +1,4 @@
1
1
  import { errorCodes, constants } from "../../../constants";
2
- import { freezeStateInPlace } from "../../../helpers";
3
2
  import { RouterError } from "../../../RouterError";
4
3
 
5
4
  import type { NavigationDependencies, NavigationContext } from "../types";
@@ -20,14 +19,19 @@ function buildTransitionMeta(
20
19
  toActivate: string[],
21
20
  intersection: string,
22
21
  ): TransitionMeta {
22
+ Object.freeze(toDeactivate);
23
+ Object.freeze(toActivate);
24
+
25
+ const segments = Object.freeze({
26
+ deactivated: toDeactivate,
27
+ activated: toActivate,
28
+ intersection,
29
+ });
30
+
23
31
  const meta: MutableTransitionMeta = {
24
32
  phase: "activating",
25
33
  reason: "success",
26
- segments: {
27
- deactivated: toDeactivate,
28
- activated: toActivate,
29
- intersection,
30
- },
34
+ segments,
31
35
  };
32
36
 
33
37
  if (fromState?.name !== undefined) {
@@ -42,7 +46,7 @@ function buildTransitionMeta(
42
46
  meta.redirected = opts.redirected;
43
47
  }
44
48
 
45
- return meta;
49
+ return Object.freeze(meta);
46
50
  }
47
51
 
48
52
  function stripSignal({
@@ -88,7 +92,7 @@ export function completeTransition(
88
92
  intersection,
89
93
  );
90
94
 
91
- const finalState = freezeStateInPlace(toState);
95
+ const finalState = Object.freeze(toState);
92
96
 
93
97
  deps.setState(finalState);
94
98
 
@@ -7,7 +7,7 @@ import {
7
7
  rebuildTreeInPlace,
8
8
  resetStore,
9
9
  } from "./routesStore";
10
- import { constants } from "../../constants";
10
+ import { constants, DEFAULT_TRANSITION } from "../../constants";
11
11
  import { getTransitionPath } from "../../transitionPath";
12
12
 
13
13
  import type { RoutesStore } from "./routesStore";
@@ -107,7 +107,7 @@ export class RoutesNamespace<
107
107
  );
108
108
  }
109
109
 
110
- if (toState.transition?.reload) {
110
+ if (toState.transition.reload) {
111
111
  return true;
112
112
  }
113
113
 
@@ -403,6 +403,8 @@ export class RoutesNamespace<
403
403
  name,
404
404
  params: effectiveParams,
405
405
  path: "",
406
+ transition: DEFAULT_TRANSITION,
407
+ context: {},
406
408
  };
407
409
 
408
410
  return this.#deps.areStatesEqual(
@@ -1,7 +1,7 @@
1
1
  // packages/core/src/namespaces/StateNamespace/StateNamespace.ts
2
2
 
3
3
  import { areParamValuesEqual } from "./helpers";
4
- import { EMPTY_PARAMS } from "../../constants";
4
+ import { DEFAULT_TRANSITION, EMPTY_PARAMS } from "../../constants";
5
5
  import { freezeStateInPlace } from "../../helpers";
6
6
  import { setStateMetaParams } from "../../stateMetaStore";
7
7
 
@@ -97,7 +97,15 @@ export class StateNamespace {
97
97
  // =========================================================================
98
98
 
99
99
  /**
100
- * Creates a frozen state object for a route.
100
+ * Creates a state object for a route.
101
+ *
102
+ * `params` is frozen at creation so it is always immutable, even when
103
+ * `skipFreeze=true` is passed to defer the outer `Object.freeze(state)` call.
104
+ * This keeps params-freezing invariants independent of transition-pipeline
105
+ * mutation (e.g. `completeTransition` attaching `state.transition`).
106
+ *
107
+ * `context` is initialized as a fresh empty object — intentionally NOT frozen
108
+ * so plugins can publish data via `claim.write(state, value)` after creation.
101
109
  */
102
110
  makeState<P extends Params = Params>(
103
111
  name: string,
@@ -114,18 +122,23 @@ export class StateNamespace {
114
122
  let mergedParams: P;
115
123
 
116
124
  if (hasDefaultParams) {
117
- mergedParams = { ...defaultParamsConfig[name], ...params } as P;
125
+ mergedParams = Object.freeze({
126
+ ...defaultParamsConfig[name],
127
+ ...params,
128
+ }) as P;
118
129
  } else if (!params || params === EMPTY_PARAMS) {
119
130
  mergedParams = EMPTY_PARAMS as P;
120
131
  } else {
121
- mergedParams = { ...params };
132
+ mergedParams = Object.freeze({ ...params }) as P;
122
133
  }
123
134
 
124
- const state: State<P> = {
135
+ const state = {
125
136
  name,
126
137
  params: mergedParams,
127
138
  path: path ?? this.#deps.buildPath(name, params),
128
- };
139
+ context: {},
140
+ ...(!skipFreeze && { transition: DEFAULT_TRANSITION }),
141
+ } as State<P>;
129
142
 
130
143
  if (meta) {
131
144
  setStateMetaParams(state, meta as unknown as Params);