@real-router/core 0.36.1 → 0.37.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.
- package/README.md +144 -168
- package/dist/cjs/api.js +1 -1
- package/dist/cjs/api.js.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/metafile-cjs.json +1 -1
- package/dist/esm/api.mjs +1 -1
- package/dist/esm/api.mjs.map +1 -1
- package/dist/esm/chunk-CG7TKDP3.mjs +1 -0
- package/dist/esm/chunk-CG7TKDP3.mjs.map +1 -0
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/metafile-esm.json +1 -1
- package/package.json +7 -7
- package/src/Router.ts +33 -12
- package/src/api/getPluginApi.ts +19 -4
- package/src/api/getRoutesApi.ts +0 -20
- package/src/constants.ts +2 -0
- package/src/fsm/routerFSM.ts +2 -21
- package/src/internals.ts +21 -2
- package/src/namespaces/EventBusNamespace/EventBusNamespace.ts +40 -37
- package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +221 -153
- package/src/namespaces/NavigationNamespace/constants.ts +55 -0
- package/src/namespaces/NavigationNamespace/transition/completeTransition.ts +100 -0
- package/src/namespaces/NavigationNamespace/transition/errorHandling.ts +34 -0
- package/src/namespaces/NavigationNamespace/transition/guardPhase.ts +214 -0
- package/src/namespaces/NavigationNamespace/types.ts +14 -30
- package/src/namespaces/RouteLifecycleNamespace/RouteLifecycleNamespace.ts +6 -1
- package/src/namespaces/RoutesNamespace/RoutesNamespace.ts +36 -35
- package/src/namespaces/RoutesNamespace/forwardToValidation.ts +2 -5
- package/src/namespaces/RoutesNamespace/types.ts +1 -2
- package/src/namespaces/StateNamespace/StateNamespace.ts +13 -17
- package/src/transitionPath.ts +68 -39
- package/src/wiring/RouterWiringBuilder.ts +16 -15
- package/dist/esm/chunk-HZ7RFKT5.mjs +0 -1
- package/dist/esm/chunk-HZ7RFKT5.mjs.map +0 -1
- package/src/namespaces/NavigationNamespace/transition/executeLifecycleGuards.ts +0 -52
- package/src/namespaces/NavigationNamespace/transition/index.ts +0 -93
|
@@ -23,6 +23,9 @@ export class EventBusNamespace {
|
|
|
23
23
|
readonly #emitter: EventEmitter<RouterEventMap>;
|
|
24
24
|
|
|
25
25
|
#currentToState: State | undefined;
|
|
26
|
+
#pendingToState: State | undefined;
|
|
27
|
+
#pendingFromState: State | undefined;
|
|
28
|
+
#pendingError: unknown;
|
|
26
29
|
|
|
27
30
|
constructor(options: EventBusOptions) {
|
|
28
31
|
this.#fsm = options.routerFSM;
|
|
@@ -90,7 +93,9 @@ export class EventBusNamespace {
|
|
|
90
93
|
|
|
91
94
|
sendNavigate(toState: State, fromState?: State): void {
|
|
92
95
|
this.#currentToState = toState;
|
|
93
|
-
|
|
96
|
+
// Bypass FSM dispatch — forceState + direct emit (no action lookup, no rest params)
|
|
97
|
+
this.#fsm.forceState(routerStates.TRANSITIONING);
|
|
98
|
+
this.emitTransitionStart(toState, fromState);
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
sendComplete(
|
|
@@ -98,15 +103,11 @@ export class EventBusNamespace {
|
|
|
98
103
|
fromState?: State,
|
|
99
104
|
opts: NavigationOptions = {},
|
|
100
105
|
): void {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this
|
|
104
|
-
state,
|
|
105
|
-
fromState,
|
|
106
|
-
opts,
|
|
107
|
-
});
|
|
106
|
+
// Bypass FSM dispatch — forceState + direct emit
|
|
107
|
+
this.#fsm.forceState(routerStates.READY);
|
|
108
|
+
this.emitTransitionSuccess(state, fromState, opts);
|
|
108
109
|
|
|
109
|
-
if (this.#currentToState ===
|
|
110
|
+
if (this.#currentToState === state) {
|
|
110
111
|
this.#currentToState = undefined;
|
|
111
112
|
}
|
|
112
113
|
}
|
|
@@ -114,7 +115,10 @@ export class EventBusNamespace {
|
|
|
114
115
|
sendFail(toState?: State, fromState?: State, error?: unknown): void {
|
|
115
116
|
const prev = this.#currentToState;
|
|
116
117
|
|
|
117
|
-
this.#
|
|
118
|
+
this.#pendingToState = toState;
|
|
119
|
+
this.#pendingFromState = fromState;
|
|
120
|
+
this.#pendingError = error;
|
|
121
|
+
this.#fsm.send(routerEvents.FAIL);
|
|
118
122
|
|
|
119
123
|
if (this.#currentToState === prev) {
|
|
120
124
|
this.#currentToState = undefined;
|
|
@@ -132,7 +136,9 @@ export class EventBusNamespace {
|
|
|
132
136
|
sendCancel(toState: State, fromState?: State): void {
|
|
133
137
|
const prev = this.#currentToState;
|
|
134
138
|
|
|
135
|
-
this.#
|
|
139
|
+
this.#pendingToState = toState;
|
|
140
|
+
this.#pendingFromState = fromState;
|
|
141
|
+
this.#fsm.send(routerEvents.CANCEL);
|
|
136
142
|
|
|
137
143
|
if (this.#currentToState === prev) {
|
|
138
144
|
this.#currentToState = undefined;
|
|
@@ -212,6 +218,14 @@ export class EventBusNamespace {
|
|
|
212
218
|
this.sendCancel(this.#currentToState!, fromState); // eslint-disable-line @typescript-eslint/no-non-null-assertion -- guaranteed set before TRANSITIONING
|
|
213
219
|
}
|
|
214
220
|
|
|
221
|
+
#emitPendingError(): void {
|
|
222
|
+
this.emitTransitionError(
|
|
223
|
+
this.#pendingToState,
|
|
224
|
+
this.#pendingFromState,
|
|
225
|
+
this.#pendingError as RouterError | undefined,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
215
229
|
#setupFSMActions(): void {
|
|
216
230
|
const fsm = this.#fsm;
|
|
217
231
|
|
|
@@ -223,40 +237,29 @@ export class EventBusNamespace {
|
|
|
223
237
|
this.emitRouterStop();
|
|
224
238
|
});
|
|
225
239
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
240
|
+
// NAVIGATE and COMPLETE actions bypassed — sendNavigate/sendComplete
|
|
241
|
+
// use fsm.forceState() + direct emit for zero-allocation hot path.
|
|
242
|
+
fsm.on(routerStates.TRANSITIONING, routerEvents.CANCEL, () => {
|
|
243
|
+
const toState = this.#pendingToState;
|
|
229
244
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
245
|
+
/* v8 ignore next -- @preserve: #pendingToState guaranteed set by sendCancel before send() */
|
|
246
|
+
if (toState === undefined) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
233
249
|
|
|
234
|
-
|
|
235
|
-
this.emitTransitionCancel(params.toState, params.fromState);
|
|
250
|
+
this.emitTransitionCancel(toState, this.#pendingFromState);
|
|
236
251
|
});
|
|
237
252
|
|
|
238
|
-
fsm.on(routerStates.STARTING, routerEvents.FAIL, (
|
|
239
|
-
this
|
|
240
|
-
params.toState,
|
|
241
|
-
params.fromState,
|
|
242
|
-
params.error as RouterError | undefined,
|
|
243
|
-
);
|
|
253
|
+
fsm.on(routerStates.STARTING, routerEvents.FAIL, () => {
|
|
254
|
+
this.#emitPendingError();
|
|
244
255
|
});
|
|
245
256
|
|
|
246
|
-
fsm.on(routerStates.READY, routerEvents.FAIL, (
|
|
247
|
-
this
|
|
248
|
-
params.toState,
|
|
249
|
-
params.fromState,
|
|
250
|
-
params.error as RouterError | undefined,
|
|
251
|
-
);
|
|
257
|
+
fsm.on(routerStates.READY, routerEvents.FAIL, () => {
|
|
258
|
+
this.#emitPendingError();
|
|
252
259
|
});
|
|
253
260
|
|
|
254
|
-
fsm.on(routerStates.TRANSITIONING, routerEvents.FAIL, (
|
|
255
|
-
this
|
|
256
|
-
params.toState,
|
|
257
|
-
params.fromState,
|
|
258
|
-
params.error as RouterError | undefined,
|
|
259
|
-
);
|
|
261
|
+
fsm.on(routerStates.TRANSITIONING, routerEvents.FAIL, () => {
|
|
262
|
+
this.#emitPendingError();
|
|
260
263
|
});
|
|
261
264
|
}
|
|
262
265
|
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
// packages/core/src/namespaces/NavigationNamespace/NavigationNamespace.ts
|
|
2
|
-
|
|
3
1
|
import { logger } from "@real-router/logger";
|
|
4
2
|
|
|
5
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CACHED_NOT_STARTED_REJECTION,
|
|
5
|
+
CACHED_ROUTE_NOT_FOUND_ERROR,
|
|
6
|
+
CACHED_ROUTE_NOT_FOUND_REJECTION,
|
|
7
|
+
CACHED_SAME_STATES_ERROR,
|
|
8
|
+
CACHED_SAME_STATES_REJECTION,
|
|
9
|
+
} from "./constants";
|
|
10
|
+
import { completeTransition } from "./transition/completeTransition";
|
|
11
|
+
import { routeTransitionError } from "./transition/errorHandling";
|
|
12
|
+
import { executeGuardPipeline } from "./transition/guardPhase";
|
|
6
13
|
import {
|
|
7
14
|
validateNavigateArgs,
|
|
8
15
|
validateNavigateToDefaultArgs,
|
|
@@ -10,9 +17,9 @@ import {
|
|
|
10
17
|
} from "./validators";
|
|
11
18
|
import { errorCodes, constants } from "../../constants";
|
|
12
19
|
import { RouterError } from "../../RouterError";
|
|
13
|
-
import { nameToIDs } from "../../transitionPath";
|
|
20
|
+
import { getTransitionPath, nameToIDs } from "../../transitionPath";
|
|
14
21
|
|
|
15
|
-
import type {
|
|
22
|
+
import type { NavigationContext, NavigationDependencies } from "./types";
|
|
16
23
|
import type {
|
|
17
24
|
NavigationOptions,
|
|
18
25
|
Params,
|
|
@@ -36,65 +43,35 @@ function forceReplaceFromUnknown(
|
|
|
36
43
|
: opts;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
function
|
|
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"],
|
|
46
|
+
function isSameNavigation(
|
|
67
47
|
fromState: State | undefined,
|
|
68
48
|
opts: NavigationOptions,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
};
|
|
49
|
+
toState: State,
|
|
50
|
+
areStatesEqual: (a: State, b: State, ignoreQuery: boolean) => boolean,
|
|
51
|
+
): boolean {
|
|
52
|
+
return (
|
|
53
|
+
!!fromState &&
|
|
54
|
+
!opts.reload &&
|
|
55
|
+
!opts.force &&
|
|
56
|
+
areStatesEqual(fromState, toState, false)
|
|
57
|
+
);
|
|
88
58
|
}
|
|
89
59
|
|
|
90
60
|
/**
|
|
91
61
|
* Independent namespace for managing navigation.
|
|
92
62
|
*
|
|
93
63
|
* Handles navigate(), navigateToDefault(), navigateToNotFound(), and transition state.
|
|
64
|
+
*
|
|
65
|
+
* Performance: navigate() uses optimistic sync execution — guards run synchronously
|
|
66
|
+
* until one returns a Promise, then switches to async. This eliminates Promise/AbortController
|
|
67
|
+
* overhead for the common case (no guards or sync guards).
|
|
94
68
|
*/
|
|
95
69
|
export class NavigationNamespace {
|
|
70
|
+
lastSyncResolved = false;
|
|
71
|
+
lastSyncRejected = false;
|
|
96
72
|
#deps!: NavigationDependencies;
|
|
97
73
|
#currentController: AbortController | null = null;
|
|
74
|
+
#navigationId = 0;
|
|
98
75
|
|
|
99
76
|
// =========================================================================
|
|
100
77
|
// Static validation methods (called by facade before instance methods)
|
|
@@ -128,154 +105,176 @@ export class NavigationNamespace {
|
|
|
128
105
|
// Instance methods
|
|
129
106
|
// =========================================================================
|
|
130
107
|
|
|
131
|
-
|
|
132
|
-
* Navigates to a route by name.
|
|
133
|
-
* Arguments should be pre-parsed and validated by facade.
|
|
134
|
-
*/
|
|
135
|
-
async navigate(
|
|
108
|
+
navigate(
|
|
136
109
|
name: string,
|
|
137
110
|
params: Params,
|
|
138
111
|
opts: NavigationOptions,
|
|
139
112
|
): Promise<State> {
|
|
140
|
-
|
|
141
|
-
throw new RouterError(errorCodes.ROUTER_NOT_STARTED);
|
|
142
|
-
}
|
|
143
|
-
|
|
113
|
+
this.lastSyncResolved = false;
|
|
144
114
|
const deps = this.#deps;
|
|
145
115
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!
|
|
149
|
-
|
|
116
|
+
// Fast-path sync rejections: cached error + cached Promise.reject
|
|
117
|
+
// No allocations, no throw/catch overhead, facade skips .catch() suppression
|
|
118
|
+
if (!deps.canNavigate()) {
|
|
119
|
+
this.lastSyncRejected = true;
|
|
150
120
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
throw err;
|
|
121
|
+
return CACHED_NOT_STARTED_REJECTION;
|
|
154
122
|
}
|
|
155
123
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
route.params,
|
|
161
|
-
deps.buildPath(route.name, route.params),
|
|
162
|
-
{
|
|
163
|
-
params: route.meta,
|
|
164
|
-
},
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
const fromState = deps.getState();
|
|
124
|
+
let toState: State | undefined;
|
|
125
|
+
let fromState: State | undefined;
|
|
126
|
+
let transitionStarted = false;
|
|
127
|
+
let controller: AbortController | null = null;
|
|
168
128
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
fromState &&
|
|
173
|
-
!opts.reload &&
|
|
174
|
-
!opts.force &&
|
|
175
|
-
deps.areStatesEqual(fromState, toState, false)
|
|
176
|
-
) {
|
|
177
|
-
const err = new RouterError(errorCodes.SAME_STATES);
|
|
129
|
+
try {
|
|
130
|
+
toState = deps.buildNavigateState(name, params);
|
|
178
131
|
|
|
179
|
-
|
|
132
|
+
if (!toState) {
|
|
133
|
+
deps.emitTransitionError(
|
|
134
|
+
undefined,
|
|
135
|
+
deps.getState(),
|
|
136
|
+
CACHED_ROUTE_NOT_FOUND_ERROR,
|
|
137
|
+
);
|
|
138
|
+
this.lastSyncRejected = true;
|
|
180
139
|
|
|
181
|
-
|
|
182
|
-
|
|
140
|
+
return CACHED_ROUTE_NOT_FOUND_REJECTION;
|
|
141
|
+
}
|
|
183
142
|
|
|
184
|
-
|
|
143
|
+
fromState = deps.getState();
|
|
144
|
+
opts = forceReplaceFromUnknown(opts, fromState);
|
|
185
145
|
|
|
186
|
-
|
|
146
|
+
if (isSameNavigation(fromState, opts, toState, deps.areStatesEqual)) {
|
|
147
|
+
deps.emitTransitionError(toState, fromState, CACHED_SAME_STATES_ERROR);
|
|
148
|
+
this.lastSyncRejected = true;
|
|
187
149
|
|
|
188
|
-
|
|
150
|
+
return CACHED_SAME_STATES_REJECTION;
|
|
151
|
+
}
|
|
189
152
|
|
|
190
|
-
|
|
191
|
-
if (opts.signal.aborted) {
|
|
192
|
-
this.#currentController = null;
|
|
153
|
+
this.#abortPreviousNavigation();
|
|
193
154
|
|
|
155
|
+
if (opts.signal?.aborted) {
|
|
194
156
|
throw new RouterError(errorCodes.TRANSITION_CANCELLED, {
|
|
195
157
|
reason: opts.signal.reason,
|
|
196
158
|
});
|
|
197
159
|
}
|
|
198
160
|
|
|
199
|
-
|
|
200
|
-
"abort",
|
|
201
|
-
() => {
|
|
202
|
-
controller.abort(opts.signal?.reason);
|
|
203
|
-
},
|
|
204
|
-
{ once: true, signal: controller.signal },
|
|
205
|
-
);
|
|
206
|
-
}
|
|
161
|
+
const myId = ++this.#navigationId;
|
|
207
162
|
|
|
208
|
-
|
|
163
|
+
deps.startTransition(toState, fromState);
|
|
164
|
+
transitionStarted = true;
|
|
209
165
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
166
|
+
// Reentrant navigate from TRANSITION_START listener superseded this navigation
|
|
167
|
+
if (this.#navigationId !== myId) {
|
|
168
|
+
throw new RouterError(errorCodes.TRANSITION_CANCELLED);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const [canDeactivateFunctions, canActivateFunctions] =
|
|
172
|
+
deps.getLifecycleFunctions();
|
|
173
|
+
const isUnknownRoute = toState.name === constants.UNKNOWN_ROUTE;
|
|
174
|
+
|
|
175
|
+
const { toDeactivate, toActivate, intersection } = getTransitionPath(
|
|
213
176
|
toState,
|
|
214
177
|
fromState,
|
|
215
|
-
opts,
|
|
216
|
-
controller.signal,
|
|
178
|
+
opts.reload,
|
|
217
179
|
);
|
|
218
180
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
181
|
+
const shouldDeactivate =
|
|
182
|
+
fromState && !opts.forceDeactivate && toDeactivate.length > 0;
|
|
183
|
+
const shouldActivate = !isUnknownRoute && toActivate.length > 0;
|
|
184
|
+
const hasGuards =
|
|
185
|
+
canDeactivateFunctions.size > 0 || canActivateFunctions.size > 0;
|
|
186
|
+
|
|
187
|
+
if (hasGuards) {
|
|
188
|
+
controller = new AbortController();
|
|
189
|
+
this.#currentController = controller;
|
|
190
|
+
|
|
191
|
+
const signal = controller.signal;
|
|
192
|
+
const isCurrentNav = () =>
|
|
193
|
+
this.#navigationId === myId && deps.isActive();
|
|
194
|
+
|
|
195
|
+
const guardCompletion = executeGuardPipeline(
|
|
196
|
+
canDeactivateFunctions,
|
|
197
|
+
canActivateFunctions,
|
|
198
|
+
toDeactivate,
|
|
199
|
+
toActivate,
|
|
200
|
+
!!shouldDeactivate,
|
|
201
|
+
shouldActivate,
|
|
202
|
+
toState,
|
|
226
203
|
fromState,
|
|
227
|
-
|
|
204
|
+
signal,
|
|
205
|
+
isCurrentNav,
|
|
228
206
|
);
|
|
229
207
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
208
|
+
if (guardCompletion !== undefined) {
|
|
209
|
+
return this.#finishAsyncNavigation(
|
|
210
|
+
guardCompletion,
|
|
211
|
+
{
|
|
212
|
+
toState,
|
|
213
|
+
fromState,
|
|
214
|
+
opts,
|
|
215
|
+
toDeactivate,
|
|
216
|
+
toActivate,
|
|
217
|
+
intersection,
|
|
218
|
+
canDeactivateFunctions,
|
|
219
|
+
},
|
|
220
|
+
controller,
|
|
221
|
+
myId,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!isCurrentNav()) {
|
|
226
|
+
throw new RouterError(errorCodes.TRANSITION_CANCELLED);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.#cleanupController(controller);
|
|
230
|
+
}
|
|
242
231
|
|
|
243
|
-
|
|
232
|
+
this.lastSyncResolved = true;
|
|
244
233
|
|
|
245
|
-
|
|
246
|
-
|
|
234
|
+
return Promise.resolve(
|
|
235
|
+
completeTransition(deps, {
|
|
236
|
+
toState,
|
|
237
|
+
fromState,
|
|
238
|
+
opts,
|
|
239
|
+
toDeactivate,
|
|
240
|
+
toActivate,
|
|
241
|
+
intersection,
|
|
242
|
+
canDeactivateFunctions,
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
247
245
|
} catch (error) {
|
|
248
|
-
|
|
246
|
+
this.#handleNavigateError(
|
|
247
|
+
error,
|
|
248
|
+
controller,
|
|
249
|
+
transitionStarted,
|
|
250
|
+
toState,
|
|
251
|
+
fromState,
|
|
252
|
+
);
|
|
249
253
|
|
|
250
|
-
|
|
251
|
-
} finally {
|
|
252
|
-
controller.abort();
|
|
253
|
-
if (this.#currentController === controller) {
|
|
254
|
-
this.#currentController = null;
|
|
255
|
-
}
|
|
254
|
+
return Promise.reject(error as Error);
|
|
256
255
|
}
|
|
257
256
|
}
|
|
258
257
|
|
|
259
|
-
|
|
260
|
-
* Navigates to the default route if configured.
|
|
261
|
-
* Arguments should be pre-parsed and validated by facade.
|
|
262
|
-
*/
|
|
263
|
-
async navigateToDefault(opts: NavigationOptions): Promise<State> {
|
|
258
|
+
navigateToDefault(opts: NavigationOptions): Promise<State> {
|
|
264
259
|
const deps = this.#deps;
|
|
265
260
|
const options = deps.getOptions();
|
|
266
261
|
|
|
267
262
|
if (!options.defaultRoute) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
263
|
+
return Promise.reject(
|
|
264
|
+
new RouterError(errorCodes.ROUTE_NOT_FOUND, {
|
|
265
|
+
routeName: "defaultRoute not configured",
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
271
268
|
}
|
|
272
269
|
|
|
273
270
|
const { route, params } = deps.resolveDefault();
|
|
274
271
|
|
|
275
272
|
if (!route) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
273
|
+
return Promise.reject(
|
|
274
|
+
new RouterError(errorCodes.ROUTE_NOT_FOUND, {
|
|
275
|
+
routeName: "defaultRoute resolved to empty",
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
279
278
|
}
|
|
280
279
|
|
|
281
280
|
return this.navigate(route, params, opts);
|
|
@@ -330,6 +329,75 @@ export class NavigationNamespace {
|
|
|
330
329
|
this.#currentController = null;
|
|
331
330
|
}
|
|
332
331
|
|
|
332
|
+
async #finishAsyncNavigation(
|
|
333
|
+
guardCompletion: Promise<void>,
|
|
334
|
+
nav: NavigationContext,
|
|
335
|
+
controller: AbortController,
|
|
336
|
+
myId: number,
|
|
337
|
+
): Promise<State> {
|
|
338
|
+
const deps = this.#deps;
|
|
339
|
+
const isActive = () =>
|
|
340
|
+
this.#navigationId === myId &&
|
|
341
|
+
!controller.signal.aborted &&
|
|
342
|
+
deps.isActive();
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
if (nav.opts.signal) {
|
|
346
|
+
if (nav.opts.signal.aborted) {
|
|
347
|
+
throw new RouterError(errorCodes.TRANSITION_CANCELLED, {
|
|
348
|
+
reason: nav.opts.signal.reason,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
nav.opts.signal.addEventListener(
|
|
353
|
+
"abort",
|
|
354
|
+
() => {
|
|
355
|
+
controller.abort(nav.opts.signal?.reason);
|
|
356
|
+
},
|
|
357
|
+
{ once: true, signal: controller.signal },
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await guardCompletion;
|
|
362
|
+
|
|
363
|
+
if (!isActive()) {
|
|
364
|
+
throw new RouterError(errorCodes.TRANSITION_CANCELLED);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return completeTransition(deps, nav);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
routeTransitionError(deps, error, nav.toState, nav.fromState);
|
|
370
|
+
|
|
371
|
+
throw error;
|
|
372
|
+
} finally {
|
|
373
|
+
this.#cleanupController(controller);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#handleNavigateError(
|
|
378
|
+
error: unknown,
|
|
379
|
+
controller: AbortController | null,
|
|
380
|
+
transitionStarted: boolean,
|
|
381
|
+
toState: State | undefined,
|
|
382
|
+
fromState: State | undefined,
|
|
383
|
+
): void {
|
|
384
|
+
if (controller) {
|
|
385
|
+
this.#cleanupController(controller);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (transitionStarted && toState) {
|
|
389
|
+
routeTransitionError(this.#deps, error, toState, fromState);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#cleanupController(controller: AbortController): void {
|
|
394
|
+
controller.abort();
|
|
395
|
+
|
|
396
|
+
if (this.#currentController === controller) {
|
|
397
|
+
this.#currentController = null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
333
401
|
#abortPreviousNavigation(): void {
|
|
334
402
|
if (this.#deps.isTransitioning()) {
|
|
335
403
|
logger.warn(
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// packages/core/src/namespaces/NavigationNamespace/constants.ts
|
|
2
|
+
|
|
3
|
+
import { errorCodes } from "../../constants";
|
|
4
|
+
import { RouterError } from "../../RouterError";
|
|
5
|
+
|
|
6
|
+
import type { State } from "@real-router/types";
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Cached Errors & Rejected Promises (Performance Optimization)
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Pre-create error instances and rejected promises for sync error paths
|
|
12
|
+
// in navigate(). Eliminates per-call allocations:
|
|
13
|
+
// - new RouterError() — object + stack trace capture (~500ns-2μs)
|
|
14
|
+
// - Promise.reject() — promise allocation
|
|
15
|
+
// - .catch(handler) — derived promise from suppression
|
|
16
|
+
//
|
|
17
|
+
// Trade-off: All error instances share the same stack trace (points here).
|
|
18
|
+
// This is acceptable because:
|
|
19
|
+
// 1. These errors indicate expected conditions, not internal bugs
|
|
20
|
+
// 2. Error code and message are sufficient for debugging
|
|
21
|
+
// 3. The facade skips .catch() suppression for cached promises (zero alloc)
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
export const CACHED_NOT_STARTED_ERROR = new RouterError(
|
|
25
|
+
errorCodes.ROUTER_NOT_STARTED,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export const CACHED_ROUTE_NOT_FOUND_ERROR = new RouterError(
|
|
29
|
+
errorCodes.ROUTE_NOT_FOUND,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export const CACHED_SAME_STATES_ERROR = new RouterError(errorCodes.SAME_STATES);
|
|
33
|
+
|
|
34
|
+
// Pre-suppressed rejected promises — .catch() at module load prevents
|
|
35
|
+
// unhandled rejection warnings. The facade skips additional .catch() calls
|
|
36
|
+
// via the lastSyncRejected flag (zero derived-promise allocation).
|
|
37
|
+
export const CACHED_NOT_STARTED_REJECTION: Promise<State> = Promise.reject(
|
|
38
|
+
CACHED_NOT_STARTED_ERROR,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export const CACHED_ROUTE_NOT_FOUND_REJECTION: Promise<State> = Promise.reject(
|
|
42
|
+
CACHED_ROUTE_NOT_FOUND_ERROR,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export const CACHED_SAME_STATES_REJECTION: Promise<State> = Promise.reject(
|
|
46
|
+
CACHED_SAME_STATES_ERROR,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Suppress once at module load — prevents unhandled rejection events.
|
|
50
|
+
// Subsequent .catch() / await by user code still works correctly:
|
|
51
|
+
// a rejected promise stays rejected forever, each .catch() creates
|
|
52
|
+
// its own derived promise and fires its handler.
|
|
53
|
+
CACHED_NOT_STARTED_REJECTION.catch(() => {}); // NOSONAR -- intentional suppression, not a promise chain
|
|
54
|
+
CACHED_ROUTE_NOT_FOUND_REJECTION.catch(() => {}); // NOSONAR
|
|
55
|
+
CACHED_SAME_STATES_REJECTION.catch(() => {}); // NOSONAR
|