@real-router/core 0.34.1 → 0.35.1

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 (33) hide show
  1. package/README.md +50 -250
  2. package/dist/cjs/index.d.ts +3 -1
  3. package/dist/cjs/index.js +1 -1
  4. package/dist/cjs/index.js.map +1 -1
  5. package/dist/cjs/metafile-cjs.json +1 -1
  6. package/dist/esm/index.d.mts +3 -1
  7. package/dist/esm/index.mjs +1 -1
  8. package/dist/esm/index.mjs.map +1 -1
  9. package/dist/esm/metafile-esm.json +1 -1
  10. package/package.json +5 -5
  11. package/src/Router.ts +21 -27
  12. package/src/api/getDependenciesApi.ts +2 -9
  13. package/src/api/getLifecycleApi.ts +1 -8
  14. package/src/api/getPluginApi.ts +1 -6
  15. package/src/api/getRoutesApi.ts +3 -11
  16. package/src/api/helpers.ts +10 -0
  17. package/src/constants.ts +3 -1
  18. package/src/index.ts +2 -1
  19. package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +14 -20
  20. package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +129 -144
  21. package/src/namespaces/NavigationNamespace/index.ts +1 -1
  22. package/src/namespaces/NavigationNamespace/transition/index.ts +5 -2
  23. package/src/namespaces/NavigationNamespace/types.ts +26 -28
  24. package/src/namespaces/PluginsNamespace/PluginsNamespace.ts +1 -9
  25. package/src/namespaces/PluginsNamespace/types.ts +2 -12
  26. package/src/namespaces/RouteLifecycleNamespace/RouteLifecycleNamespace.ts +32 -58
  27. package/src/namespaces/RouteLifecycleNamespace/types.ts +3 -10
  28. package/src/namespaces/RouterLifecycleNamespace/RouterLifecycleNamespace.ts +8 -51
  29. package/src/namespaces/RouterLifecycleNamespace/types.ts +12 -2
  30. package/src/namespaces/StateNamespace/StateNamespace.ts +0 -15
  31. package/src/transitionPath.ts +1 -1
  32. package/src/wiring/RouterWiringBuilder.ts +54 -39
  33. package/src/wiring/wireRouter.ts +2 -3
@@ -10,13 +10,9 @@ import {
10
10
  } from "./validators";
11
11
  import { errorCodes, constants } from "../../constants";
12
12
  import { RouterError } from "../../RouterError";
13
- import { resolveOption } from "../OptionsNamespace";
13
+ import { nameToIDs } from "../../transitionPath";
14
14
 
15
- import type {
16
- NavigationDependencies,
17
- TransitionDependencies,
18
- TransitionOutput,
19
- } from "./types";
15
+ import type { NavigationDependencies, TransitionOutput } from "./types";
20
16
  import type {
21
17
  NavigationOptions,
22
18
  Params,
@@ -24,20 +20,80 @@ import type {
24
20
  TransitionMeta,
25
21
  } from "@real-router/types";
26
22
 
23
+ const FROZEN_ACTIVATED: string[] = [constants.UNKNOWN_ROUTE];
24
+
25
+ Object.freeze(FROZEN_ACTIVATED);
26
+ const FROZEN_REPLACE_OPTS: NavigationOptions = { replace: true };
27
+
28
+ Object.freeze(FROZEN_REPLACE_OPTS);
29
+
30
+ function forceReplaceFromUnknown(
31
+ opts: NavigationOptions,
32
+ fromState: State | undefined,
33
+ ): NavigationOptions {
34
+ return fromState?.name === constants.UNKNOWN_ROUTE && !opts.replace
35
+ ? { ...opts, replace: true }
36
+ : opts;
37
+ }
38
+
39
+ function stripSignal({
40
+ signal: _,
41
+ ...rest
42
+ }: NavigationOptions): NavigationOptions {
43
+ return rest;
44
+ }
45
+
46
+ function routeTransitionError(
47
+ deps: NavigationDependencies,
48
+ error: unknown,
49
+ toState: State,
50
+ fromState: State | undefined,
51
+ ): void {
52
+ const routerError = error as RouterError;
53
+
54
+ if (
55
+ routerError.code === errorCodes.TRANSITION_CANCELLED ||
56
+ routerError.code === errorCodes.ROUTE_NOT_FOUND
57
+ ) {
58
+ return;
59
+ }
60
+
61
+ deps.sendTransitionFail(toState, fromState, routerError);
62
+ }
63
+
64
+ function buildSuccessState(
65
+ finalState: State,
66
+ transitionOutput: TransitionOutput["meta"],
67
+ fromState: State | undefined,
68
+ opts: NavigationOptions,
69
+ ): State {
70
+ const transitionMeta: TransitionMeta = {
71
+ phase: transitionOutput.phase,
72
+ ...(fromState?.name !== undefined && { from: fromState.name }),
73
+ reason: "success",
74
+ segments: transitionOutput.segments,
75
+ ...(opts.reload !== undefined && { reload: opts.reload }),
76
+ ...(opts.redirected !== undefined && { redirected: opts.redirected }),
77
+ };
78
+
79
+ Object.freeze(transitionMeta.segments.deactivated);
80
+ Object.freeze(transitionMeta.segments.activated);
81
+ Object.freeze(transitionMeta.segments);
82
+ Object.freeze(transitionMeta);
83
+
84
+ return {
85
+ ...finalState,
86
+ transition: transitionMeta,
87
+ };
88
+ }
89
+
27
90
  /**
28
91
  * Independent namespace for managing navigation.
29
92
  *
30
- * Handles navigate(), navigateToDefault(), navigateToState(), and transition state.
93
+ * Handles navigate(), navigateToDefault(), navigateToNotFound(), and transition state.
31
94
  */
32
95
  export class NavigationNamespace {
33
- // ═══════════════════════════════════════════════════════════════════════════
34
- // Functional reference for cyclic dependency
35
- // ═══════════════════════════════════════════════════════════════════════════
36
-
37
- // Dependencies injected via setDependencies (replaces full router reference)
38
- #canNavigate!: () => boolean;
39
96
  #deps!: NavigationDependencies;
40
- #transitionDeps!: TransitionDependencies;
41
97
  #currentController: AbortController | null = null;
42
98
 
43
99
  // =========================================================================
@@ -64,30 +120,10 @@ export class NavigationNamespace {
64
120
  // Dependency injection
65
121
  // =========================================================================
66
122
 
67
- /**
68
- * Sets the canNavigate check (cyclic dependency on EventBusNamespace).
69
- * Must be called before using navigate().
70
- */
71
- setCanNavigate(fn: () => boolean): void {
72
- this.#canNavigate = fn;
73
- }
74
-
75
- /**
76
- * Sets dependencies for navigation operations.
77
- * Must be called before using navigation methods.
78
- */
79
123
  setDependencies(deps: NavigationDependencies): void {
80
124
  this.#deps = deps;
81
125
  }
82
126
 
83
- /**
84
- * Sets dependencies for transition operations.
85
- * Must be called before using navigation methods.
86
- */
87
- setTransitionDependencies(deps: TransitionDependencies): void {
88
- this.#transitionDeps = deps;
89
- }
90
-
91
127
  // =========================================================================
92
128
  // Instance methods
93
129
  // =========================================================================
@@ -101,7 +137,7 @@ export class NavigationNamespace {
101
137
  params: Params,
102
138
  opts: NavigationOptions,
103
139
  ): Promise<State> {
104
- if (!this.#canNavigate()) {
140
+ if (!this.#deps.canNavigate()) {
105
141
  throw new RouterError(errorCodes.ROUTER_NOT_STARTED);
106
142
  }
107
143
 
@@ -130,7 +166,10 @@ export class NavigationNamespace {
130
166
 
131
167
  const fromState = deps.getState();
132
168
 
169
+ opts = forceReplaceFromUnknown(opts, fromState);
170
+
133
171
  if (
172
+ fromState &&
134
173
  !opts.reload &&
135
174
  !opts.force &&
136
175
  deps.areStatesEqual(fromState, toState, false)
@@ -142,32 +181,7 @@ export class NavigationNamespace {
142
181
  throw err;
143
182
  }
144
183
 
145
- return this.navigateToState(toState, fromState, opts);
146
- }
147
-
148
- /**
149
- * Internal navigation function that accepts pre-built state.
150
- * Used by RouterLifecycleNamespace for start() transitions.
151
- */
152
- async navigateToState(
153
- toState: State,
154
- fromState: State | undefined,
155
- opts: NavigationOptions,
156
- ): Promise<State> {
157
- const deps = this.#deps;
158
- const transitionDeps = this.#transitionDeps;
159
-
160
- if (transitionDeps.isTransitioning()) {
161
- logger.warn(
162
- "router.navigate",
163
- "Concurrent navigation detected on shared router instance. " +
164
- "For SSR, use cloneRouter() to create isolated instance per request.",
165
- );
166
- this.#currentController?.abort(
167
- new RouterError(errorCodes.TRANSITION_CANCELLED),
168
- );
169
- deps.cancelNavigation();
170
- }
184
+ this.#abortPreviousNavigation();
171
185
 
172
186
  const controller = new AbortController();
173
187
 
@@ -195,7 +209,7 @@ export class NavigationNamespace {
195
209
 
196
210
  try {
197
211
  const { state: finalState, meta: transitionOutput } = await transition(
198
- transitionDeps,
212
+ deps,
199
213
  toState,
200
214
  fromState,
201
215
  opts,
@@ -206,7 +220,7 @@ export class NavigationNamespace {
206
220
  finalState.name === constants.UNKNOWN_ROUTE ||
207
221
  deps.hasRoute(finalState.name)
208
222
  ) {
209
- const stateWithTransition = NavigationNamespace.#buildSuccessState(
223
+ const stateWithTransition = buildSuccessState(
210
224
  finalState,
211
225
  transitionOutput,
212
226
  fromState,
@@ -216,9 +230,7 @@ export class NavigationNamespace {
216
230
  deps.setState(stateWithTransition);
217
231
 
218
232
  const transitionOpts =
219
- opts.signal === undefined
220
- ? opts
221
- : NavigationNamespace.#stripSignal(opts);
233
+ opts.signal === undefined ? opts : stripSignal(opts);
222
234
 
223
235
  deps.sendTransitionDone(stateWithTransition, fromState, transitionOpts);
224
236
 
@@ -228,16 +240,16 @@ export class NavigationNamespace {
228
240
  routeName: finalState.name,
229
241
  });
230
242
 
231
- deps.sendTransitionError(finalState, fromState, err);
243
+ deps.sendTransitionFail(finalState, fromState, err);
232
244
 
233
245
  throw err;
234
246
  }
235
247
  } catch (error) {
236
- this.#routeTransitionError(error, toState, fromState);
248
+ routeTransitionError(deps, error, toState, fromState);
237
249
 
238
250
  throw error;
239
251
  } finally {
240
- controller.abort(); // Cleanup: removes listener on external signal
252
+ controller.abort();
241
253
  if (this.#currentController === controller) {
242
254
  this.#currentController = null;
243
255
  }
@@ -258,104 +270,77 @@ export class NavigationNamespace {
258
270
  });
259
271
  }
260
272
 
261
- const resolvedRoute = resolveOption(
262
- options.defaultRoute,
263
- deps.getDependency,
264
- );
273
+ const { route, params } = deps.resolveDefault();
265
274
 
266
- if (!resolvedRoute) {
275
+ if (!route) {
267
276
  throw new RouterError(errorCodes.ROUTE_NOT_FOUND, {
268
277
  routeName: "defaultRoute resolved to empty",
269
278
  });
270
279
  }
271
280
 
272
- const resolvedParams = resolveOption(
273
- options.defaultParams,
274
- deps.getDependency,
275
- );
276
-
277
- return this.navigate(resolvedRoute, resolvedParams, opts);
281
+ return this.navigate(route, params, opts);
278
282
  }
279
283
 
280
- /**
281
- * Aborts the current in-flight navigation, if any.
282
- */
283
- abortCurrentNavigation(): void {
284
- this.#currentController?.abort(
285
- new RouterError(errorCodes.TRANSITION_CANCELLED),
286
- );
287
- this.#currentController = null;
288
- }
284
+ navigateToNotFound(path: string): State {
285
+ this.#abortPreviousNavigation();
289
286
 
290
- // =========================================================================
291
- // Private methods
292
- // =========================================================================
287
+ const fromState = this.#deps.getState();
288
+ const deactivated: string[] = fromState
289
+ ? nameToIDs(fromState.name).toReversed()
290
+ : [];
293
291
 
294
- /**
295
- * Strips the non-serializable `signal` field from NavigationOptions.
296
- */
297
- static #stripSignal(opts: NavigationOptions): NavigationOptions {
298
- // eslint-disable-next-line sonarjs/no-unused-vars
299
- const { signal: _, ...rest } = opts;
292
+ Object.freeze(deactivated);
300
293
 
301
- return rest;
302
- }
294
+ const segments: TransitionMeta["segments"] = {
295
+ deactivated,
296
+ activated: FROZEN_ACTIVATED,
297
+ intersection: "",
298
+ };
299
+
300
+ Object.freeze(segments);
303
301
 
304
- /**
305
- * Builds the final state with frozen TransitionMeta attached.
306
- */
307
- static #buildSuccessState(
308
- finalState: State,
309
- transitionOutput: TransitionOutput["meta"],
310
- fromState: State | undefined,
311
- opts: NavigationOptions,
312
- ): State {
313
302
  const transitionMeta: TransitionMeta = {
314
- phase: transitionOutput.phase,
315
- ...(fromState?.name !== undefined && { from: fromState.name }),
303
+ phase: "activating",
304
+ ...(fromState && { from: fromState.name }),
316
305
  reason: "success",
317
- segments: transitionOutput.segments,
318
- ...(opts.reload !== undefined && { reload: opts.reload }),
319
- ...(opts.redirected !== undefined && { redirected: opts.redirected }),
306
+ segments,
320
307
  };
321
308
 
322
- Object.freeze(transitionMeta.segments.deactivated);
323
- Object.freeze(transitionMeta.segments.activated);
324
- Object.freeze(transitionMeta.segments);
325
309
  Object.freeze(transitionMeta);
326
310
 
327
- return {
328
- ...finalState,
311
+ const state: State = {
312
+ name: constants.UNKNOWN_ROUTE,
313
+ params: {} as Params,
314
+ path,
329
315
  transition: transitionMeta,
330
316
  };
317
+
318
+ Object.freeze(state);
319
+
320
+ this.#deps.setState(state);
321
+ this.#deps.emitTransitionSuccess(state, fromState, FROZEN_REPLACE_OPTS);
322
+
323
+ return state;
331
324
  }
332
325
 
333
- /**
334
- * Routes a caught transition error to the correct FSM event.
335
- */
336
- #routeTransitionError(
337
- error: unknown,
338
- toState: State,
339
- fromState: State | undefined,
340
- ): void {
341
- const routerError = error as RouterError;
342
-
343
- // Already routed: cancel/stop sent CANCEL, sendTransitionError called in try block
344
- if (
345
- routerError.code === errorCodes.TRANSITION_CANCELLED ||
346
- routerError.code === errorCodes.ROUTE_NOT_FOUND
347
- ) {
348
- return;
349
- }
326
+ abortCurrentNavigation(): void {
327
+ this.#currentController?.abort(
328
+ new RouterError(errorCodes.TRANSITION_CANCELLED),
329
+ );
330
+ this.#currentController = null;
331
+ }
350
332
 
351
- /* v8 ignore next 7 -- @preserve: defensive guard for unexpected error codes (e.g. future error types); else branch unreachable after middleware became fire-and-forget */
352
- if (
353
- routerError.code === errorCodes.CANNOT_ACTIVATE ||
354
- routerError.code === errorCodes.CANNOT_DEACTIVATE
355
- ) {
356
- this.#deps.sendTransitionBlocked(toState, fromState, routerError);
357
- } else {
358
- this.#deps.sendTransitionError(toState, fromState, routerError);
333
+ #abortPreviousNavigation(): void {
334
+ if (this.#deps.isTransitioning()) {
335
+ logger.warn(
336
+ "router.navigate",
337
+ "Concurrent navigation detected on shared router instance. " +
338
+ "For SSR, use cloneRouter() to create isolated instance per request.",
339
+ );
340
+ this.#currentController?.abort(
341
+ new RouterError(errorCodes.TRANSITION_CANCELLED),
342
+ );
343
+ this.#deps.cancelNavigation();
359
344
  }
360
345
  }
361
346
  }
@@ -2,4 +2,4 @@
2
2
 
3
3
  export { NavigationNamespace } from "./NavigationNamespace";
4
4
 
5
- export type { NavigationDependencies, TransitionDependencies } from "./types";
5
+ export type { NavigationDependencies } from "./types";
@@ -5,11 +5,14 @@ import { constants, errorCodes } from "../../../constants";
5
5
  import { RouterError } from "../../../RouterError";
6
6
  import { getTransitionPath } from "../../../transitionPath";
7
7
 
8
- import type { TransitionDependencies, TransitionOutput } from "../types";
8
+ import type { NavigationDependencies, TransitionOutput } from "../types";
9
9
  import type { NavigationOptions, State } from "@real-router/types";
10
10
 
11
11
  export async function transition(
12
- deps: TransitionDependencies,
12
+ deps: Pick<
13
+ NavigationDependencies,
14
+ "getLifecycleFunctions" | "isActive" | "clearCanDeactivate"
15
+ >,
13
16
  toState: State,
14
17
  fromState: State | undefined,
15
18
  opts: NavigationOptions,
@@ -16,7 +16,7 @@ import type {
16
16
  *
17
17
  * These are function references from other namespaces/facade,
18
18
  * avoiding the need to pass the entire Router object.
19
- */
19
+ **/
20
20
  export interface NavigationDependencies {
21
21
  /** Get router options */
22
22
  getOptions: () => Options;
@@ -54,8 +54,8 @@ export interface NavigationDependencies {
54
54
  ignoreQueryParams?: boolean,
55
55
  ) => boolean;
56
56
 
57
- /** Get a dependency by name (untyped used only for resolveOption) */
58
- getDependency: (name: string) => unknown;
57
+ /** Resolve defaultRoute and defaultParams options (static value or callback) */
58
+ resolveDefault: () => { route: string; params: Params };
59
59
 
60
60
  /** Start transition and send NAVIGATE event to routerFSM */
61
61
  startTransition: (toState: State, fromState: State | undefined) => void;
@@ -70,15 +70,8 @@ export interface NavigationDependencies {
70
70
  opts: NavigationOptions,
71
71
  ) => void;
72
72
 
73
- /** Send FAIL event to routerFSM (transition blocked) */
74
- sendTransitionBlocked: (
75
- toState: State,
76
- fromState: State | undefined,
77
- error: unknown,
78
- ) => void;
79
-
80
- /** Send FAIL event to routerFSM (transition error) */
81
- sendTransitionError: (
73
+ /** Send FAIL event to routerFSM */
74
+ sendTransitionFail: (
82
75
  toState: State,
83
76
  fromState: State | undefined,
84
77
  error: unknown,
@@ -90,24 +83,17 @@ export interface NavigationDependencies {
90
83
  fromState: State | undefined,
91
84
  error: unknown,
92
85
  ) => void;
93
- }
94
86
 
95
- export interface TransitionOutput {
96
- state: State;
97
- meta: {
98
- phase: TransitionPhase;
99
- segments: {
100
- deactivated: string[];
101
- activated: string[];
102
- intersection: string;
103
- };
104
- };
105
- }
87
+ /** Emit TRANSITION_SUCCESS event to listeners (without FSM transition) */
88
+ emitTransitionSuccess: (
89
+ toState: State,
90
+ fromState?: State,
91
+ opts?: NavigationOptions,
92
+ ) => void;
93
+
94
+ /** Check if navigation can begin (router is started) */
95
+ canNavigate: () => boolean;
106
96
 
107
- /**
108
- * Dependencies required for the transition function.
109
- */
110
- export interface TransitionDependencies {
111
97
  /** Get lifecycle functions (canDeactivate, canActivate maps) */
112
98
  getLifecycleFunctions: () => [Map<string, GuardFn>, Map<string, GuardFn>];
113
99
 
@@ -120,3 +106,15 @@ export interface TransitionDependencies {
120
106
  /** Clear canDeactivate guard for a route */
121
107
  clearCanDeactivate: (name: string) => void;
122
108
  }
109
+
110
+ export interface TransitionOutput {
111
+ state: State;
112
+ meta: {
113
+ phase: TransitionPhase;
114
+ segments: {
115
+ deactivated: string[];
116
+ activated: string[];
117
+ intersection: string;
118
+ };
119
+ };
120
+ }
@@ -12,7 +12,6 @@ import { DEFAULT_LIMITS } from "../../constants";
12
12
  import { computeThresholds } from "../../helpers";
13
13
 
14
14
  import type { PluginsDependencies } from "./types";
15
- import type { Router } from "../../Router";
16
15
  import type { Limits, PluginFactory } from "../../types";
17
16
  import type {
18
17
  DefaultDependencies,
@@ -32,7 +31,6 @@ export class PluginsNamespace<
32
31
  readonly #plugins = new Set<PluginFactory<Dependencies>>();
33
32
  readonly #unsubscribes = new Set<Unsubscribe>();
34
33
 
35
- #router!: Router<Dependencies>;
36
34
  #deps!: PluginsDependencies<Dependencies>;
37
35
  #limits: Limits = DEFAULT_LIMITS;
38
36
 
@@ -77,10 +75,6 @@ export class PluginsNamespace<
77
75
  // Dependency injection
78
76
  // =========================================================================
79
77
 
80
- setRouter(router: Router<Dependencies>): void {
81
- this.#router = router;
82
- }
83
-
84
78
  setDependencies(deps: PluginsDependencies<Dependencies>): void {
85
79
  this.#deps = deps;
86
80
  }
@@ -289,9 +283,7 @@ export class PluginsNamespace<
289
283
  }
290
284
 
291
285
  #startPlugin(pluginFactory: PluginFactory<Dependencies>): Unsubscribe {
292
- // Bind getDependency to preserve 'this' context when called from factory
293
- // Plugin factories receive full router as part of their public API
294
- const appliedPlugin = pluginFactory(this.#router, this.#deps.getDependency);
286
+ const appliedPlugin = this.#deps.compileFactory(pluginFactory);
295
287
 
296
288
  PluginsNamespace.validatePlugin(appliedPlugin);
297
289
 
@@ -1,6 +1,6 @@
1
1
  // packages/core/src/namespaces/PluginsNamespace/types.ts
2
2
 
3
- import type { EventMethodMap } from "../../types";
3
+ import type { EventMethodMap, PluginFactory } from "../../types";
4
4
  import type {
5
5
  DefaultDependencies,
6
6
  EventName,
@@ -8,25 +8,15 @@ import type {
8
8
  Unsubscribe,
9
9
  } from "@real-router/types";
10
10
 
11
- /**
12
- * Dependencies injected into PluginsNamespace.
13
- *
14
- * Note: Plugin factories still receive the router object directly
15
- * as they need access to various router methods. This interface
16
- * only covers the internal namespace operations.
17
- */
18
11
  export interface PluginsDependencies<
19
12
  Dependencies extends DefaultDependencies = DefaultDependencies,
20
13
  > {
21
- /** Add event listener for plugin subscription */
22
14
  addEventListener: <E extends EventName>(
23
15
  eventName: E,
24
16
  cb: Plugin[EventMethodMap[E]],
25
17
  ) => Unsubscribe;
26
18
 
27
- /** Check if navigation is possible (for warning about late onStart) */
28
19
  canNavigate: () => boolean;
29
20
 
30
- /** Get dependency value for plugin factory */
31
- getDependency: <K extends keyof Dependencies>(key: K) => Dependencies[K];
21
+ compileFactory: (factory: PluginFactory<Dependencies>) => Plugin;
32
22
  }