@jsenv/navi 0.29.100 → 0.29.101
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/jsenv_navi.js +306 -13
- package/dist/jsenv_navi.js.map +4 -4
- package/docs/navigation.md +127 -0
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -2819,7 +2819,10 @@ const readSignalForUrlBuild = (connection) => {
|
|
|
2819
2819
|
/**
|
|
2820
2820
|
* Creates a custom route pattern matcher
|
|
2821
2821
|
*/
|
|
2822
|
-
const createRoutePattern = (
|
|
2822
|
+
const createRoutePattern = (
|
|
2823
|
+
pattern,
|
|
2824
|
+
{ searchParams = {}, params: paramConstraints = {} } = {},
|
|
2825
|
+
) => {
|
|
2823
2826
|
// Detect and process path signals in the pattern
|
|
2824
2827
|
const [cleanPattern, pathConnections] = detectSignals(pattern);
|
|
2825
2828
|
|
|
@@ -2844,9 +2847,11 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
|
|
|
2844
2847
|
// All connections (path + query) for ancestor/descendant signal resolution
|
|
2845
2848
|
const connections = [...pathConnections, ...queryConnectionMap.values()];
|
|
2846
2849
|
|
|
2850
|
+
const paramConstraintMap = createParamConstraintMap(paramConstraints);
|
|
2847
2851
|
const parsedPattern = parsePattern(cleanPattern, {
|
|
2848
2852
|
pathConnectionMap,
|
|
2849
2853
|
queryConnectionMap,
|
|
2854
|
+
paramConstraintMap,
|
|
2850
2855
|
});
|
|
2851
2856
|
|
|
2852
2857
|
debug$3(`[CustomPattern] Created pattern:`, parsedPattern);
|
|
@@ -2854,11 +2859,21 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
|
|
|
2854
2859
|
debug$3(`[CustomPattern] Path connections:`, pathConnectionMap.size);
|
|
2855
2860
|
debug$3(`[CustomPattern] Query connections:`, queryConnectionMap.size);
|
|
2856
2861
|
|
|
2857
|
-
|
|
2862
|
+
/**
|
|
2863
|
+
* @param {string} url
|
|
2864
|
+
* @param {object} [options]
|
|
2865
|
+
* @param {boolean} [options.exact] - Only match the url this pattern is the
|
|
2866
|
+
* address of: a trailing slash or a wildcard stops catching what lies below
|
|
2867
|
+
* it. What "/" means for a container ("everything under me") and what it
|
|
2868
|
+
* means for a redirection ("that very address") are not the same question.
|
|
2869
|
+
*/
|
|
2870
|
+
const applyOn = (url, { exact = false } = {}) => {
|
|
2858
2871
|
const result = matchUrl(parsedPattern, url, {
|
|
2859
2872
|
baseUrl,
|
|
2860
2873
|
baseFileUrl,
|
|
2861
2874
|
queryConnectionMap,
|
|
2875
|
+
paramConstraintMap,
|
|
2876
|
+
exact,
|
|
2862
2877
|
patternObj: patternObject,
|
|
2863
2878
|
});
|
|
2864
2879
|
|
|
@@ -3524,7 +3539,9 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
|
|
|
3524
3539
|
const entryIsMeaningful = (conn) => {
|
|
3525
3540
|
const entry = intended.get(conn.paramName);
|
|
3526
3541
|
return (
|
|
3527
|
-
Boolean(entry) &&
|
|
3542
|
+
Boolean(entry) &&
|
|
3543
|
+
entry.value !== undefined &&
|
|
3544
|
+
conn.isCustomValue(entry.value)
|
|
3528
3545
|
);
|
|
3529
3546
|
};
|
|
3530
3547
|
if (ancestorPattern !== patternObject.parent) {
|
|
@@ -3714,6 +3731,7 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
|
|
|
3714
3731
|
connections,
|
|
3715
3732
|
pathConnectionMap, // Separate map for path parameters
|
|
3716
3733
|
queryConnectionMap, // Separate map for query parameters
|
|
3734
|
+
paramConstraintMap, // Which values each param accepts (Map<paramName, test>)
|
|
3717
3735
|
parsedPattern,
|
|
3718
3736
|
children: [],
|
|
3719
3737
|
parent: null,
|
|
@@ -3821,10 +3839,60 @@ const detectSignals = (routePattern) => {
|
|
|
3821
3839
|
return [updatedPattern, signalConnections];
|
|
3822
3840
|
};
|
|
3823
3841
|
|
|
3842
|
+
const EMPTY_PARAM_CONSTRAINT_MAP = new Map();
|
|
3843
|
+
|
|
3844
|
+
/**
|
|
3845
|
+
* Turns `{ gameId: /^W-[A-Z0-9]{8}$/i }` into `Map<paramName, (value) => boolean>`.
|
|
3846
|
+
* A constraint is a regexp, a list of accepted values, or a predicate.
|
|
3847
|
+
* A constraint decides which url segments the param accepts; a segment it
|
|
3848
|
+
* declines makes the whole route a non-match, so only path params can carry
|
|
3849
|
+
* one — a search param is extracted from a url the path already matched.
|
|
3850
|
+
*/
|
|
3851
|
+
const createParamConstraintMap = (paramConstraints) => {
|
|
3852
|
+
const entries = Object.entries(paramConstraints);
|
|
3853
|
+
if (entries.length === 0) {
|
|
3854
|
+
return EMPTY_PARAM_CONSTRAINT_MAP;
|
|
3855
|
+
}
|
|
3856
|
+
const paramConstraintMap = new Map();
|
|
3857
|
+
for (const [paramName, constraint] of entries) {
|
|
3858
|
+
if (constraint instanceof RegExp) {
|
|
3859
|
+
paramConstraintMap.set(paramName, (value) => {
|
|
3860
|
+
constraint.lastIndex = 0; // a /g or /y regexp would otherwise resume where the previous test stopped
|
|
3861
|
+
return constraint.test(value);
|
|
3862
|
+
});
|
|
3863
|
+
continue;
|
|
3864
|
+
}
|
|
3865
|
+
if (Array.isArray(constraint)) {
|
|
3866
|
+
// a url segment is a string, so the list is compared as strings:
|
|
3867
|
+
// `oneOf: [1, 2, 3]` accepts "/2"
|
|
3868
|
+
const acceptedValueSet = new Set(
|
|
3869
|
+
constraint.map((value) => String(value)),
|
|
3870
|
+
);
|
|
3871
|
+
paramConstraintMap.set(paramName, (value) => acceptedValueSet.has(value));
|
|
3872
|
+
continue;
|
|
3873
|
+
}
|
|
3874
|
+
if (typeof constraint === "function") {
|
|
3875
|
+
paramConstraintMap.set(paramName, (value) => Boolean(constraint(value)));
|
|
3876
|
+
continue;
|
|
3877
|
+
}
|
|
3878
|
+
throw new TypeError(
|
|
3879
|
+
`params.${paramName} must be a regexp, an array of values or a function, got ${constraint}`,
|
|
3880
|
+
);
|
|
3881
|
+
}
|
|
3882
|
+
return paramConstraintMap;
|
|
3883
|
+
};
|
|
3884
|
+
|
|
3824
3885
|
/**
|
|
3825
3886
|
* Parse a route pattern string into structured segments
|
|
3826
3887
|
*/
|
|
3827
|
-
const parsePattern = (
|
|
3888
|
+
const parsePattern = (
|
|
3889
|
+
pattern,
|
|
3890
|
+
{
|
|
3891
|
+
pathConnectionMap,
|
|
3892
|
+
queryConnectionMap,
|
|
3893
|
+
paramConstraintMap = EMPTY_PARAM_CONSTRAINT_MAP,
|
|
3894
|
+
},
|
|
3895
|
+
) => {
|
|
3828
3896
|
// Build queryParams from queryConnectionMap
|
|
3829
3897
|
const queryParams = [];
|
|
3830
3898
|
for (const [paramName, connection] of queryConnectionMap) {
|
|
@@ -3878,13 +3946,14 @@ const parsePattern = (pattern, { pathConnectionMap, queryConnectionMap }) => {
|
|
|
3878
3946
|
// 1. Explicitly marked with ?
|
|
3879
3947
|
// 2. Has a default value
|
|
3880
3948
|
// 3. Connected signal has undefined value and no explicit default (allows /map to match /map/:panel)
|
|
3949
|
+
// — unless the param is constrained: "no segment" is not one of the values it accepts
|
|
3881
3950
|
const connection =
|
|
3882
3951
|
pathConnectionMap.get(paramName) || queryConnectionMap.get(paramName);
|
|
3883
3952
|
const hasDefault =
|
|
3884
3953
|
connection && connection.getDefaultValue() !== undefined;
|
|
3885
3954
|
let isOptional = seg.endsWith("?") || hasDefault;
|
|
3886
3955
|
|
|
3887
|
-
if (!isOptional) {
|
|
3956
|
+
if (!isOptional && !paramConstraintMap.has(paramName)) {
|
|
3888
3957
|
// Check if connected signal has undefined value (making parameter optional for index routes)
|
|
3889
3958
|
if (
|
|
3890
3959
|
connection &&
|
|
@@ -3995,7 +4064,14 @@ const tryExtractChildParameters = (
|
|
|
3995
4064
|
// We need to verify that this parameter isn't already captured by parent
|
|
3996
4065
|
if (!(segment.name in existingParams)) {
|
|
3997
4066
|
const urlSegment = remainingSegments[remainingIndex];
|
|
3998
|
-
|
|
4067
|
+
const paramValue = decodeURIComponent(urlSegment);
|
|
4068
|
+
const paramConstraint = childPattern.paramConstraintMap.get(
|
|
4069
|
+
segment.name,
|
|
4070
|
+
);
|
|
4071
|
+
if (paramConstraint && !paramConstraint(paramValue)) {
|
|
4072
|
+
return null; // the child declines this value, it cannot explain these segments
|
|
4073
|
+
}
|
|
4074
|
+
childParams[segment.name] = paramValue;
|
|
3999
4075
|
remainingIndex++;
|
|
4000
4076
|
}
|
|
4001
4077
|
} else if (
|
|
@@ -4022,7 +4098,14 @@ const tryExtractChildParameters = (
|
|
|
4022
4098
|
const matchUrl = (
|
|
4023
4099
|
parsedPattern,
|
|
4024
4100
|
url,
|
|
4025
|
-
{
|
|
4101
|
+
{
|
|
4102
|
+
baseUrl,
|
|
4103
|
+
baseFileUrl,
|
|
4104
|
+
queryConnectionMap,
|
|
4105
|
+
paramConstraintMap = EMPTY_PARAM_CONSTRAINT_MAP,
|
|
4106
|
+
exact = false,
|
|
4107
|
+
patternObj = null,
|
|
4108
|
+
},
|
|
4026
4109
|
) => {
|
|
4027
4110
|
// Parse the URL
|
|
4028
4111
|
const urlObj = new URL(url, baseUrl);
|
|
@@ -4064,7 +4147,7 @@ const matchUrl = (
|
|
|
4064
4147
|
}
|
|
4065
4148
|
|
|
4066
4149
|
// Root route with trailing slash matches all sub-paths (prefix matching, like other trailing-slash routes)
|
|
4067
|
-
if (parsedPattern.trailingSlash) {
|
|
4150
|
+
if (parsedPattern.trailingSlash && !exact) {
|
|
4068
4151
|
return extractSearchParams(urlObj, queryConnectionMap);
|
|
4069
4152
|
}
|
|
4070
4153
|
|
|
@@ -4133,7 +4216,12 @@ const matchUrl = (
|
|
|
4133
4216
|
|
|
4134
4217
|
// Capture URL segment as parameter value
|
|
4135
4218
|
const urlSeg = urlSegments[urlSegmentIndex];
|
|
4136
|
-
|
|
4219
|
+
const paramValue = decodeURIComponent(urlSeg);
|
|
4220
|
+
const paramConstraint = paramConstraintMap.get(patternSeg.name);
|
|
4221
|
+
if (paramConstraint && !paramConstraint(paramValue)) {
|
|
4222
|
+
return null; // the param declines this value: the route does not match
|
|
4223
|
+
}
|
|
4224
|
+
params[patternSeg.name] = paramValue;
|
|
4137
4225
|
urlSegmentIndex++;
|
|
4138
4226
|
}
|
|
4139
4227
|
}
|
|
@@ -4142,8 +4230,7 @@ const matchUrl = (
|
|
|
4142
4230
|
// Patterns with trailing slashes can match additional URL segments (like wildcards)
|
|
4143
4231
|
// Patterns without trailing slashes should match exactly (unless they're wildcards)
|
|
4144
4232
|
if (
|
|
4145
|
-
!parsedPattern.wildcard &&
|
|
4146
|
-
!parsedPattern.trailingSlash &&
|
|
4233
|
+
(exact || (!parsedPattern.wildcard && !parsedPattern.trailingSlash)) &&
|
|
4147
4234
|
urlSegmentIndex < urlSegments.length
|
|
4148
4235
|
) {
|
|
4149
4236
|
return null; // Pattern without trailing slash/wildcard should not match extra segments
|
|
@@ -4862,8 +4949,61 @@ const getRoutePrivateProperties = (route) => {
|
|
|
4862
4949
|
const ROUTE_NOT_MATCHING_PARAMS = {};
|
|
4863
4950
|
// Flag to prevent signal-to-URL synchronization during URL-to-signal synchronization
|
|
4864
4951
|
let isUpdatingRoutesFromUrl = false;
|
|
4865
|
-
|
|
4866
|
-
|
|
4952
|
+
/**
|
|
4953
|
+
* Declares a route from an url pattern.
|
|
4954
|
+
*
|
|
4955
|
+
* @param {string} pattern - The url pattern, where `:name` is a path param and
|
|
4956
|
+
* `:name=${signal}` binds that param to a signal.
|
|
4957
|
+
* @param {object} [options]
|
|
4958
|
+
* @param {Object<string, import("@preact/signals").Signal>} [options.searchParams]
|
|
4959
|
+
* Search params this route two-way syncs with, by name.
|
|
4960
|
+
* @param {Object<string, RegExp | Array | ((value: string) => boolean)>} [options.params]
|
|
4961
|
+
* Which values a path param accepts, by param name: a regexp tested against the
|
|
4962
|
+
* decoded segment, the list of accepted values (compared as strings, so it can
|
|
4963
|
+
* be the `oneOf` of the signal bound to that param), or a predicate. A route
|
|
4964
|
+
* whose param declines the segment does not match at all: no signal is
|
|
4965
|
+
* written, `<Route fallback>` is reachable, and `/:gameId` can sit at the root
|
|
4966
|
+
* without swallowing `/cgu`. A constrained param is also required — no segment
|
|
4967
|
+
* is not one of the values it accepts — so `/:gameId` does not match `/`.
|
|
4968
|
+
*
|
|
4969
|
+
* Constrain the shape, not the existence: whether that game exists is for the
|
|
4970
|
+
* route action and the page to answer, on a route that did match.
|
|
4971
|
+
*
|
|
4972
|
+
* ```js
|
|
4973
|
+
* route(`/:gameId=${gamePageIdSignal}`, { params: { gameId: /^W-[A-Z0-9]{8}$/i } });
|
|
4974
|
+
* route(`/games/:section=${sectionSignal}`, { params: { section: SECTIONS } });
|
|
4975
|
+
* ```
|
|
4976
|
+
* @param {object} [options.redirectRoute]
|
|
4977
|
+
* This address only sends elsewhere: the route it resolves to. The
|
|
4978
|
+
* redirection happens at the door of the navigation, so this address is never
|
|
4979
|
+
* displayed, never enters the history, runs no route action and writes no
|
|
4980
|
+
* signal — going back lands on the page before it, not on the redirection
|
|
4981
|
+
* again. It fires on this route's own address only: a trailing slash still
|
|
4982
|
+
* catches what lies below it for rendering, never for redirecting.
|
|
4983
|
+
*
|
|
4984
|
+
* The params found in the url carry over to the ones the target route
|
|
4985
|
+
* declares under the same name; what it cannot place is left behind.
|
|
4986
|
+
*
|
|
4987
|
+
* ```js
|
|
4988
|
+
* route("/", { redirectRoute: MY_GAMES_PAGE });
|
|
4989
|
+
* // gameId carries over on its own, shareState is not a param of GAME_PAGE
|
|
4990
|
+
* route("/:gameId/:shareState", { redirectRoute: GAME_PAGE });
|
|
4991
|
+
* ```
|
|
4992
|
+
* @param {object|Function|null} [options.redirectRouteParams]
|
|
4993
|
+
* What to change about the params carried over: an object, or a function of
|
|
4994
|
+
* the params found in the url returning one. Written params win over inherited
|
|
4995
|
+
* ones, `undefined` drops one, and `null` carries nothing over at all.
|
|
4996
|
+
*
|
|
4997
|
+
* ```js
|
|
4998
|
+
* route("/legacy/:id", { redirectRoute: GAME_PAGE, redirectRouteParams: ({ id }) => ({ gameId: id }) });
|
|
4999
|
+
* route("/:gameId/invite", { redirectRoute: HOME_PAGE, redirectRouteParams: null });
|
|
5000
|
+
* ```
|
|
5001
|
+
*/
|
|
5002
|
+
const route = (
|
|
5003
|
+
pattern,
|
|
5004
|
+
{ searchParams, params, redirectRoute, redirectRouteParams } = {},
|
|
5005
|
+
) => {
|
|
5006
|
+
const routePattern = createRoutePattern(pattern, { searchParams, params });
|
|
4867
5007
|
const { cleanPattern } = routePattern;
|
|
4868
5008
|
const [publishStatus, subscribeStatus] = createPubSub();
|
|
4869
5009
|
|
|
@@ -4906,6 +5046,8 @@ const route = (pattern, { searchParams } = {}) => {
|
|
|
4906
5046
|
{
|
|
4907
5047
|
const routePrivateProperties = {
|
|
4908
5048
|
routePattern,
|
|
5049
|
+
redirectRoute,
|
|
5050
|
+
redirectRouteParams,
|
|
4909
5051
|
setup: null,
|
|
4910
5052
|
updateStatus: null,
|
|
4911
5053
|
cleanup: null,
|
|
@@ -5229,6 +5371,113 @@ const route = (pattern, { searchParams } = {}) => {
|
|
|
5229
5371
|
|
|
5230
5372
|
const [publishRouteMutations, observeRouteMutations] = createPubSub();
|
|
5231
5373
|
|
|
5374
|
+
let redirectingRouteSet = null;
|
|
5375
|
+
/**
|
|
5376
|
+
* Where does this url really lead?
|
|
5377
|
+
*
|
|
5378
|
+
* Asked at the door of every navigation, before the url is written anywhere —
|
|
5379
|
+
* an address that only sends elsewhere must not become a page, an entry in the
|
|
5380
|
+
* history, or a route anything can see matching. Answered without reading a
|
|
5381
|
+
* single signal: the url alone says it.
|
|
5382
|
+
*
|
|
5383
|
+
* The chain is followed here rather than by letting each redirection navigate
|
|
5384
|
+
* in turn, so one navigation happens and the addresses in between leave no
|
|
5385
|
+
* trace at all.
|
|
5386
|
+
*
|
|
5387
|
+
* @param {string} url
|
|
5388
|
+
* @returns {string|null} The url to go to instead, or null.
|
|
5389
|
+
*/
|
|
5390
|
+
const resolveRouteRedirection = (url) => {
|
|
5391
|
+
if (!redirectingRouteSet || redirectingRouteSet.size === 0) {
|
|
5392
|
+
return null;
|
|
5393
|
+
}
|
|
5394
|
+
let urlToResolve = url;
|
|
5395
|
+
let redirectionUrl = null;
|
|
5396
|
+
const urlChain = [url];
|
|
5397
|
+
while (true) {
|
|
5398
|
+
const nextUrl = resolveRedirectionOnce(urlToResolve);
|
|
5399
|
+
if (!nextUrl || nextUrl === urlToResolve) {
|
|
5400
|
+
break;
|
|
5401
|
+
}
|
|
5402
|
+
if (urlChain.includes(nextUrl)) {
|
|
5403
|
+
throw new Error(
|
|
5404
|
+
`Redirection cycle: ${[...urlChain, nextUrl].join(" -> ")}`,
|
|
5405
|
+
);
|
|
5406
|
+
}
|
|
5407
|
+
urlChain.push(nextUrl);
|
|
5408
|
+
redirectionUrl = nextUrl;
|
|
5409
|
+
urlToResolve = nextUrl;
|
|
5410
|
+
}
|
|
5411
|
+
return redirectionUrl;
|
|
5412
|
+
};
|
|
5413
|
+
const resolveRedirectionOnce = (url) => {
|
|
5414
|
+
let redirectingRoute = null;
|
|
5415
|
+
let redirectingRouteParams = null;
|
|
5416
|
+
let maxDepth = -1;
|
|
5417
|
+
for (const route of redirectingRouteSet) {
|
|
5418
|
+
const { routePattern } = getRoutePrivateProperties(route);
|
|
5419
|
+
// exact: what a trailing slash catches is what a container renders, not
|
|
5420
|
+
// what an address redirects — "/" redirecting must not take "/cgu" with it
|
|
5421
|
+
const urlParams = routePattern.applyOn(url, { exact: true });
|
|
5422
|
+
if (!urlParams) {
|
|
5423
|
+
continue;
|
|
5424
|
+
}
|
|
5425
|
+
// Deeper = more specific, the reading the whole router shares (see
|
|
5426
|
+
// replaceParams): "/:gameId/invite" is a child of "/:gameId/:shareState",
|
|
5427
|
+
// so it answers for an url both of them match.
|
|
5428
|
+
if (routePattern.depth > maxDepth) {
|
|
5429
|
+
maxDepth = routePattern.depth;
|
|
5430
|
+
redirectingRoute = route;
|
|
5431
|
+
redirectingRouteParams = urlParams;
|
|
5432
|
+
}
|
|
5433
|
+
}
|
|
5434
|
+
if (!redirectingRoute) {
|
|
5435
|
+
return null;
|
|
5436
|
+
}
|
|
5437
|
+
return buildRedirectionUrl(redirectingRoute, redirectingRouteParams);
|
|
5438
|
+
};
|
|
5439
|
+
const buildRedirectionUrl = (route, urlParams) => {
|
|
5440
|
+
const { redirectRoute, redirectRouteParams } =
|
|
5441
|
+
getRoutePrivateProperties(route);
|
|
5442
|
+
const paramsCarriedOver =
|
|
5443
|
+
redirectRouteParams === null
|
|
5444
|
+
? {}
|
|
5445
|
+
: paramsTargetCanPlace(redirectRoute, urlParams);
|
|
5446
|
+
if (!redirectRouteParams) {
|
|
5447
|
+
return redirectRoute.buildUrl(paramsCarriedOver);
|
|
5448
|
+
}
|
|
5449
|
+
const paramsWritten =
|
|
5450
|
+
typeof redirectRouteParams === "function"
|
|
5451
|
+
? redirectRouteParams(urlParams)
|
|
5452
|
+
: redirectRouteParams;
|
|
5453
|
+
// A param written as undefined is a param dropped: `paramName in params` is
|
|
5454
|
+
// what tells a url build "this one is decided", value included (resolveParams)
|
|
5455
|
+
return redirectRoute.buildUrl({ ...paramsCarriedOver, ...paramsWritten });
|
|
5456
|
+
};
|
|
5457
|
+
|
|
5458
|
+
/**
|
|
5459
|
+
* The params of the url being left that the target route can put somewhere:
|
|
5460
|
+
* its own path segments and the search params it declares. What it cannot
|
|
5461
|
+
* place would otherwise be appended to the url as a query param, and a share
|
|
5462
|
+
* link resolving to "/W-ABC234PQ?shareState=1" is not the address anyone meant.
|
|
5463
|
+
*/
|
|
5464
|
+
const paramsTargetCanPlace = (redirectRoute, urlParams) => {
|
|
5465
|
+
const { routePattern } = getRoutePrivateProperties(redirectRoute);
|
|
5466
|
+
const { parsedPattern, queryConnectionMap } = routePattern;
|
|
5467
|
+
const paramsCarriedOver = {};
|
|
5468
|
+
for (const paramName of Object.keys(urlParams)) {
|
|
5469
|
+
const canPlace =
|
|
5470
|
+
queryConnectionMap.has(paramName) ||
|
|
5471
|
+
parsedPattern.segments.some(
|
|
5472
|
+
(segment) => segment.type === "param" && segment.name === paramName,
|
|
5473
|
+
);
|
|
5474
|
+
if (canPlace) {
|
|
5475
|
+
paramsCarriedOver[paramName] = urlParams[paramName];
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
return paramsCarriedOver;
|
|
5479
|
+
};
|
|
5480
|
+
|
|
5232
5481
|
let setupRoutesCalled = false;
|
|
5233
5482
|
const setupRoutes = (routes) => {
|
|
5234
5483
|
if (setupRoutesCalled) {
|
|
@@ -5258,6 +5507,22 @@ This prevents cross-test pollution and ensures clean state.`,
|
|
|
5258
5507
|
setup({ routeSet, getUrl });
|
|
5259
5508
|
}
|
|
5260
5509
|
|
|
5510
|
+
// Checked here rather than at declaration: a route may redirect to one
|
|
5511
|
+
// declared after it, and reading the target then would forbid that order.
|
|
5512
|
+
redirectingRouteSet = new Set();
|
|
5513
|
+
for (const route of routeSet) {
|
|
5514
|
+
const { redirectRoute } = getRoutePrivateProperties(route);
|
|
5515
|
+
if (!redirectRoute) {
|
|
5516
|
+
continue;
|
|
5517
|
+
}
|
|
5518
|
+
if (!redirectRoute.isRoute) {
|
|
5519
|
+
throw new TypeError(
|
|
5520
|
+
`${route} redirects to ${redirectRoute}, expecting a route object`,
|
|
5521
|
+
);
|
|
5522
|
+
}
|
|
5523
|
+
redirectingRouteSet.add(route);
|
|
5524
|
+
}
|
|
5525
|
+
|
|
5261
5526
|
// Store previous route states to detect changes
|
|
5262
5527
|
const routePreviousStateMap = new WeakMap();
|
|
5263
5528
|
const updateRoutes = (
|
|
@@ -5501,6 +5766,7 @@ This prevents cross-test pollution and ensures clean state.`,
|
|
|
5501
5766
|
routePrivatePropertiesMap.delete(route);
|
|
5502
5767
|
}
|
|
5503
5768
|
routeSet.clear();
|
|
5769
|
+
redirectingRouteSet = null;
|
|
5504
5770
|
setupRoutesCalled = false;
|
|
5505
5771
|
};
|
|
5506
5772
|
return {
|
|
@@ -21522,6 +21788,24 @@ const setupBrowserIntegrationViaHistory = ({
|
|
|
21522
21788
|
// the single door every navigation goes through, rather than by each caller
|
|
21523
21789
|
// (navBack's fallback in particular arrives here raw).
|
|
21524
21790
|
const url = new URL(target, window.location.href).href;
|
|
21791
|
+
// An address that only sends elsewhere never becomes anything here: asked
|
|
21792
|
+
// before the announcement, before the history write and before the routes,
|
|
21793
|
+
// so that what follows is entirely about where the reader is going (see
|
|
21794
|
+
// resolveRouteRedirection).
|
|
21795
|
+
const redirectionUrl = resolveRouteRedirection(url);
|
|
21796
|
+
if (redirectionUrl) {
|
|
21797
|
+
if (redirectionUrl === window.location.href) {
|
|
21798
|
+
// Asked to go where we already are: an address that redirects is never
|
|
21799
|
+
// the one being displayed, so there is nothing to go to and nothing to
|
|
21800
|
+
// stack on the history.
|
|
21801
|
+
return undefined;
|
|
21802
|
+
}
|
|
21803
|
+
return handleRoutingTask(redirectionUrl, {
|
|
21804
|
+
...options,
|
|
21805
|
+
reason: `${options.reason} (redirected from ${url})`,
|
|
21806
|
+
redirected: true,
|
|
21807
|
+
});
|
|
21808
|
+
}
|
|
21525
21809
|
// Decided before anything is announced: an elided push IS the traversal it
|
|
21526
21810
|
// becomes, and the traversal will make its own announcements when the
|
|
21527
21811
|
// browser answers — a before/after cycle here would be about a navigation
|
|
@@ -21570,6 +21854,7 @@ const setupBrowserIntegrationViaHistory = ({
|
|
|
21570
21854
|
reason,
|
|
21571
21855
|
navigationType, // "load", "reload", "replace", "push", "traverse"
|
|
21572
21856
|
state,
|
|
21857
|
+
redirected,
|
|
21573
21858
|
} = options;
|
|
21574
21859
|
|
|
21575
21860
|
// Where the entry being reached stands in this document's own stack —
|
|
@@ -21612,6 +21897,14 @@ const setupBrowserIntegrationViaHistory = ({
|
|
|
21612
21897
|
} else {
|
|
21613
21898
|
// traverse / reload: state comes from the history entry, no push/replace needed.
|
|
21614
21899
|
markUrlAsVisited(url);
|
|
21900
|
+
if (redirected) {
|
|
21901
|
+
// The entry the browser is on names an address that only sends
|
|
21902
|
+
// elsewhere — a cold load on it, or a back into it. Written over where
|
|
21903
|
+
// it stands (the entry keeps its place in the stack, hence its state
|
|
21904
|
+
// and the depth in it): pressing back must not walk into it again.
|
|
21905
|
+
window.history.replaceState(state, null, url);
|
|
21906
|
+
rememberEntryIsOfThisDocument();
|
|
21907
|
+
}
|
|
21615
21908
|
updateDocumentUrl(url);
|
|
21616
21909
|
updateDocumentState(state);
|
|
21617
21910
|
}
|