@jsenv/navi 0.16.40 → 0.16.42

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.
@@ -1,7 +1,7 @@
1
1
  import { installImportMetaCss } from "./jsenv_navi_side_effects.js";
2
2
  import { useErrorBoundary, useLayoutEffect, useEffect, useRef, useState, useCallback, useContext, useMemo, useImperativeHandle, useId } from "preact/hooks";
3
3
  import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
4
- import { createIterableWeakSet, mergeOneStyle, stringifyStyle, createPubSub, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, resolveCSSSize, findBefore, findAfter, createValueEffect, createStyleController, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, allowWheelThrough, resolveCSSColor, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, hasCSSSizeUnit, activeElementSignal, canInterceptKeys, pickLightOrDark, resolveColorLuminance, initFocusGroup, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, elementIsFocusable } from "@jsenv/dom";
4
+ import { createIterableWeakSet, mergeOneStyle, stringifyStyle, createPubSub, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, resolveCSSSize, findBefore, findAfter, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, allowWheelThrough, resolveCSSColor, createStyleController, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, hasCSSSizeUnit, activeElementSignal, canInterceptKeys, pickLightOrDark, resolveColorLuminance, initFocusGroup, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, elementIsFocusable } from "@jsenv/dom";
5
5
  import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize";
6
6
  import { effect, signal, computed, batch, useSignal } from "@preact/signals";
7
7
  import { createValidity } from "@jsenv/validity";
@@ -10145,6 +10145,107 @@ const resolveRouteUrl = (relativeUrl) => {
10145
10145
  */
10146
10146
 
10147
10147
 
10148
+ /**
10149
+ * Set up all application routes with reactive state management.
10150
+ *
10151
+ * Creates route objects that automatically sync with the current URL and provide
10152
+ * reactive signals for building dynamic UIs. Each route tracks its matching state,
10153
+ * extracted parameters, and computed URLs.
10154
+ *
10155
+ * @example
10156
+ * ```js
10157
+ * import { setupRoutes, stateSignal } from "@jsenv/navi";
10158
+ *
10159
+ * const settingsTabSignal = stateSignal('general', { type: "string", oneOf: ['general', 'overview'] });
10160
+ *
10161
+ * let { USER_PROFILE } = setupRoutes({
10162
+ * HOME: "/",
10163
+ * SETTINGS: "/settings/:tab=${settingsTabSignal}/",
10164
+ * });
10165
+ *
10166
+ * USER_PROFILE.matching // boolean
10167
+ * USER_PROFILE.matchingSignal.value // reactive signal
10168
+ * settingsTabSignal.value = 'overview'; // updates URL automatically
10169
+ * ```
10170
+ *
10171
+ * ⚠️ HOT RELOAD: Use 'let' instead of 'const' when destructuring:
10172
+ * ```js
10173
+ * // ❌ const { HOME, USER_PROFILE } = setupRoutes({...})
10174
+ * // ✅ let { HOME, USER_PROFILE } = setupRoutes({...})
10175
+ * ```
10176
+ *
10177
+ * @param {Object} routeDefinition - Object mapping route names to URL patterns
10178
+ * @param {string} routeDefinition[key] - URL pattern with optional parameters
10179
+ * @returns {Object} Object with route names as keys and route objects as values
10180
+ * @returns {Object.<string, {
10181
+ * pattern: string,
10182
+ * matching: boolean,
10183
+ * params: Object,
10184
+ * url: string,
10185
+ * relativeUrl: string,
10186
+ * matchingSignal: import("@preact/signals").Signal<boolean>,
10187
+ * paramsSignal: import("@preact/signals").Signal<Object>,
10188
+ * urlSignal: import("@preact/signals").Signal<string>,
10189
+ * navTo: (params?: Object) => Promise<void>,
10190
+ * redirectTo: (params?: Object) => Promise<void>,
10191
+ * replaceParams: (params: Object) => Promise<void>,
10192
+ * buildUrl: (params?: Object) => string,
10193
+ * buildRelativeUrl: (params?: Object) => string,
10194
+ * }>} Route objects with reactive state and navigation methods
10195
+ *
10196
+ * All routes MUST be created at once because any url can be accessed
10197
+ * at any given time (url can be shared, reloaded, etc..)
10198
+ */
10199
+
10200
+ const setupRoutes = (routeDefinition) => {
10201
+ // Prevent calling setupRoutes when routes already exist - enforce clean setup
10202
+ if (routeSet.size > 0) {
10203
+ throw new Error(
10204
+ "Routes already exist. Call clearAllRoutes() first to clean up existing routes before creating new ones. This prevents cross-test pollution and ensures clean state.",
10205
+ );
10206
+ }
10207
+ // PHASE 1: Setup patterns with unified objects (includes all relationships and signal connections)
10208
+ const routePatterns = setupPatterns(routeDefinition);
10209
+
10210
+ // PHASE 2: Create routes using the unified pattern objects
10211
+ const routes = {};
10212
+ for (const key of Object.keys(routeDefinition)) {
10213
+ const routePattern = routePatterns[key];
10214
+ const route = registerRoute(routePattern);
10215
+ routes[key] = route;
10216
+ }
10217
+ onRouteDefined();
10218
+
10219
+ return routes;
10220
+ };
10221
+
10222
+ const useRouteStatus = (route) => {
10223
+ const { urlSignal, matchingSignal, paramsSignal, visitedSignal } = route;
10224
+ const url = urlSignal.value;
10225
+ const matching = matchingSignal.value;
10226
+ const params = paramsSignal.value;
10227
+ const visited = visitedSignal.value;
10228
+
10229
+ return {
10230
+ url,
10231
+ matching,
10232
+ params,
10233
+ visited,
10234
+ };
10235
+ };
10236
+
10237
+ // for unit tests
10238
+ const clearAllRoutes = () => {
10239
+ for (const [, routePrivateProperties] of routePrivatePropertiesMap) {
10240
+ routePrivateProperties.cleanup();
10241
+ }
10242
+ routeSet.clear();
10243
+ routePrivatePropertiesMap.clear();
10244
+ // Pattern registry is now local to setupPatterns, no global cleanup needed
10245
+ // Don't clear signal registry here - let tests manage it explicitly
10246
+ // This prevents clearing signals that are still being used across multiple route registrations
10247
+ };
10248
+
10148
10249
  // Flag to prevent signal-to-URL synchronization during URL-to-signal synchronization
10149
10250
  let isUpdatingRoutesFromUrl = false;
10150
10251
 
@@ -10159,9 +10260,11 @@ const ROUTE_DEACTIVATION_STRATEGY = "abort"; // 'abort', 'keep-loading'
10159
10260
  const ROUTE_NOT_MATCHING_PARAMS = {};
10160
10261
 
10161
10262
  const routeSet = new Set();
10162
- // Store previous route states to detect changes
10163
10263
  const routePrivatePropertiesMap = new Map();
10164
-
10264
+ const getRoutePrivateProperties = (route) => {
10265
+ return routePrivatePropertiesMap.get(route);
10266
+ };
10267
+ // Store previous route states to detect changes
10165
10268
  const routePreviousStateMap = new WeakMap();
10166
10269
  // Store abort controllers per action to control their lifecycle based on route state
10167
10270
  const actionAbortControllerWeakMap = new WeakMap();
@@ -10261,8 +10364,8 @@ const updateRoutes = (
10261
10364
 
10262
10365
  for (const [paramName, connection] of connectionMap) {
10263
10366
  const { signal: paramSignal, debug } = connection;
10264
- const params = routePrivateProperties.rawParamsSignal.value;
10265
- const urlParamValue = params[paramName];
10367
+ const rawParams = route.rawParamsSignal.value;
10368
+ const urlParamValue = rawParams[paramName];
10266
10369
 
10267
10370
  if (!newMatching) {
10268
10371
  // Route doesn't match - check if any matching route extracts this parameter
@@ -10273,17 +10376,18 @@ const updateRoutes = (
10273
10376
  if (otherRoute === route || !otherRoute.matching) {
10274
10377
  continue;
10275
10378
  }
10276
- const otherRouteProperties = getRoutePrivateProperties(otherRoute);
10277
- const otherParams = otherRouteProperties.rawParamsSignal.value;
10379
+ const otherRawParams = otherRoute.rawParamsSignal.value;
10380
+ const otherRoutePrivateProperties =
10381
+ getRoutePrivateProperties(otherRoute);
10278
10382
 
10279
10383
  // Check if this matching route extracts the parameter
10280
- if (paramName in otherParams) {
10384
+ if (paramName in otherRawParams) {
10281
10385
  parameterExtractedByMatchingRoute = true;
10282
10386
  }
10283
10387
 
10284
10388
  // Check if this matching route is in the same family using parent-child relationships
10285
10389
  const thisPatternObj = routePattern;
10286
- const otherPatternObj = otherRouteProperties.routePattern;
10390
+ const otherPatternObj = otherRoutePrivateProperties.routePattern;
10287
10391
 
10288
10392
  // Routes are in same family if they share a hierarchical relationship:
10289
10393
  // 1. One is parent/ancestor of the other
@@ -10500,23 +10604,12 @@ const updateRoutes = (
10500
10604
  };
10501
10605
  };
10502
10606
 
10503
- const getRoutePrivateProperties = (route) => {
10504
- return routePrivatePropertiesMap.get(route);
10505
- };
10506
-
10507
10607
  const registerRoute = (routePattern) => {
10508
10608
  const urlPatternRaw = routePattern.originalPattern;
10509
10609
  const { cleanPattern, connectionMap } = routePattern;
10510
-
10511
- const cleanupCallbackSet = new Set();
10512
- const cleanup = () => {
10513
- for (const cleanupCallback of cleanupCallbackSet) {
10514
- cleanupCallback();
10515
- }
10516
- cleanupCallbackSet.clear();
10517
- };
10518
10610
  const [publishStatus, subscribeStatus] = createPubSub();
10519
10611
 
10612
+ // prepare route object
10520
10613
  const route = {
10521
10614
  urlPattern: cleanPattern,
10522
10615
  pattern: cleanPattern,
@@ -10528,74 +10621,78 @@ const registerRoute = (routePattern) => {
10528
10621
  relativeUrl: null,
10529
10622
  url: null,
10530
10623
  action: null,
10531
- cleanup,
10624
+ matchingSignal: null,
10625
+ paramsSignal: null,
10626
+ urlSignal: null,
10627
+ replaceParams: undefined,
10628
+ subscribeStatus,
10532
10629
  toString: () => {
10533
10630
  return `route "${cleanPattern}"`;
10534
10631
  },
10535
- replaceParams: undefined,
10536
- subscribeStatus,
10537
10632
  };
10538
10633
  routeSet.add(route);
10539
10634
  const routePrivateProperties = {
10540
10635
  routePattern,
10541
10636
  originalPattern: urlPatternRaw,
10542
10637
  pattern: cleanPattern,
10543
- matchingSignal: null,
10544
- paramsSignal: null,
10545
- rawParamsSignal: null,
10546
- visitedSignal: null,
10547
- relativeUrlSignal: null,
10548
- urlSignal: null,
10549
- updateStatus: ({ matching, params, visited }) => {
10550
- let someChange = false;
10551
- matchingSignal.value = matching;
10552
-
10553
- if (route.matching !== matching) {
10554
- route.matching = matching;
10555
- someChange = true;
10556
- }
10557
- visitedSignal.value = visited;
10558
- if (route.visited !== visited) {
10559
- route.visited = visited;
10560
- someChange = true;
10561
- }
10562
- // Store raw params (from URL) - paramsSignal will reactively compute merged params
10563
- rawParamsSignal.value = params;
10564
- // Get merged params for comparison (computed signal will handle the merging)
10565
- const mergedParams = paramsSignal.value;
10566
- if (route.params !== mergedParams) {
10567
- route.params = mergedParams;
10568
- someChange = true;
10569
- }
10570
- if (someChange) {
10571
- publishStatus({
10572
- matching,
10573
- params: mergedParams,
10574
- visited,
10575
- });
10576
- }
10577
- },
10638
+ updateStatus: null,
10639
+ cleanup: null,
10578
10640
  };
10579
10641
  routePrivatePropertiesMap.set(route, routePrivateProperties);
10642
+ const cleanupCallbackSet = new Set();
10643
+ routePrivateProperties.cleanup = () => {
10644
+ for (const cleanupCallback of cleanupCallbackSet) {
10645
+ cleanupCallback();
10646
+ }
10647
+ cleanupCallbackSet.clear();
10648
+ };
10649
+ routePrivateProperties.updateStatus = ({ matching, params, visited }) => {
10650
+ let someChange = false;
10651
+ route.matchingSignal.value = matching;
10652
+
10653
+ if (route.matching !== matching) {
10654
+ route.matching = matching;
10655
+ someChange = true;
10656
+ }
10657
+ route.visitedSignal.value = visited;
10658
+ if (route.visited !== visited) {
10659
+ route.visited = visited;
10660
+ someChange = true;
10661
+ }
10662
+ // Store raw params (from URL) - paramsSignal will reactively compute merged params
10663
+ route.rawParamsSignal.value = params;
10664
+ // Get merged params for comparison (computed signal will handle the merging)
10665
+ const mergedParams = route.paramsSignal.value;
10666
+ if (route.params !== mergedParams) {
10667
+ route.params = mergedParams;
10668
+ someChange = true;
10669
+ }
10670
+ if (someChange) {
10671
+ publishStatus({
10672
+ matching,
10673
+ params: mergedParams,
10674
+ visited,
10675
+ });
10676
+ }
10677
+ };
10580
10678
 
10581
- const matchingSignal = signal(false);
10582
- const rawParamsSignal = signal(ROUTE_NOT_MATCHING_PARAMS);
10583
- const paramsSignal = computed(() => {
10584
- const rawParams = rawParamsSignal.value;
10679
+ // populate route object
10680
+ route.matchingSignal = signal(false);
10681
+ route.rawParamsSignal = signal(ROUTE_NOT_MATCHING_PARAMS);
10682
+ route.paramsSignal = computed(() => {
10683
+ const rawParams = route.rawParamsSignal.value;
10585
10684
  const resolvedParams = routePattern.resolveParams(rawParams);
10586
10685
  return resolvedParams;
10587
10686
  });
10588
- const visitedSignal = signal(false);
10589
-
10687
+ route.visitedSignal = signal(false);
10590
10688
  // Keep route.params synchronized with computed paramsSignal
10591
10689
  // This ensures route.params includes parameters from child routes
10592
10690
  effect(() => {
10593
- const computedParams = paramsSignal.value;
10691
+ const computedParams = route.paramsSignal.value;
10594
10692
  if (route.params !== computedParams) {
10595
10693
  route.params = computedParams;
10596
10694
  }
10597
10695
  });
10598
-
10599
10696
  for (const [paramName, connection] of connectionMap) {
10600
10697
  const { signal: paramSignal, debug } = connection;
10601
10698
 
@@ -10610,9 +10707,9 @@ const registerRoute = (routePattern) => {
10610
10707
  // eslint-disable-next-line no-loop-func
10611
10708
  effect(() => {
10612
10709
  const value = paramSignal.value;
10613
- const params = rawParamsSignal.value;
10614
- const urlParamValue = params[paramName];
10615
- const matching = matchingSignal.value;
10710
+ const rawParams = route.rawParamsSignal.value;
10711
+ const urlParamValue = rawParams[paramName];
10712
+ const matching = route.matchingSignal.value;
10616
10713
 
10617
10714
  // Signal returned to default - clean up URL by removing the parameter
10618
10715
  // Skip cleanup during URL-to-signal synchronization to prevent recursion
@@ -10662,7 +10759,6 @@ const registerRoute = (routePattern) => {
10662
10759
  route.replaceParams({ [paramName]: value });
10663
10760
  });
10664
10761
  }
10665
-
10666
10762
  route.navTo = (params) => {
10667
10763
  if (!integration) {
10668
10764
  return Promise.resolve();
@@ -10679,7 +10775,7 @@ const registerRoute = (routePattern) => {
10679
10775
  });
10680
10776
  };
10681
10777
  route.replaceParams = (newParams) => {
10682
- const matching = matchingSignal.peek();
10778
+ const matching = route.matchingSignal.peek();
10683
10779
  if (!matching) {
10684
10780
  console.warn(
10685
10781
  `Cannot replace params on route ${route} because it is not matching the current URL.`,
@@ -10695,16 +10791,14 @@ const registerRoute = (routePattern) => {
10695
10791
  if (matchingRoute.action) {
10696
10792
  const matchingRoutePrivateProperties =
10697
10793
  getRoutePrivateProperties(matchingRoute);
10698
- if (matchingRoutePrivateProperties) {
10699
- const { routePattern: matchingRoutePattern } =
10700
- matchingRoutePrivateProperties;
10701
- const currentResolvedParams = matchingRoutePattern.resolveParams();
10702
- const updatedActionParams = {
10703
- ...currentResolvedParams,
10704
- ...newParams,
10705
- };
10706
- matchingRoute.action.replaceParams(updatedActionParams);
10707
- }
10794
+ const { routePattern: matchingRoutePattern } =
10795
+ matchingRoutePrivateProperties;
10796
+ const currentResolvedParams = matchingRoutePattern.resolveParams();
10797
+ const updatedActionParams = {
10798
+ ...currentResolvedParams,
10799
+ ...newParams,
10800
+ };
10801
+ matchingRoute.action.replaceParams(updatedActionParams);
10708
10802
  }
10709
10803
  }
10710
10804
 
@@ -10753,20 +10847,20 @@ const registerRoute = (routePattern) => {
10753
10847
  };
10754
10848
 
10755
10849
  // relativeUrl/url
10756
- const relativeUrlSignal = computed(() => {
10757
- const rawParams = rawParamsSignal.value;
10850
+ route.relativeUrlSignal = computed(() => {
10851
+ const rawParams = route.rawParamsSignal.value;
10758
10852
  const relativeUrl = route.buildRelativeUrl(rawParams);
10759
10853
  return relativeUrl;
10760
10854
  });
10761
- const urlSignal = computed(() => {
10855
+ route.urlSignal = computed(() => {
10762
10856
  const routeUrl = route.buildUrl();
10763
10857
  return routeUrl;
10764
10858
  });
10765
10859
  const disposeRelativeUrlEffect = effect(() => {
10766
- route.relativeUrl = relativeUrlSignal.value;
10860
+ route.relativeUrl = route.relativeUrlSignal.value;
10767
10861
  });
10768
10862
  const disposeUrlEffect = effect(() => {
10769
- route.url = urlSignal.value;
10863
+ route.url = route.urlSignal.value;
10770
10864
  });
10771
10865
  cleanupCallbackSet.add(disposeRelativeUrlEffect);
10772
10866
  cleanupCallbackSet.add(disposeUrlEffect);
@@ -10779,7 +10873,7 @@ const registerRoute = (routePattern) => {
10779
10873
  if (mutableIdKeys.length) {
10780
10874
  const mutableIdKey = mutableIdKeys[0];
10781
10875
  const mutableIdValueSignal = computed(() => {
10782
- const params = paramsSignal.value;
10876
+ const params = route.paramsSignal.value;
10783
10877
  const mutableIdValue = params[mutableIdKey];
10784
10878
  return mutableIdValue;
10785
10879
  });
@@ -10799,45 +10893,14 @@ const registerRoute = (routePattern) => {
10799
10893
  }
10800
10894
  }
10801
10895
 
10802
- const actionBoundToThisRoute = action.bindParams(paramsSignal);
10896
+ const actionBoundToThisRoute = action.bindParams(route.paramsSignal);
10803
10897
  route.action = actionBoundToThisRoute;
10804
10898
  return actionBoundToThisRoute;
10805
10899
  };
10806
10900
 
10807
- // Store private properties for internal access
10808
- routePrivateProperties.matchingSignal = matchingSignal;
10809
- routePrivateProperties.paramsSignal = paramsSignal;
10810
- routePrivateProperties.rawParamsSignal = rawParamsSignal;
10811
- routePrivateProperties.visitedSignal = visitedSignal;
10812
- routePrivateProperties.relativeUrlSignal = relativeUrlSignal;
10813
- routePrivateProperties.urlSignal = urlSignal;
10814
- routePrivateProperties.cleanupCallbackSet = cleanupCallbackSet;
10815
-
10816
10901
  return route;
10817
10902
  };
10818
10903
 
10819
- const useRouteStatus = (route) => {
10820
- const routePrivateProperties = getRoutePrivateProperties(route);
10821
- if (!routePrivateProperties) {
10822
- throw new Error(`Cannot find route private properties for ${route}`);
10823
- }
10824
-
10825
- const { urlSignal, matchingSignal, paramsSignal, visitedSignal } =
10826
- routePrivateProperties;
10827
-
10828
- const url = urlSignal.value;
10829
- const matching = matchingSignal.value;
10830
- const params = paramsSignal.value;
10831
- const visited = visitedSignal.value;
10832
-
10833
- return {
10834
- url,
10835
- matching,
10836
- params,
10837
- visited,
10838
- };
10839
- };
10840
-
10841
10904
  let integration;
10842
10905
  const setRouteIntegration = (integrationInterface) => {
10843
10906
  integration = integrationInterface;
@@ -10846,56 +10909,6 @@ let onRouteDefined = () => {};
10846
10909
  const setOnRouteDefined = (v) => {
10847
10910
  onRouteDefined = v;
10848
10911
  };
10849
- /**
10850
- * Define all routes for the application.
10851
- *
10852
- * ⚠️ HOT RELOAD WARNING: When destructuring the returned routes, use 'let' instead of 'const'
10853
- * to allow hot reload to update the route references:
10854
- *
10855
- * ❌ const [ROLE_ROUTE, DATABASE_ROUTE] = defineRoutes({...})
10856
- * ✅ let [ROLE_ROUTE, DATABASE_ROUTE] = defineRoutes({...})
10857
- *
10858
- * @param {Object} routeDefinition - Object mapping URL patterns to actions
10859
- * @returns {Array} Array of route objects in the same order as the keys
10860
- */
10861
- // All routes MUST be created at once because any url can be accessed
10862
- // at any given time (url can be shared, reloaded, etc..)
10863
- // Later I'll consider adding ability to have dynamic import into the mix
10864
- // (An async function returning an action)
10865
-
10866
- const setupRoutes = (routeDefinition) => {
10867
- // Prevent calling setupRoutes when routes already exist - enforce clean setup
10868
- if (routeSet.size > 0) {
10869
- throw new Error(
10870
- "Routes already exist. Call clearAllRoutes() first to clean up existing routes before creating new ones. This prevents cross-test pollution and ensures clean state.",
10871
- );
10872
- }
10873
- // PHASE 1: Setup patterns with unified objects (includes all relationships and signal connections)
10874
- const routePatterns = setupPatterns(routeDefinition);
10875
-
10876
- // PHASE 2: Create routes using the unified pattern objects
10877
- const routes = {};
10878
- for (const key of Object.keys(routeDefinition)) {
10879
- const routePattern = routePatterns[key];
10880
- const route = registerRoute(routePattern);
10881
- routes[key] = route;
10882
- }
10883
- onRouteDefined();
10884
-
10885
- return routes;
10886
- };
10887
-
10888
- // for unit tests
10889
- const clearAllRoutes = () => {
10890
- for (const route of routeSet) {
10891
- route.cleanup();
10892
- }
10893
- routeSet.clear();
10894
- routePrivatePropertiesMap.clear();
10895
- // Pattern registry is now local to setupPatterns, no global cleanup needed
10896
- // Don't clear signal registry here - let tests manage it explicitly
10897
- // This prevents clearing signals that are still being used across multiple route registrations
10898
- };
10899
10912
 
10900
10913
  const arraySignal = (initialValue = []) => {
10901
10914
  const theSignal = signal(initialValue);