@jsenv/navi 0.16.26 → 0.16.28

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.
@@ -2401,15 +2401,18 @@ const generateSignalId = () => {
2401
2401
  * 2. When explicitly set (programmatically or via localStorage), the explicit value takes precedence
2402
2402
  * 3. When default signal changes, it only updates if no explicit value was ever set
2403
2403
  * 4. Calling reset() or setting to undefined makes the signal use the dynamic default again
2404
+ * 5. If dynamic default is undefined and options.default is provided, uses the static fallback
2404
2405
  *
2405
2406
  * This is useful for:
2406
2407
  * - Backend data that can change but shouldn't override user preferences
2407
2408
  * - Route parameters with dynamic defaults based on other state
2408
2409
  * - Cascading configuration where defaults can be updated without losing user customizations
2410
+ * - Having a static fallback when dynamic defaults might be undefined
2409
2411
  *
2410
2412
  * @param {any|import("@preact/signals").Signal} defaultValue - Static default value OR signal for dynamic default behavior
2411
2413
  * @param {Object} [options={}] - Configuration options
2412
2414
  * @param {string|number} [options.id] - Custom ID for the signal. If not provided, an auto-generated ID will be used. Used for localStorage key and route pattern detection.
2415
+ * @param {any} [options.default] - Static fallback value used when defaultValue is a signal and that signal's value is undefined
2413
2416
  * @param {boolean} [options.persists=false] - Whether to persist the signal value in localStorage using the signal ID as key
2414
2417
  * @param {"string" | "number" | "boolean" | "object"} [options.type="string"] - Type for localStorage serialization/deserialization
2415
2418
  * @param {Array} [options.oneOf] - Array of valid values for validation. Signal will be marked invalid if value is not in this array
@@ -2450,6 +2453,21 @@ const generateSignalId = () => {
2450
2453
  * // Reset: userTheme.value = undefined; // Now follows dynamic default again
2451
2454
  *
2452
2455
  * @example
2456
+ * // Dynamic default with static fallback
2457
+ * const backendValue = signal(undefined); // might be undefined initially
2458
+ * const userValue = stateSignal(backendValue, {
2459
+ * default: "fallback",
2460
+ * persists: true
2461
+ * });
2462
+ *
2463
+ * // Initially: userValue.value = "fallback" (static fallback since dynamic is undefined)
2464
+ * // Backend loads: backendValue.value = "loaded"; userValue.value = "loaded" (follows dynamic)
2465
+ * // User sets: userValue.value = "custom" (explicit choice, persisted)
2466
+ * // Backend changes: backendValue.value = "updated"
2467
+ * // Result: userValue.value = "custom" (user choice preserved)
2468
+ * // Reset: userValue.value = undefined; userValue.value = "updated" (follows dynamic again)
2469
+ *
2470
+ * @example
2453
2471
  * // Route parameter with dynamic default from parent route
2454
2472
  * const parentTab = signal("overview");
2455
2473
  * const childTab = stateSignal(parentTab);
@@ -2464,6 +2482,7 @@ const stateSignal = (defaultValue, options = {}) => {
2464
2482
  autoFix,
2465
2483
  persists = false,
2466
2484
  debug,
2485
+ default: staticFallback,
2467
2486
  } = options;
2468
2487
 
2469
2488
  // Check if defaultValue is a signal (dynamic default) or static value
@@ -2473,7 +2492,7 @@ const stateSignal = (defaultValue, options = {}) => {
2473
2492
  "value" in defaultValue &&
2474
2493
  "peek" in defaultValue;
2475
2494
  const dynamicDefaultSignal = isDynamicDefault ? defaultValue : null;
2476
- const staticDefaultValue = isDynamicDefault ? undefined : defaultValue;
2495
+ const staticDefaultValue = isDynamicDefault ? staticFallback : defaultValue;
2477
2496
  const signalId = id || generateSignalId();
2478
2497
  // Convert numeric IDs to strings for consistency
2479
2498
  const signalIdString = String(signalId);
@@ -2505,7 +2524,15 @@ const stateSignal = (defaultValue, options = {}) => {
2505
2524
  if (dynamicDefaultSignal) {
2506
2525
  const dynamicValue = dynamicDefaultSignal.peek();
2507
2526
  if (dynamicValue === undefined) {
2508
- return undefined;
2527
+ if (staticDefaultValue === undefined) {
2528
+ return undefined;
2529
+ }
2530
+ if (debug) {
2531
+ console.debug(
2532
+ `[stateSignal:${signalIdString}] dynamic default is undefined, using static default=${staticDefaultValue}`,
2533
+ );
2534
+ }
2535
+ return staticDefaultValue;
2509
2536
  }
2510
2537
  if (debug) {
2511
2538
  console.debug(
@@ -2526,7 +2553,11 @@ const stateSignal = (defaultValue, options = {}) => {
2526
2553
  return false;
2527
2554
  }
2528
2555
  if (dynamicDefaultSignal) {
2529
- return value !== dynamicDefaultSignal.peek();
2556
+ const dynamicValue = dynamicDefaultSignal.peek();
2557
+ if (dynamicValue === undefined) {
2558
+ return value !== staticDefaultValue;
2559
+ }
2560
+ return value !== dynamicValue;
2530
2561
  }
2531
2562
  return value !== staticDefaultValue;
2532
2563
  };
@@ -2577,21 +2608,40 @@ const stateSignal = (defaultValue, options = {}) => {
2577
2608
  dynamicDefaultPreviousValue = dynamicDefaultValue;
2578
2609
  return;
2579
2610
  }
2580
- if (value !== dynamicDefaultPreviousValue) {
2611
+ // Check if current signal value matches the PREVIOUS dynamic default
2612
+ // If so, it was following the dynamic default and should update
2613
+ // Special case: if previous was undefined and we were using static fallback
2614
+ let wasFollowingDefault = false;
2615
+ if (
2616
+ dynamicDefaultPreviousValue === undefined &&
2617
+ staticDefaultValue !== undefined
2618
+ ) {
2619
+ // Signal might have been using static fallback
2620
+ wasFollowingDefault = value === staticDefaultValue;
2621
+ } else {
2622
+ // Signal was following the previous dynamic default
2623
+ wasFollowingDefault = value === dynamicDefaultPreviousValue;
2624
+ }
2625
+
2626
+ if (!wasFollowingDefault) {
2627
+ // Signal has a custom value, don't update even if dynamic default changes
2581
2628
  dynamicDefaultPreviousValue = dynamicDefaultValue;
2582
2629
  return;
2583
2630
  }
2584
- dynamicDefaultPreviousValue = dynamicDefaultValue;
2585
- const defaultValue = getDefaultValue();
2586
- if (defaultValue === value) {
2631
+
2632
+ // Signal was using default value, update to new default
2633
+ const newDefaultValue = getDefaultValue();
2634
+ if (newDefaultValue === value) {
2635
+ dynamicDefaultPreviousValue = dynamicDefaultValue;
2587
2636
  return;
2588
2637
  }
2589
2638
  if (debug) {
2590
2639
  console.debug(
2591
- `[stateSignal:${signalIdString}] dynamic default updated, update to ${defaultValue}`,
2640
+ `[stateSignal:${signalIdString}] dynamic default updated, update to ${newDefaultValue}`,
2592
2641
  );
2593
2642
  }
2594
- advancedSignal.value = defaultValue;
2643
+ dynamicDefaultPreviousValue = dynamicDefaultValue;
2644
+ advancedSignal.value = newDefaultValue;
2595
2645
  });
2596
2646
  }
2597
2647
  persist_in_local_storage: {
@@ -2664,6 +2714,7 @@ const stateSignal = (defaultValue, options = {}) => {
2664
2714
  getDefaultValue,
2665
2715
  defaultValue: staticDefaultValue,
2666
2716
  dynamicDefaultSignal,
2717
+ isCustomValue,
2667
2718
  type,
2668
2719
  persists,
2669
2720
  localStorageKey,
@@ -7847,21 +7898,26 @@ const createRoutePattern = (pattern) => {
7847
7898
 
7848
7899
  /**
7849
7900
  * Helper: Filter out default values from parameters for cleaner URLs
7901
+ *
7902
+ * This function removes parameters that match their default values (static or dynamic)
7903
+ * while preserving custom values and inherited parameters from ancestor routes.
7904
+ * Parameter inheritance from parent routes is intentional - only default values
7905
+ * for the current route's own parameters are filtered out.
7850
7906
  */
7851
7907
  const removeDefaultValues = (params) => {
7852
7908
  const filtered = { ...params };
7853
7909
 
7854
7910
  for (const connection of connections) {
7855
- const { paramName, signal } = connection;
7856
- const defaultValue = parameterDefaults.get(paramName);
7911
+ const { paramName, signal, options } = connection;
7857
7912
 
7858
- if (paramName in filtered && filtered[paramName] === defaultValue) {
7859
- delete filtered[paramName];
7860
- } else if (
7861
- !(paramName in filtered) &&
7862
- signal?.value !== undefined &&
7863
- signal.value !== defaultValue
7864
- ) {
7913
+ if (paramName in filtered) {
7914
+ // Parameter is explicitly provided - check if we should remove it
7915
+ if (!options.isCustomValue?.(filtered[paramName])) {
7916
+ // Parameter value is not custom (matches default) - remove it
7917
+ delete filtered[paramName];
7918
+ }
7919
+ } else if (options.isCustomValue?.(signal.value)) {
7920
+ // Parameter not provided but signal has custom value - add it
7865
7921
  filtered[paramName] = signal.value;
7866
7922
  }
7867
7923
  }
@@ -7880,7 +7936,7 @@ const createRoutePattern = (pattern) => {
7880
7936
  const effectiveValue = userValue !== undefined ? userValue : signalValue;
7881
7937
  return (
7882
7938
  effectiveValue === literalValue &&
7883
- effectiveValue !== conn.options.defaultValue
7939
+ conn.options.isCustomValue?.(effectiveValue)
7884
7940
  );
7885
7941
  });
7886
7942
 
@@ -7918,7 +7974,7 @@ const createRoutePattern = (pattern) => {
7918
7974
  const signalValue = conn.signal?.value;
7919
7975
  return (
7920
7976
  signalValue === literalValue &&
7921
- signalValue !== conn.options.defaultValue
7977
+ conn.options.isCustomValue?.(signalValue)
7922
7978
  );
7923
7979
  });
7924
7980
 
@@ -8061,10 +8117,10 @@ const createRoutePattern = (pattern) => {
8061
8117
  } else {
8062
8118
  const { paramName: name, signal, options } = item;
8063
8119
  paramName = name;
8064
- // Only include non-default parent signal values
8120
+ // Only include custom parent signal values (not using defaults)
8065
8121
  if (
8066
8122
  signal?.value === undefined ||
8067
- signal.value === options.defaultValue
8123
+ !options.isCustomValue?.(signal.value)
8068
8124
  ) {
8069
8125
  return { isCompatible: true, shouldInclude: false };
8070
8126
  }
@@ -8214,7 +8270,6 @@ const createRoutePattern = (pattern) => {
8214
8270
 
8215
8271
  for (const connection of childPatternObj.connections) {
8216
8272
  const { paramName, signal, options } = connection;
8217
- const defaultValue = options.defaultValue;
8218
8273
 
8219
8274
  // Check if parameter was explicitly provided by user
8220
8275
  const hasExplicitParam = paramName in params;
@@ -8223,13 +8278,16 @@ const createRoutePattern = (pattern) => {
8223
8278
  if (hasExplicitParam) {
8224
8279
  // User explicitly provided this parameter - use their value
8225
8280
  childParams[paramName] = explicitValue;
8226
- if (explicitValue !== undefined && explicitValue !== defaultValue) {
8281
+ if (
8282
+ explicitValue !== undefined &&
8283
+ options.isCustomValue?.(explicitValue)
8284
+ ) {
8227
8285
  hasActiveParams = true;
8228
8286
  }
8229
8287
  } else if (signal?.value !== undefined) {
8230
8288
  // No explicit override - use signal value
8231
8289
  childParams[paramName] = signal.value;
8232
- if (signal.value !== defaultValue) {
8290
+ if (options.isCustomValue?.(signal.value)) {
8233
8291
  hasActiveParams = true;
8234
8292
  }
8235
8293
  }
@@ -8309,7 +8367,7 @@ const createRoutePattern = (pattern) => {
8309
8367
  // Check if parameters that determine child selection are non-default
8310
8368
  // OR if any descendant parameters indicate explicit navigation
8311
8369
  for (const connection of connections) {
8312
- const { paramName } = connection;
8370
+ const { paramName, options } = connection;
8313
8371
  const defaultValue = parameterDefaults.get(paramName);
8314
8372
  const resolvedValue = resolvedParams[paramName];
8315
8373
  const userProvidedParam = paramName in params;
@@ -8318,9 +8376,10 @@ const createRoutePattern = (pattern) => {
8318
8376
  // This literal corresponds to a parameter in the parent
8319
8377
  if (
8320
8378
  userProvidedParam ||
8321
- (resolvedValue !== undefined && resolvedValue !== defaultValue)
8379
+ (resolvedValue !== undefined &&
8380
+ options.isCustomValue?.(resolvedValue))
8322
8381
  ) {
8323
- // Parameter was explicitly provided or has non-default value - child is needed
8382
+ // Parameter was explicitly provided or has custom value - child is needed
8324
8383
  childSpecificParamsAreDefaults = false;
8325
8384
  break;
8326
8385
  }
@@ -8421,7 +8480,7 @@ const createRoutePattern = (pattern) => {
8421
8480
  // If explicitly undefined, don't include it (which means don't use child route)
8422
8481
  } else if (
8423
8482
  signal?.value !== undefined &&
8424
- signal.value !== options.defaultValue
8483
+ options.isCustomValue?.(signal.value)
8425
8484
  ) {
8426
8485
  // No explicit override - use signal value if non-default
8427
8486
  baseParams[paramName] = signal.value;
@@ -8439,7 +8498,6 @@ const createRoutePattern = (pattern) => {
8439
8498
  // Add parent's signal parameters
8440
8499
  for (const connection of parentPatternObj.connections) {
8441
8500
  const { paramName, signal, options } = connection;
8442
- const defaultValue = options.defaultValue;
8443
8501
 
8444
8502
  // Skip if child route already handles this parameter
8445
8503
  const childConnection = childPatternObj.connections.find(
@@ -8454,8 +8512,11 @@ const createRoutePattern = (pattern) => {
8454
8512
  continue; // Already have this parameter
8455
8513
  }
8456
8514
 
8457
- // Only include non-default signal values
8458
- if (signal?.value !== undefined && signal.value !== defaultValue) {
8515
+ // Only include custom signal values (not using defaults)
8516
+ if (
8517
+ signal?.value !== undefined &&
8518
+ options.isCustomValue?.(signal.value)
8519
+ ) {
8459
8520
  // Skip if parameter is consumed by child's literal path segments
8460
8521
  const isConsumedByChildPath = childPatternObj.pattern.segments.some(
8461
8522
  (segment) =>
@@ -8521,10 +8582,9 @@ const createRoutePattern = (pattern) => {
8521
8582
 
8522
8583
  if (childConnection) {
8523
8584
  const { options } = childConnection;
8524
- const defaultValue = options.defaultValue;
8525
8585
 
8526
- // Only include if it's NOT the signal's default value
8527
- if (userValue !== defaultValue) {
8586
+ // Only include if it's a custom value (not default)
8587
+ if (options.isCustomValue?.(userValue)) {
8528
8588
  baseParams[paramName] = userValue;
8529
8589
  } else {
8530
8590
  // User provided the default value - complete omission
@@ -8584,7 +8644,7 @@ const createRoutePattern = (pattern) => {
8584
8644
  const hasNonDefaultChildParams = (childPatternObj.connections || []).some(
8585
8645
  (childConnection) => {
8586
8646
  const { signal, options } = childConnection;
8587
- return signal?.value !== options.defaultValue;
8647
+ return options.isCustomValue?.(signal?.value);
8588
8648
  },
8589
8649
  );
8590
8650
 
@@ -8794,13 +8854,15 @@ const createRoutePattern = (pattern) => {
8794
8854
  // 2. The source route has only query parameters that are non-default
8795
8855
  const hasNonDefaultPathParams = connections.some((connection) => {
8796
8856
  const resolvedValue = resolvedParams[connection.paramName];
8797
- const defaultValue = connection.options.defaultValue;
8857
+
8798
8858
  // Check if this is a query parameter (not in the pattern path)
8799
8859
  const isQueryParam = parsedPattern.queryParams.some(
8800
8860
  (qp) => qp.name === connection.paramName,
8801
8861
  );
8802
8862
  // Allow non-default query parameters, but not path parameters
8803
- return !isQueryParam && resolvedValue !== defaultValue;
8863
+ return (
8864
+ !isQueryParam && connection.options.isCustomValue?.(resolvedValue)
8865
+ );
8804
8866
  });
8805
8867
 
8806
8868
  if (hasNonDefaultPathParams) {
@@ -8830,8 +8892,7 @@ const createRoutePattern = (pattern) => {
8830
8892
  // For non-immediate parents, only allow optimization if all resolved parameters have default values
8831
8893
  const hasNonDefaultParameters = connections.some((connection) => {
8832
8894
  const resolvedValue = resolvedParams[connection.paramName];
8833
- const defaultValue = connection.options.defaultValue;
8834
- return resolvedValue !== defaultValue;
8895
+ return connection.options.isCustomValue?.(resolvedValue);
8835
8896
  });
8836
8897
 
8837
8898
  if (hasNonDefaultParameters) {
@@ -9100,13 +9161,12 @@ const createRoutePattern = (pattern) => {
9100
9161
  // Also check target ancestor's own signal values for parameters not in resolvedParams
9101
9162
  for (const connection of targetAncestor.connections) {
9102
9163
  const { paramName, signal, options } = connection;
9103
- const defaultValue = options.defaultValue;
9104
9164
 
9105
- // Only include if not already processed and has non-default value
9165
+ // Only include if not already processed and has custom value (not default)
9106
9166
  if (
9107
9167
  !(paramName in ancestorParams) &&
9108
9168
  signal?.value !== undefined &&
9109
- signal.value !== defaultValue
9169
+ options.isCustomValue?.(signal.value)
9110
9170
  ) {
9111
9171
  // Don't include path parameters that correspond to literal segments we're optimizing away
9112
9172
  const targetParam = targetParams.find((p) => p.name === paramName);
@@ -9136,13 +9196,12 @@ const createRoutePattern = (pattern) => {
9136
9196
  while (currentParent) {
9137
9197
  for (const connection of currentParent.connections) {
9138
9198
  const { paramName, signal, options } = connection;
9139
- const defaultValue = options.defaultValue;
9140
9199
 
9141
- // Only inherit non-default values that we don't already have
9200
+ // Only inherit custom values (not defaults) that we don't already have
9142
9201
  if (
9143
9202
  !(paramName in ancestorParams) &&
9144
9203
  signal?.value !== undefined &&
9145
- signal.value !== defaultValue
9204
+ options.isCustomValue?.(signal.value)
9146
9205
  ) {
9147
9206
  // Check if this parameter would be redundant with target ancestor's literal segments
9148
9207
  const isRedundant = isParameterRedundantWithLiteralSegments(
@@ -9217,13 +9276,12 @@ const createRoutePattern = (pattern) => {
9217
9276
  // Check parent's signal connections for non-default values to inherit
9218
9277
  for (const parentConnection of currentParent.connections) {
9219
9278
  const { paramName, signal, options } = parentConnection;
9220
- const defaultValue = options.defaultValue;
9221
9279
 
9222
- // Only inherit if we don't have this param and parent has non-default value
9280
+ // Only inherit if we don't have this param and parent has custom value (not default)
9223
9281
  if (
9224
9282
  !(paramName in finalParams) &&
9225
9283
  signal?.value !== undefined &&
9226
- signal.value !== defaultValue
9284
+ options.isCustomValue?.(signal.value)
9227
9285
  ) {
9228
9286
  // Don't inherit if parameter corresponds to a literal in our path
9229
9287
  const shouldInherit = !isParameterRedundantWithLiteralSegments(
@@ -9707,6 +9765,21 @@ const extractSearchParams = (urlObj, connections = []) => {
9707
9765
  /**
9708
9766
  * Build query parameters respecting hierarchical order from ancestor patterns
9709
9767
  */
9768
+ /**
9769
+ * Build hierarchical query parameters from pattern hierarchy
9770
+ *
9771
+ * IMPORTANT: This function implements parameter inheritance - child routes inherit
9772
+ * query parameters from their ancestor routes. This is intentional behavior that
9773
+ * allows child routes to preserve context from parent routes.
9774
+ *
9775
+ * For example:
9776
+ * - Parent route: /map/?lon=123
9777
+ * - Child route: /map/isochrone?iso_lon=456
9778
+ * - Final URL: /map/isochrone?lon=123&iso_lon=456
9779
+ *
9780
+ * The child route inherits 'lon' from its parent, maintaining navigation context.
9781
+ * Only parameters that match their defaults (static or dynamic) are omitted.
9782
+ */
9710
9783
  const buildHierarchicalQueryParams = (
9711
9784
  parsedPattern,
9712
9785
  params,