@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.
@@ -2819,7 +2819,10 @@ const readSignalForUrlBuild = (connection) => {
2819
2819
  /**
2820
2820
  * Creates a custom route pattern matcher
2821
2821
  */
2822
- const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
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
- const applyOn = (url) => {
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) && entry.value !== undefined && conn.isCustomValue(entry.value)
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 = (pattern, { pathConnectionMap, queryConnectionMap }) => {
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
- childParams[segment.name] = decodeURIComponent(urlSegment);
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
- { baseUrl, baseFileUrl, queryConnectionMap, patternObj = null },
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
- params[patternSeg.name] = decodeURIComponent(urlSeg);
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
- const route = (pattern, { searchParams } = {}) => {
4866
- const routePattern = createRoutePattern(pattern, { searchParams });
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
  }