@real-router/core 0.50.2 → 0.52.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/dist/cjs/Router-DrBkBdZ5.d.ts.map +1 -1
- package/dist/cjs/Router-Pztue5fk.js +6 -0
- package/dist/cjs/Router-Pztue5fk.js.map +1 -0
- package/dist/{esm/RouterValidator-BLtjhvRo.d.mts → cjs/RouterValidator-DLy_W2du.d.ts} +2 -1
- package/dist/cjs/{RouterValidator-BL1Uq6Rq.d.ts.map → RouterValidator-DLy_W2du.d.ts.map} +1 -1
- package/dist/cjs/api.d.ts.map +1 -1
- package/dist/cjs/api.js +1 -1
- package/dist/cjs/api.js.map +1 -1
- package/dist/cjs/getPluginApi-CUcFDzuA.js +2 -0
- package/dist/cjs/getPluginApi-CUcFDzuA.js.map +1 -0
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/internals-na15rxo_.js.map +1 -1
- package/dist/cjs/utils.d.ts +70 -2
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js +1 -1
- package/dist/cjs/utils.js.map +1 -1
- package/dist/cjs/validation.d.ts +9 -2
- package/dist/cjs/validation.d.ts.map +1 -1
- package/dist/esm/Router-BeXr2zW4.d.mts.map +1 -1
- package/dist/esm/Router-CK8U23pP.mjs +6 -0
- package/dist/esm/Router-CK8U23pP.mjs.map +1 -0
- package/dist/{cjs/RouterValidator-BL1Uq6Rq.d.ts → esm/RouterValidator-C-PvV00i.d.mts} +2 -1
- package/dist/esm/{RouterValidator-BLtjhvRo.d.mts.map → RouterValidator-C-PvV00i.d.mts.map} +1 -1
- package/dist/esm/api.d.mts.map +1 -1
- package/dist/esm/api.mjs +1 -1
- package/dist/esm/api.mjs.map +1 -1
- package/dist/esm/getPluginApi-CsTfDB-O.mjs +2 -0
- package/dist/esm/getPluginApi-CsTfDB-O.mjs.map +1 -0
- package/dist/esm/index.d.mts +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/internals-CCymabFj.mjs.map +1 -1
- package/dist/esm/utils.d.mts +70 -2
- package/dist/esm/utils.d.mts.map +1 -1
- package/dist/esm/utils.mjs +1 -1
- package/dist/esm/utils.mjs.map +1 -1
- package/dist/esm/validation.d.mts +9 -2
- package/dist/esm/validation.d.mts.map +1 -1
- package/package.json +2 -2
- package/src/Router.ts +20 -0
- package/src/api/getPluginApi.ts +33 -2
- package/src/internals.ts +12 -0
- package/src/namespaces/NavigationNamespace/NavigationNamespace.ts +164 -94
- package/src/namespaces/RouterLifecycleNamespace/RouterLifecycleNamespace.ts +5 -5
- package/src/namespaces/RouterLifecycleNamespace/types.ts +9 -5
- package/src/types/RouterValidator.ts +1 -0
- package/src/utils/hydrateRouter.ts +33 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/serializeRouterState.ts +73 -0
- package/src/wiring/RouterWiringBuilder.ts +2 -2
- package/dist/cjs/Router--UAz5UnF.js +0 -6
- package/dist/cjs/Router--UAz5UnF.js.map +0 -1
- package/dist/cjs/getPluginApi-BBcZZXA5.js +0 -2
- package/dist/cjs/getPluginApi-BBcZZXA5.js.map +0 -1
- package/dist/esm/Router-C4grLNrL.mjs +0 -6
- package/dist/esm/Router-C4grLNrL.mjs.map +0 -1
- package/dist/esm/getPluginApi-DasnID2W.mjs +0 -2
- package/dist/esm/getPluginApi-DasnID2W.mjs.map +0 -1
package/src/api/getPluginApi.ts
CHANGED
|
@@ -12,12 +12,25 @@ import type {
|
|
|
12
12
|
State,
|
|
13
13
|
} from "@real-router/types";
|
|
14
14
|
|
|
15
|
+
// Cache the assembled PluginApi per router — mirrors getNavigator() (#525):
|
|
16
|
+
// avoids re-allocating the closure-bag on each call (plugins call this once
|
|
17
|
+
// at init, but tests + nested plugins poll it), and gives spy/stub helpers
|
|
18
|
+
// a stable object identity to attach to (e.g. spying on
|
|
19
|
+
// `getPluginApi(router).navigateToState` to inject errors in popstate
|
|
20
|
+
// recovery tests).
|
|
21
|
+
const cache = new WeakMap<object, PluginApi>();
|
|
22
|
+
|
|
15
23
|
export function getPluginApi<
|
|
16
24
|
Dependencies extends DefaultDependencies = DefaultDependencies,
|
|
17
25
|
>(router: Router<Dependencies>): PluginApi {
|
|
18
|
-
const
|
|
26
|
+
const cached = cache.get(router);
|
|
27
|
+
|
|
28
|
+
if (cached) {
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
const ctx = getInternals(router);
|
|
33
|
+
const api: PluginApi = {
|
|
21
34
|
makeState: (name, params, path, meta) => {
|
|
22
35
|
ctx.validator?.state.validateMakeStateArgs(name, params, path);
|
|
23
36
|
|
|
@@ -58,6 +71,20 @@ export function getPluginApi<
|
|
|
58
71
|
|
|
59
72
|
return ctx.matchPath(path, ctx.getOptions());
|
|
60
73
|
},
|
|
74
|
+
navigateToState: (state, options) => {
|
|
75
|
+
throwIfDisposed(ctx.isDisposed);
|
|
76
|
+
|
|
77
|
+
ctx.validator?.navigation.validateNavigateToStateArgs(state);
|
|
78
|
+
|
|
79
|
+
if (options !== undefined) {
|
|
80
|
+
ctx.validator?.navigation.validateNavigationOptions(
|
|
81
|
+
options,
|
|
82
|
+
"navigateToState",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return ctx.navigateToState(state, options);
|
|
87
|
+
},
|
|
61
88
|
setRootPath: (rootPath) => {
|
|
62
89
|
throwIfDisposed(ctx.isDisposed);
|
|
63
90
|
|
|
@@ -194,4 +221,8 @@ export function getPluginApi<
|
|
|
194
221
|
} satisfies ContextNamespaceClaim;
|
|
195
222
|
}) as PluginApi["claimContextNamespace"],
|
|
196
223
|
};
|
|
224
|
+
|
|
225
|
+
cache.set(router, api);
|
|
226
|
+
|
|
227
|
+
return api;
|
|
197
228
|
}
|
package/src/internals.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { RouterValidator } from "./types/RouterValidator";
|
|
|
6
6
|
import type {
|
|
7
7
|
DefaultDependencies,
|
|
8
8
|
EventName,
|
|
9
|
+
NavigationOptions,
|
|
9
10
|
Options,
|
|
10
11
|
Params,
|
|
11
12
|
Plugin,
|
|
@@ -55,6 +56,17 @@ export interface RouterInternals<
|
|
|
55
56
|
|
|
56
57
|
readonly start: (path: string) => Promise<State>;
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Plugin-only navigation entry point — delegates to
|
|
61
|
+
* `NavigationNamespace.navigateToState` (`getPluginApi(router).navigateToState`).
|
|
62
|
+
* Hidden from `Router`/`Navigator` to keep the userland surface minimal;
|
|
63
|
+
* see `core-types/src/api.ts` for usage docs.
|
|
64
|
+
*/
|
|
65
|
+
readonly navigateToState: (
|
|
66
|
+
state: State,
|
|
67
|
+
options?: NavigationOptions,
|
|
68
|
+
) => Promise<State>;
|
|
69
|
+
|
|
58
70
|
/* eslint-disable @typescript-eslint/no-explicit-any -- heterogeneous map: stores different InterceptorFn<M> types under different keys */
|
|
59
71
|
readonly interceptors: Map<
|
|
60
72
|
string,
|
|
@@ -98,24 +98,176 @@ export class NavigationNamespace {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
let toState: State | undefined;
|
|
101
|
-
let fromState: State | undefined;
|
|
102
|
-
let transitionStarted = false;
|
|
103
|
-
let controller: AbortController | null = null;
|
|
104
101
|
|
|
105
102
|
try {
|
|
106
103
|
toState = deps.buildNavigateState(name, params);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
/* v8 ignore next 3 -- @preserve: reachable only via validator-driven
|
|
106
|
+
throws from buildNavigateState (validateStateBuilderArgs) — covered
|
|
107
|
+
in @real-router/validation-plugin's suite, not in core. */
|
|
108
|
+
return Promise.reject(error as Error);
|
|
109
|
+
}
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
if (!toState) {
|
|
112
|
+
deps.emitTransitionError(
|
|
113
|
+
undefined,
|
|
114
|
+
deps.getState(),
|
|
115
|
+
CACHED_ROUTE_NOT_FOUND_ERROR,
|
|
116
|
+
);
|
|
117
|
+
this.lastSyncRejected = true;
|
|
115
118
|
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
return CACHED_ROUTE_NOT_FOUND_REJECTION;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return this.#executeNavigation(toState, opts);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Navigate to a fully-built `State` directly, skipping `buildNavigateState`
|
|
127
|
+
* (forwardState + buildPath + meta lookup). Used by URL plugins after they
|
|
128
|
+
* have already produced a `State` from a browser-initiated event via
|
|
129
|
+
* `api.matchPath(url)` — see issue #525.
|
|
130
|
+
*
|
|
131
|
+
* Semantics vs. `navigate(name, params, opts)`:
|
|
132
|
+
* - `forwardState` is NOT re-applied. matchPath already runs it; reapplying
|
|
133
|
+
* is redundant in the idempotent case and can race in the dynamic case.
|
|
134
|
+
* - `buildPath` is NOT re-run. The caller's `state.path` is used as-is —
|
|
135
|
+
* so `trailingSlash:"preserve"` matchedState paths flow through unchanged
|
|
136
|
+
* (closes #525 Q2). `buildPath` interceptors do NOT run; the URL the
|
|
137
|
+
* user navigated to is the source of truth for this code path.
|
|
138
|
+
* - All other pipeline steps run unchanged: SAME_STATES check, FSM
|
|
139
|
+
* transition, guards, `subscribeLeave`, `completeTransition`,
|
|
140
|
+
* plugin lifecycle hooks.
|
|
141
|
+
*/
|
|
142
|
+
navigateToState(state: State, opts: NavigationOptions): Promise<State> {
|
|
143
|
+
this.lastSyncResolved = false;
|
|
144
|
+
const deps = this.#deps;
|
|
145
|
+
|
|
146
|
+
if (!deps.canNavigate()) {
|
|
147
|
+
this.lastSyncRejected = true;
|
|
118
148
|
|
|
149
|
+
return CACHED_NOT_STARTED_REJECTION;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Reject states whose route no longer exists (e.g. the route tree was
|
|
153
|
+
// mutated between matchPath and navigateToState). UNKNOWN_ROUTE is
|
|
154
|
+
// structurally legal — it is the navigateToNotFound output shape.
|
|
155
|
+
if (state.name !== constants.UNKNOWN_ROUTE && !deps.hasRoute(state.name)) {
|
|
156
|
+
const err = new RouterError(errorCodes.ROUTE_NOT_FOUND, {
|
|
157
|
+
routeName: state.name,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
deps.emitTransitionError(undefined, deps.getState(), err);
|
|
161
|
+
this.lastSyncRejected = true;
|
|
162
|
+
|
|
163
|
+
return Promise.reject(err);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// States from `matchPath` are deeply frozen (`freezeStateInPlace`).
|
|
167
|
+
// `completeTransition` mutates `toState.transition` and `context` is
|
|
168
|
+
// intentionally extensible for plugin claim writes, so we hand the
|
|
169
|
+
// pipeline a writable shell — same shape `makeState(skipFreeze=true)`
|
|
170
|
+
// produces. `params` stays referentially shared (already frozen).
|
|
171
|
+
// `transition` is omitted so completeTransition can assign it.
|
|
172
|
+
const writableState = {
|
|
173
|
+
name: state.name,
|
|
174
|
+
params: state.params,
|
|
175
|
+
path: state.path,
|
|
176
|
+
context: { ...state.context },
|
|
177
|
+
} as State;
|
|
178
|
+
|
|
179
|
+
return this.#executeNavigation(writableState, opts);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
navigateToDefault(opts: NavigationOptions): Promise<State> {
|
|
183
|
+
const deps = this.#deps;
|
|
184
|
+
const options = deps.getOptions();
|
|
185
|
+
|
|
186
|
+
if (!options.defaultRoute) {
|
|
187
|
+
return Promise.reject(
|
|
188
|
+
new RouterError(errorCodes.ROUTE_NOT_FOUND, {
|
|
189
|
+
routeName: "defaultRoute not configured",
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let route: string;
|
|
195
|
+
let params: Params;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
({ route, params } = deps.resolveDefault());
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return Promise.reject(error as Error);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!route) {
|
|
204
|
+
return Promise.reject(
|
|
205
|
+
new RouterError(errorCodes.ROUTE_NOT_FOUND, {
|
|
206
|
+
routeName: "defaultRoute resolved to empty",
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return this.navigate(route, params, opts);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
navigateToNotFound(path: string): State {
|
|
215
|
+
this.#abortPreviousNavigation();
|
|
216
|
+
|
|
217
|
+
const fromState = this.#deps.getState();
|
|
218
|
+
const deactivated: string[] = fromState
|
|
219
|
+
? nameToIDs(fromState.name).toReversed()
|
|
220
|
+
: [];
|
|
221
|
+
|
|
222
|
+
Object.freeze(deactivated);
|
|
223
|
+
|
|
224
|
+
const segments: TransitionMeta["segments"] = {
|
|
225
|
+
deactivated,
|
|
226
|
+
activated: FROZEN_ACTIVATED,
|
|
227
|
+
intersection: "",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
Object.freeze(segments);
|
|
231
|
+
|
|
232
|
+
const transitionMeta: TransitionMeta = {
|
|
233
|
+
phase: "activating",
|
|
234
|
+
...(fromState && { from: fromState.name }),
|
|
235
|
+
reason: "success",
|
|
236
|
+
segments,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
Object.freeze(transitionMeta);
|
|
240
|
+
|
|
241
|
+
const state: State = {
|
|
242
|
+
name: constants.UNKNOWN_ROUTE,
|
|
243
|
+
params: EMPTY_PARAMS as Params,
|
|
244
|
+
path,
|
|
245
|
+
transition: transitionMeta,
|
|
246
|
+
context: {},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
Object.freeze(state);
|
|
250
|
+
|
|
251
|
+
this.#deps.setState(state);
|
|
252
|
+
this.#deps.emitTransitionSuccess(state, fromState, FROZEN_REPLACE_OPTS);
|
|
253
|
+
|
|
254
|
+
return state;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
abortCurrentNavigation(): void {
|
|
258
|
+
this.#currentController?.abort(
|
|
259
|
+
new RouterError(errorCodes.TRANSITION_CANCELLED),
|
|
260
|
+
);
|
|
261
|
+
this.#currentController = null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#executeNavigation(toState: State, opts: NavigationOptions): Promise<State> {
|
|
265
|
+
const deps = this.#deps;
|
|
266
|
+
let fromState: State | undefined;
|
|
267
|
+
let transitionStarted = false;
|
|
268
|
+
let controller: AbortController | null = null;
|
|
269
|
+
|
|
270
|
+
try {
|
|
119
271
|
fromState = deps.getState();
|
|
120
272
|
opts = forceReplaceFromUnknown(opts, fromState);
|
|
121
273
|
|
|
@@ -256,88 +408,6 @@ export class NavigationNamespace {
|
|
|
256
408
|
}
|
|
257
409
|
}
|
|
258
410
|
|
|
259
|
-
navigateToDefault(opts: NavigationOptions): Promise<State> {
|
|
260
|
-
const deps = this.#deps;
|
|
261
|
-
const options = deps.getOptions();
|
|
262
|
-
|
|
263
|
-
if (!options.defaultRoute) {
|
|
264
|
-
return Promise.reject(
|
|
265
|
-
new RouterError(errorCodes.ROUTE_NOT_FOUND, {
|
|
266
|
-
routeName: "defaultRoute not configured",
|
|
267
|
-
}),
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
let route: string;
|
|
272
|
-
let params: Params;
|
|
273
|
-
|
|
274
|
-
try {
|
|
275
|
-
({ route, params } = deps.resolveDefault());
|
|
276
|
-
} catch (error) {
|
|
277
|
-
return Promise.reject(error as Error);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (!route) {
|
|
281
|
-
return Promise.reject(
|
|
282
|
-
new RouterError(errorCodes.ROUTE_NOT_FOUND, {
|
|
283
|
-
routeName: "defaultRoute resolved to empty",
|
|
284
|
-
}),
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return this.navigate(route, params, opts);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
navigateToNotFound(path: string): State {
|
|
292
|
-
this.#abortPreviousNavigation();
|
|
293
|
-
|
|
294
|
-
const fromState = this.#deps.getState();
|
|
295
|
-
const deactivated: string[] = fromState
|
|
296
|
-
? nameToIDs(fromState.name).toReversed()
|
|
297
|
-
: [];
|
|
298
|
-
|
|
299
|
-
Object.freeze(deactivated);
|
|
300
|
-
|
|
301
|
-
const segments: TransitionMeta["segments"] = {
|
|
302
|
-
deactivated,
|
|
303
|
-
activated: FROZEN_ACTIVATED,
|
|
304
|
-
intersection: "",
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
Object.freeze(segments);
|
|
308
|
-
|
|
309
|
-
const transitionMeta: TransitionMeta = {
|
|
310
|
-
phase: "activating",
|
|
311
|
-
...(fromState && { from: fromState.name }),
|
|
312
|
-
reason: "success",
|
|
313
|
-
segments,
|
|
314
|
-
};
|
|
315
|
-
|
|
316
|
-
Object.freeze(transitionMeta);
|
|
317
|
-
|
|
318
|
-
const state: State = {
|
|
319
|
-
name: constants.UNKNOWN_ROUTE,
|
|
320
|
-
params: EMPTY_PARAMS as Params,
|
|
321
|
-
path,
|
|
322
|
-
transition: transitionMeta,
|
|
323
|
-
context: {},
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
Object.freeze(state);
|
|
327
|
-
|
|
328
|
-
this.#deps.setState(state);
|
|
329
|
-
this.#deps.emitTransitionSuccess(state, fromState, FROZEN_REPLACE_OPTS);
|
|
330
|
-
|
|
331
|
-
return state;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
abortCurrentNavigation(): void {
|
|
335
|
-
this.#currentController?.abort(
|
|
336
|
-
new RouterError(errorCodes.TRANSITION_CANCELLED),
|
|
337
|
-
);
|
|
338
|
-
this.#currentController = null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
411
|
async #finishAsyncNavigation(
|
|
342
412
|
guardCompletion: Promise<void>,
|
|
343
413
|
nav: NavigationContext,
|
|
@@ -60,11 +60,11 @@ export class RouterLifecycleNamespace {
|
|
|
60
60
|
deps.completeStart();
|
|
61
61
|
|
|
62
62
|
if (matchedState) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
);
|
|
63
|
+
// navigateToState commits matchedState verbatim — same primitive URL
|
|
64
|
+
// plugins use on popstate / navigate-event (#525). Keeps trailing-slash
|
|
65
|
+
// and any other source-URL flavor that matchPath produced; skips the
|
|
66
|
+
// redundant forwardState+buildPath round-trip in buildNavigateState.
|
|
67
|
+
return deps.navigateToState(matchedState, REPLACE_OPTS);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
return deps.navigateToNotFound(startPath);
|
|
@@ -9,11 +9,15 @@ import type {
|
|
|
9
9
|
|
|
10
10
|
export interface RouterLifecycleDependencies {
|
|
11
11
|
getOptions: () => Options;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Commit a fully-resolved State without re-running `forwardState`/`buildPath`.
|
|
14
|
+
* `start(path)` uses this to commit `matchPath(path)` directly — the same
|
|
15
|
+
* primitive URL plugins use on popstate / navigate-event (#525). Keeps
|
|
16
|
+
* `state.path` identical to the source URL (preserves trailing slash in
|
|
17
|
+
* `trailingSlash:"preserve"` mode) and avoids the redundant
|
|
18
|
+
* forwardState+buildPath round-trip in `buildNavigateState`.
|
|
19
|
+
*/
|
|
20
|
+
navigateToState: (state: State, opts: NavigationOptions) => Promise<State>;
|
|
17
21
|
navigateToNotFound: (path: string) => State;
|
|
18
22
|
clearState: () => void;
|
|
19
23
|
matchPath: <P extends Params = Params>(path: string) => State<P> | undefined;
|
|
@@ -124,6 +124,7 @@ export interface RouterValidator {
|
|
|
124
124
|
navigation: {
|
|
125
125
|
validateNavigateArgs: (name: unknown) => void;
|
|
126
126
|
validateNavigateToDefaultArgs: (options: unknown) => void;
|
|
127
|
+
validateNavigateToStateArgs: (state: unknown) => void;
|
|
127
128
|
validateNavigationOptions: (options: unknown, caller: string) => void;
|
|
128
129
|
validateParams: (params: unknown, methodName: string) => void;
|
|
129
130
|
validateStartArgs: (path: unknown) => void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Router, State } from "@real-router/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hydrate a fresh router from server-serialized State (#563).
|
|
5
|
+
*
|
|
6
|
+
* Accepts either a JSON string (parsed via `JSON.parse`) or a State-shaped
|
|
7
|
+
* object. Extracts `state.path` and delegates to `router.start(state.path)` —
|
|
8
|
+
* the canonical URL is the source of truth for the router on hydration.
|
|
9
|
+
*
|
|
10
|
+
* The serialized State (produced by `serializeRouterState`) is still useful
|
|
11
|
+
* for application-level concerns: `state.context.<namespace>` payloads (e.g.
|
|
12
|
+
* server-side data from `ssr-data-plugin`) can be read separately by app code
|
|
13
|
+
* before or after `hydrateRouter` resolves.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* // Client
|
|
18
|
+
* const router = createAppRouter();
|
|
19
|
+
* router.usePlugin(browserPluginFactory());
|
|
20
|
+
* await hydrateRouter(router, window.__SSR_STATE__);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function hydrateRouter(
|
|
24
|
+
router: Router,
|
|
25
|
+
source: string | { path: string },
|
|
26
|
+
): Promise<State> {
|
|
27
|
+
const parsed =
|
|
28
|
+
typeof source === "string"
|
|
29
|
+
? (JSON.parse(source) as { path: string })
|
|
30
|
+
: source;
|
|
31
|
+
|
|
32
|
+
return router.start(parsed.path);
|
|
33
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { getStaticPaths } from "./getStaticPaths";
|
|
2
2
|
|
|
3
|
+
export { hydrateRouter } from "./hydrateRouter";
|
|
4
|
+
|
|
5
|
+
export { serializeRouterState } from "./serializeRouterState";
|
|
6
|
+
|
|
7
|
+
export type { SerializeRouterStateOptions } from "./serializeRouterState";
|
|
8
|
+
|
|
3
9
|
export { serializeState } from "./serializeState";
|
|
4
10
|
|
|
5
11
|
export type { StaticPathEntries } from "./getStaticPaths";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { serializeState } from "./serializeState";
|
|
2
|
+
|
|
3
|
+
import type { State } from "@real-router/types";
|
|
4
|
+
|
|
5
|
+
export interface SerializeRouterStateOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Plugin context namespaces to strip from the serialized output.
|
|
8
|
+
* Use when a plugin populates `state.context.<ns>` with non-JSON-serializable
|
|
9
|
+
* values (e.g., RSC payload: ReactNode trees containing functions/symbols).
|
|
10
|
+
*
|
|
11
|
+
* @default []
|
|
12
|
+
*/
|
|
13
|
+
excludeContext?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* XSS-safe JSON serialization of router State for SSR → client transport (#563).
|
|
18
|
+
*
|
|
19
|
+
* Strips `state.transition` (per-navigation `TransitionMeta` — meaningless after
|
|
20
|
+
* hydration; the client's hydration commit produces its own `transition`).
|
|
21
|
+
* Keeps `name`, `params`, `path`, and `context` (plugin context namespaces are
|
|
22
|
+
* preserved as-is — server's `state.context.data` from `ssr-data-plugin` and
|
|
23
|
+
* any other plugin claims travel to the client untouched).
|
|
24
|
+
*
|
|
25
|
+
* Pass `options.excludeContext` to strip specific namespaces from the output —
|
|
26
|
+
* required for plugins that publish non-JSON-serializable values (e.g., RSC
|
|
27
|
+
* `ReactNode` trees from `@real-router/rsc-server-plugin`).
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* // Server
|
|
32
|
+
* const state = await router.start(req.url);
|
|
33
|
+
* const html = `<script>window.__SSR_STATE__=${serializeRouterState(state)}</script>`;
|
|
34
|
+
*
|
|
35
|
+
* // Client
|
|
36
|
+
* await hydrateRouter(router, window.__SSR_STATE__);
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* // With RSC plugin: strip the "rsc" namespace before transport
|
|
42
|
+
* const state = await router.start(url);
|
|
43
|
+
* const json = serializeRouterState(state, { excludeContext: ["rsc"] });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function serializeRouterState(
|
|
47
|
+
state: State,
|
|
48
|
+
options?: SerializeRouterStateOptions,
|
|
49
|
+
): string {
|
|
50
|
+
const exclude = options?.excludeContext;
|
|
51
|
+
|
|
52
|
+
let context = state.context;
|
|
53
|
+
|
|
54
|
+
if (exclude?.length) {
|
|
55
|
+
const filtered: Record<string, unknown> = {};
|
|
56
|
+
const source = state.context as Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
for (const key of Object.keys(source)) {
|
|
59
|
+
if (!exclude.includes(key)) {
|
|
60
|
+
filtered[key] = source[key];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
context = filtered;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return serializeState({
|
|
68
|
+
name: state.name,
|
|
69
|
+
params: state.params,
|
|
70
|
+
path: state.path,
|
|
71
|
+
context,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -225,8 +225,8 @@ export class RouterWiringBuilder<
|
|
|
225
225
|
wireLifecycleDeps(): void {
|
|
226
226
|
const lifecycleDeps: RouterLifecycleDependencies = {
|
|
227
227
|
getOptions: () => this.options.get(),
|
|
228
|
-
|
|
229
|
-
this.navigation.
|
|
228
|
+
navigateToState: (state, opts) =>
|
|
229
|
+
this.navigation.navigateToState(state, opts),
|
|
230
230
|
navigateToNotFound: (path) => this.navigation.navigateToNotFound(path),
|
|
231
231
|
clearState: () => {
|
|
232
232
|
this.state.set(undefined);
|