@jsenv/navi 0.16.25 → 0.16.27

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.
@@ -2390,22 +2390,26 @@ const generateSignalId = () => {
2390
2390
  };
2391
2391
 
2392
2392
  /**
2393
- * Creates an advanced signal with optional source signal synchronization, local storage persistence, and validation.
2393
+ * Creates an advanced signal with dynamic default value, local storage persistence, and validation.
2394
2394
  *
2395
- * The sourceSignal option creates a fallback mechanism where:
2396
- * 1. The signal initially takes the value from sourceSignal (if defined) or falls back to defaultValue
2397
- * 2. The signal can be manually overridden with any value
2398
- * 3. When sourceSignal changes, it will override the current value again
2395
+ * The first parameter can be either a static value or a signal acting as a "dynamic default":
2396
+ * - If a static value: traditional default behavior
2397
+ * - If a signal: acts as a dynamic default that updates the signal ONLY when no explicit value has been set
2399
2398
  *
2400
- * This is useful for scenarios like UI state management where you want to:
2401
- * - Start with a value from an external source (e.g., backend data)
2402
- * - Allow temporary local overrides (e.g., user interactions)
2403
- * - Reset to the external source when context changes (e.g., navigation, data refresh)
2399
+ * Dynamic default behavior (when first param is a signal):
2400
+ * 1. Initially takes value from the default signal
2401
+ * 2. When explicitly set (programmatically or via localStorage), the explicit value takes precedence
2402
+ * 3. When default signal changes, it only updates if no explicit value was ever set
2403
+ * 4. Calling reset() or setting to undefined makes the signal use the dynamic default again
2404
2404
  *
2405
- * @param {any} defaultValue - The default value to use when no other value is available
2405
+ * This is useful for:
2406
+ * - Backend data that can change but shouldn't override user preferences
2407
+ * - Route parameters with dynamic defaults based on other state
2408
+ * - Cascading configuration where defaults can be updated without losing user customizations
2409
+ *
2410
+ * @param {any|import("@preact/signals").Signal} defaultValue - Static default value OR signal for dynamic default behavior
2406
2411
  * @param {Object} [options={}] - Configuration options
2407
2412
  * @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.
2408
- * @param {import("@preact/signals").Signal} [options.sourceSignal] - Source signal to synchronize with. When the source signal changes, this signal will be updated
2409
2413
  * @param {boolean} [options.persists=false] - Whether to persist the signal value in localStorage using the signal ID as key
2410
2414
  * @param {"string" | "number" | "boolean" | "object"} [options.type="string"] - Type for localStorage serialization/deserialization
2411
2415
  * @param {Array} [options.oneOf] - Array of valid values for validation. Signal will be marked invalid if value is not in this array
@@ -2435,14 +2439,21 @@ const generateSignalId = () => {
2435
2439
  * });
2436
2440
  *
2437
2441
  * @example
2438
- * // Position that follows backend data but allows temporary overrides
2439
- * const backendPosition = signal({ x: 100, y: 50 });
2440
- * const currentPosition = stateSignal({ x: 0, y: 0 }, { sourceSignal: backendPosition });
2442
+ * // Dynamic default that doesn't override user choices
2443
+ * const backendTheme = signal("light");
2444
+ * const userTheme = stateSignal(backendTheme, { persists: true });
2445
+ *
2446
+ * // Initially: userTheme.value = "light" (from dynamic default)
2447
+ * // User sets: userTheme.value = "dark" (explicit choice, persisted)
2448
+ * // Backend changes: backendTheme.value = "blue"
2449
+ * // Result: userTheme.value = "dark" (user choice preserved)
2450
+ * // Reset: userTheme.value = undefined; // Now follows dynamic default again
2441
2451
  *
2442
- * // Initially: currentPosition.value = { x: 100, y: 50 } (from backend)
2443
- * // User drags: currentPosition.value = { x: 150, y: 80 } (manual override)
2444
- * // Backend updates: backendPosition.value = { x: 200, y: 60 }
2445
- * // Result: currentPosition.value = { x: 200, y: 60 } (reset to new backend value)
2452
+ * @example
2453
+ * // Route parameter with dynamic default from parent route
2454
+ * const parentTab = signal("overview");
2455
+ * const childTab = stateSignal(parentTab);
2456
+ * // childTab follows parentTab changes unless explicitly set
2446
2457
  */
2447
2458
  const NO_LOCAL_STORAGE = [() => undefined, () => {}, () => {}];
2448
2459
  const stateSignal = (defaultValue, options = {}) => {
@@ -2451,10 +2462,18 @@ const stateSignal = (defaultValue, options = {}) => {
2451
2462
  type = "string",
2452
2463
  oneOf,
2453
2464
  autoFix,
2454
- sourceSignal,
2455
2465
  persists = false,
2456
2466
  debug,
2457
2467
  } = options;
2468
+
2469
+ // Check if defaultValue is a signal (dynamic default) or static value
2470
+ const isDynamicDefault =
2471
+ defaultValue &&
2472
+ typeof defaultValue === "object" &&
2473
+ "value" in defaultValue &&
2474
+ "peek" in defaultValue;
2475
+ const dynamicDefaultSignal = isDynamicDefault ? defaultValue : null;
2476
+ const staticDefaultValue = isDynamicDefault ? undefined : defaultValue;
2458
2477
  const signalId = id || generateSignalId();
2459
2478
  // Convert numeric IDs to strings for consistency
2460
2479
  const signalIdString = String(signalId);
@@ -2471,202 +2490,200 @@ const stateSignal = (defaultValue, options = {}) => {
2471
2490
  persists
2472
2491
  ? valueInLocalStorage(localStorageKey, { type })
2473
2492
  : NO_LOCAL_STORAGE;
2474
- const getFallbackValue = () => {
2475
- const valueFromLocalStorage = readFromLocalStorage();
2476
- if (valueFromLocalStorage !== undefined) {
2477
- if (debug) {
2478
- console.debug(
2479
- `[stateSignal:${signalIdString}] using value from localStorage "${localStorageKey}"=${valueFromLocalStorage}`,
2480
- );
2481
- }
2482
- return valueFromLocalStorage;
2483
- }
2484
- if (sourceSignal) {
2485
- const sourceValue = sourceSignal.peek();
2486
- if (sourceValue !== undefined) {
2493
+ const getDefaultValue = () => {
2494
+ if (persists) {
2495
+ const valueFromLocalStorage = readFromLocalStorage();
2496
+ if (valueFromLocalStorage !== undefined) {
2487
2497
  if (debug) {
2488
2498
  console.debug(
2489
- `[stateSignal:${signalIdString}] using value from source signal=${sourceValue}`,
2499
+ `[stateSignal:${signalIdString}] using value from localStorage "${localStorageKey}"=${valueFromLocalStorage}`,
2490
2500
  );
2491
2501
  }
2492
- return sourceValue;
2502
+ return valueFromLocalStorage;
2493
2503
  }
2494
2504
  }
2505
+ if (dynamicDefaultSignal) {
2506
+ const dynamicValue = dynamicDefaultSignal.peek();
2507
+ if (dynamicValue === undefined) {
2508
+ return undefined;
2509
+ }
2510
+ if (debug) {
2511
+ console.debug(
2512
+ `[stateSignal:${signalIdString}] using value from dynamic default signal=${dynamicValue}`,
2513
+ );
2514
+ }
2515
+ return dynamicValue;
2516
+ }
2495
2517
  if (debug) {
2496
2518
  console.debug(
2497
- `[stateSignal:${signalIdString}] using default value=${defaultValue}`,
2519
+ `[stateSignal:${signalIdString}] using static default value=${staticDefaultValue}`,
2498
2520
  );
2499
2521
  }
2500
- return defaultValue;
2522
+ return staticDefaultValue;
2523
+ };
2524
+ const isCustomValue = (value) => {
2525
+ if (value === undefined) {
2526
+ return false;
2527
+ }
2528
+ if (dynamicDefaultSignal) {
2529
+ return value !== dynamicDefaultSignal.peek();
2530
+ }
2531
+ return value !== staticDefaultValue;
2501
2532
  };
2502
2533
 
2503
- const advancedSignal = signal(getFallbackValue());
2504
-
2505
- if (debug) {
2506
- console.debug(
2507
- `[stateSignal:${signalIdString}] created with initial value=${advancedSignal.peek()}`,
2508
- {
2509
- defaultValue,
2510
- hasSourceSignal: Boolean(sourceSignal),
2511
- persists,
2512
- localStorageKey: persists ? localStorageKey : undefined,
2513
- },
2514
- );
2515
- }
2516
-
2517
- // Set signal ID and create meaningful string representation
2518
- advancedSignal.__signalId = signalIdString;
2519
- advancedSignal.toString = () => `{navi_state_signal:${signalIdString}}`;
2520
-
2521
- // Store signal with its options for later route connection
2522
- globalSignalRegistry.set(signalIdString, {
2523
- signal: advancedSignal,
2524
- options: {
2525
- getFallbackValue,
2526
- defaultValue,
2527
- type,
2528
- persists,
2529
- localStorageKey,
2530
- debug,
2531
- ...options,
2532
- },
2533
- });
2534
-
2534
+ // Create signal with initial value: use stored value, or undefined to indicate no explicit value
2535
+ const advancedSignal = signal(getDefaultValue());
2535
2536
  const validity = { valid: true };
2536
2537
  advancedSignal.validity = validity;
2537
-
2538
- // ensure current value always fallback to
2539
- // 1. source signal
2540
- // 2. local storage
2541
- // 3. default value
2538
+ advancedSignal.__signalId = signalIdString;
2539
+ advancedSignal.toString = () => `{navi_state_signal:${signalIdString}}`;
2540
+ // 1. when signal value changes to undefined, it needs to fallback to default value
2541
+ // 2. when dynamic default changes and signal value is not custom, it needs to update
2542
2542
  {
2543
- let firstRun = true;
2543
+ let isFirstRun = true;
2544
2544
  effect(() => {
2545
2545
  const value = advancedSignal.value;
2546
- if (sourceSignal) {
2547
- // eslint-disable-next-line no-unused-expressions
2548
- sourceSignal.value;
2549
- }
2550
- if (firstRun) {
2551
- firstRun = true;
2546
+ if (isFirstRun) {
2547
+ isFirstRun = false;
2552
2548
  return;
2553
2549
  }
2554
2550
  if (value !== undefined) {
2555
2551
  return;
2556
2552
  }
2557
- advancedSignal.value = getFallbackValue();
2553
+ const defaultValue = getDefaultValue();
2554
+ if (defaultValue === value) {
2555
+ return;
2556
+ }
2557
+ if (debug) {
2558
+ console.debug(
2559
+ `[stateSignal:${signalIdString}] becomes undefined, reset to ${defaultValue}`,
2560
+ );
2561
+ }
2562
+ advancedSignal.value = defaultValue;
2558
2563
  });
2559
2564
  }
2560
- // When source signal value is updated, it overrides current signal value
2561
- source_signal_override: {
2562
- if (!sourceSignal) {
2563
- break source_signal_override;
2565
+ dynamic_signal_effect: {
2566
+ if (!dynamicDefaultSignal) {
2567
+ break dynamic_signal_effect;
2564
2568
  }
2565
-
2569
+ // here we listen only on the dynamic default signal
2566
2570
  let isFirstRun = true;
2567
- let sourcePreviousValue;
2571
+ let dynamicDefaultPreviousValue;
2568
2572
  effect(() => {
2569
- const sourceValue = sourceSignal.value;
2573
+ const value = advancedSignal.peek();
2574
+ const dynamicDefaultValue = dynamicDefaultSignal.value;
2570
2575
  if (isFirstRun) {
2571
- // first run
2572
2576
  isFirstRun = false;
2573
- sourcePreviousValue = sourceValue;
2577
+ dynamicDefaultPreviousValue = dynamicDefaultValue;
2574
2578
  return;
2575
2579
  }
2576
- if (sourceValue === undefined) {
2577
- // we don't have anything in the source signal, keep current value
2578
- if (debug) {
2579
- console.debug(
2580
- `[stateSignal:${signalIdString}] source signal is undefined, keeping current value=${advancedSignal.peek()}`,
2581
- {
2582
- sourcePreviousValue,
2583
- sourceValue,
2584
- currentValue: advancedSignal.peek(),
2585
- },
2586
- );
2587
- }
2588
- sourcePreviousValue = undefined;
2580
+ if (value !== dynamicDefaultPreviousValue) {
2581
+ dynamicDefaultPreviousValue = dynamicDefaultValue;
2582
+ return;
2583
+ }
2584
+ dynamicDefaultPreviousValue = dynamicDefaultValue;
2585
+ const defaultValue = getDefaultValue();
2586
+ if (defaultValue === value) {
2589
2587
  return;
2590
2588
  }
2591
- // the case we want to support: source signal value changes -> override current value
2592
2589
  if (debug) {
2593
2590
  console.debug(
2594
- `[stateSignal:${signalIdString}] source signal updated, overriding current value`,
2595
- {
2596
- sourcePreviousValue,
2597
- sourceValue,
2598
- previousValue: advancedSignal.peek(),
2599
- },
2591
+ `[stateSignal:${signalIdString}] dynamic default updated, update to ${defaultValue}`,
2600
2592
  );
2601
2593
  }
2602
- advancedSignal.value = sourceValue;
2603
- sourcePreviousValue = sourceValue;
2594
+ advancedSignal.value = defaultValue;
2604
2595
  });
2605
2596
  }
2606
- // Read/write into local storage when enabled
2607
2597
  persist_in_local_storage: {
2608
2598
  if (!localStorageKey) {
2609
2599
  break persist_in_local_storage;
2610
2600
  }
2611
2601
  effect(() => {
2612
2602
  const value = advancedSignal.value;
2613
- if (value === undefined || value === null || value === defaultValue) {
2603
+ if (isCustomValue(value)) {
2614
2604
  if (debug) {
2615
2605
  console.debug(
2616
- `[stateSignal:${signalIdString}] removing "${localStorageKey}" from localStorage (value=${value}, default=${defaultValue})`,
2606
+ `[stateSignal:${signalIdString}] writing into localStorage "${localStorageKey}"=${value}`,
2617
2607
  );
2618
2608
  }
2619
- removeFromLocalStorage();
2609
+ writeIntoLocalStorage(value);
2620
2610
  } else {
2621
2611
  if (debug) {
2622
2612
  console.debug(
2623
- `[stateSignal:${signalIdString}] writing into localStorage "${localStorageKey}"=${value}`,
2613
+ `[stateSignal:${signalIdString}] removing "${localStorageKey}" from localStorage (value=${value})`,
2624
2614
  );
2625
2615
  }
2626
- writeIntoLocalStorage(value);
2616
+ removeFromLocalStorage();
2627
2617
  }
2628
2618
  });
2629
2619
  }
2630
- // update validity object according to the advanced signal value
2620
+ // update validity object according to the signal value
2631
2621
  {
2632
2622
  effect(() => {
2633
- const value = advancedSignal.value;
2634
2623
  const wasValid = validity.valid;
2624
+ const value = advancedSignal.value;
2635
2625
  updateValidity({ oneOf }, validity, value);
2636
- if (!validity.valid) {
2637
- if (debug) {
2638
- console.debug(`[stateSignal:${signalIdString}] validation failed`, {
2639
- value,
2640
- oneOf,
2641
- hasAutoFix: Boolean(autoFix),
2642
- });
2643
- }
2644
- if (autoFix) {
2645
- const fixedValue = autoFix();
2626
+ if (validity.valid) {
2627
+ if (!wasValid) {
2646
2628
  if (debug) {
2647
2629
  console.debug(
2648
- `[stateSignal:${signalIdString}] auto-fixing invalid value`,
2649
- {
2650
- invalidValue: value,
2651
- fixedValue,
2652
- },
2630
+ `[stateSignal:${signalIdString}] validation now passes`,
2631
+ { value },
2653
2632
  );
2654
2633
  }
2655
- advancedSignal.value = fixedValue;
2656
- return;
2657
2634
  }
2658
- } else if (!wasValid && validity.valid) {
2635
+ return;
2636
+ }
2637
+ if (debug) {
2638
+ console.debug(`[stateSignal:${signalIdString}] validation failed`, {
2639
+ value,
2640
+ oneOf,
2641
+ hasAutoFix: Boolean(autoFix),
2642
+ });
2643
+ }
2644
+ if (autoFix) {
2645
+ const fixedValue = autoFix(value);
2659
2646
  if (debug) {
2660
2647
  console.debug(
2661
- `[stateSignal:${signalIdString}] validation now passes`,
2648
+ `[stateSignal:${signalIdString}] autoFix applied: ${value} → ${fixedValue}`,
2662
2649
  {
2663
2650
  value,
2651
+ fixedValue,
2664
2652
  },
2665
2653
  );
2666
2654
  }
2655
+ advancedSignal.value = fixedValue;
2656
+ return;
2667
2657
  }
2668
2658
  });
2669
2659
  }
2660
+ // Store signal with its options (used by route_pattern.js)
2661
+ globalSignalRegistry.set(signalIdString, {
2662
+ signal: advancedSignal,
2663
+ options: {
2664
+ getDefaultValue,
2665
+ defaultValue: staticDefaultValue,
2666
+ dynamicDefaultSignal,
2667
+ isCustomValue,
2668
+ type,
2669
+ persists,
2670
+ localStorageKey,
2671
+ debug,
2672
+ ...options,
2673
+ },
2674
+ });
2675
+ if (debug) {
2676
+ console.debug(
2677
+ `[stateSignal:${signalIdString}] created with initial value=${advancedSignal.value}`,
2678
+ {
2679
+ staticDefaultValue,
2680
+ hasDynamicDefault: Boolean(dynamicDefaultSignal),
2681
+ hasStoredValue: persists && readFromLocalStorage() !== undefined,
2682
+ persists,
2683
+ localStorageKey: persists ? localStorageKey : undefined,
2684
+ },
2685
+ );
2686
+ }
2670
2687
 
2671
2688
  return advancedSignal;
2672
2689
  };
@@ -7831,21 +7848,26 @@ const createRoutePattern = (pattern) => {
7831
7848
 
7832
7849
  /**
7833
7850
  * Helper: Filter out default values from parameters for cleaner URLs
7851
+ *
7852
+ * This function removes parameters that match their default values (static or dynamic)
7853
+ * while preserving custom values and inherited parameters from ancestor routes.
7854
+ * Parameter inheritance from parent routes is intentional - only default values
7855
+ * for the current route's own parameters are filtered out.
7834
7856
  */
7835
7857
  const removeDefaultValues = (params) => {
7836
7858
  const filtered = { ...params };
7837
7859
 
7838
7860
  for (const connection of connections) {
7839
- const { paramName, signal } = connection;
7840
- const defaultValue = parameterDefaults.get(paramName);
7861
+ const { paramName, signal, options } = connection;
7841
7862
 
7842
- if (paramName in filtered && filtered[paramName] === defaultValue) {
7843
- delete filtered[paramName];
7844
- } else if (
7845
- !(paramName in filtered) &&
7846
- signal?.value !== undefined &&
7847
- signal.value !== defaultValue
7848
- ) {
7863
+ if (paramName in filtered) {
7864
+ // Parameter is explicitly provided - check if we should remove it
7865
+ if (!options.isCustomValue?.(filtered[paramName])) {
7866
+ // Parameter value is not custom (matches default) - remove it
7867
+ delete filtered[paramName];
7868
+ }
7869
+ } else if (options.isCustomValue?.(signal.value)) {
7870
+ // Parameter not provided but signal has custom value - add it
7849
7871
  filtered[paramName] = signal.value;
7850
7872
  }
7851
7873
  }
@@ -7864,7 +7886,7 @@ const createRoutePattern = (pattern) => {
7864
7886
  const effectiveValue = userValue !== undefined ? userValue : signalValue;
7865
7887
  return (
7866
7888
  effectiveValue === literalValue &&
7867
- effectiveValue !== conn.options.defaultValue
7889
+ conn.options.isCustomValue?.(effectiveValue)
7868
7890
  );
7869
7891
  });
7870
7892
 
@@ -7902,7 +7924,7 @@ const createRoutePattern = (pattern) => {
7902
7924
  const signalValue = conn.signal?.value;
7903
7925
  return (
7904
7926
  signalValue === literalValue &&
7905
- signalValue !== conn.options.defaultValue
7927
+ conn.options.isCustomValue?.(signalValue)
7906
7928
  );
7907
7929
  });
7908
7930
 
@@ -8045,10 +8067,10 @@ const createRoutePattern = (pattern) => {
8045
8067
  } else {
8046
8068
  const { paramName: name, signal, options } = item;
8047
8069
  paramName = name;
8048
- // Only include non-default parent signal values
8070
+ // Only include custom parent signal values (not using defaults)
8049
8071
  if (
8050
8072
  signal?.value === undefined ||
8051
- signal.value === options.defaultValue
8073
+ !options.isCustomValue?.(signal.value)
8052
8074
  ) {
8053
8075
  return { isCompatible: true, shouldInclude: false };
8054
8076
  }
@@ -8198,7 +8220,6 @@ const createRoutePattern = (pattern) => {
8198
8220
 
8199
8221
  for (const connection of childPatternObj.connections) {
8200
8222
  const { paramName, signal, options } = connection;
8201
- const defaultValue = options.defaultValue;
8202
8223
 
8203
8224
  // Check if parameter was explicitly provided by user
8204
8225
  const hasExplicitParam = paramName in params;
@@ -8207,13 +8228,16 @@ const createRoutePattern = (pattern) => {
8207
8228
  if (hasExplicitParam) {
8208
8229
  // User explicitly provided this parameter - use their value
8209
8230
  childParams[paramName] = explicitValue;
8210
- if (explicitValue !== undefined && explicitValue !== defaultValue) {
8231
+ if (
8232
+ explicitValue !== undefined &&
8233
+ options.isCustomValue?.(explicitValue)
8234
+ ) {
8211
8235
  hasActiveParams = true;
8212
8236
  }
8213
8237
  } else if (signal?.value !== undefined) {
8214
8238
  // No explicit override - use signal value
8215
8239
  childParams[paramName] = signal.value;
8216
- if (signal.value !== defaultValue) {
8240
+ if (options.isCustomValue?.(signal.value)) {
8217
8241
  hasActiveParams = true;
8218
8242
  }
8219
8243
  }
@@ -8293,7 +8317,7 @@ const createRoutePattern = (pattern) => {
8293
8317
  // Check if parameters that determine child selection are non-default
8294
8318
  // OR if any descendant parameters indicate explicit navigation
8295
8319
  for (const connection of connections) {
8296
- const { paramName } = connection;
8320
+ const { paramName, options } = connection;
8297
8321
  const defaultValue = parameterDefaults.get(paramName);
8298
8322
  const resolvedValue = resolvedParams[paramName];
8299
8323
  const userProvidedParam = paramName in params;
@@ -8302,9 +8326,10 @@ const createRoutePattern = (pattern) => {
8302
8326
  // This literal corresponds to a parameter in the parent
8303
8327
  if (
8304
8328
  userProvidedParam ||
8305
- (resolvedValue !== undefined && resolvedValue !== defaultValue)
8329
+ (resolvedValue !== undefined &&
8330
+ options.isCustomValue?.(resolvedValue))
8306
8331
  ) {
8307
- // Parameter was explicitly provided or has non-default value - child is needed
8332
+ // Parameter was explicitly provided or has custom value - child is needed
8308
8333
  childSpecificParamsAreDefaults = false;
8309
8334
  break;
8310
8335
  }
@@ -8405,7 +8430,7 @@ const createRoutePattern = (pattern) => {
8405
8430
  // If explicitly undefined, don't include it (which means don't use child route)
8406
8431
  } else if (
8407
8432
  signal?.value !== undefined &&
8408
- signal.value !== options.defaultValue
8433
+ options.isCustomValue?.(signal.value)
8409
8434
  ) {
8410
8435
  // No explicit override - use signal value if non-default
8411
8436
  baseParams[paramName] = signal.value;
@@ -8423,7 +8448,6 @@ const createRoutePattern = (pattern) => {
8423
8448
  // Add parent's signal parameters
8424
8449
  for (const connection of parentPatternObj.connections) {
8425
8450
  const { paramName, signal, options } = connection;
8426
- const defaultValue = options.defaultValue;
8427
8451
 
8428
8452
  // Skip if child route already handles this parameter
8429
8453
  const childConnection = childPatternObj.connections.find(
@@ -8438,8 +8462,11 @@ const createRoutePattern = (pattern) => {
8438
8462
  continue; // Already have this parameter
8439
8463
  }
8440
8464
 
8441
- // Only include non-default signal values
8442
- if (signal?.value !== undefined && signal.value !== defaultValue) {
8465
+ // Only include custom signal values (not using defaults)
8466
+ if (
8467
+ signal?.value !== undefined &&
8468
+ options.isCustomValue?.(signal.value)
8469
+ ) {
8443
8470
  // Skip if parameter is consumed by child's literal path segments
8444
8471
  const isConsumedByChildPath = childPatternObj.pattern.segments.some(
8445
8472
  (segment) =>
@@ -8505,10 +8532,9 @@ const createRoutePattern = (pattern) => {
8505
8532
 
8506
8533
  if (childConnection) {
8507
8534
  const { options } = childConnection;
8508
- const defaultValue = options.defaultValue;
8509
8535
 
8510
- // Only include if it's NOT the signal's default value
8511
- if (userValue !== defaultValue) {
8536
+ // Only include if it's a custom value (not default)
8537
+ if (options.isCustomValue?.(userValue)) {
8512
8538
  baseParams[paramName] = userValue;
8513
8539
  } else {
8514
8540
  // User provided the default value - complete omission
@@ -8568,7 +8594,7 @@ const createRoutePattern = (pattern) => {
8568
8594
  const hasNonDefaultChildParams = (childPatternObj.connections || []).some(
8569
8595
  (childConnection) => {
8570
8596
  const { signal, options } = childConnection;
8571
- return signal?.value !== options.defaultValue;
8597
+ return options.isCustomValue?.(signal?.value);
8572
8598
  },
8573
8599
  );
8574
8600
 
@@ -8778,13 +8804,15 @@ const createRoutePattern = (pattern) => {
8778
8804
  // 2. The source route has only query parameters that are non-default
8779
8805
  const hasNonDefaultPathParams = connections.some((connection) => {
8780
8806
  const resolvedValue = resolvedParams[connection.paramName];
8781
- const defaultValue = connection.options.defaultValue;
8807
+
8782
8808
  // Check if this is a query parameter (not in the pattern path)
8783
8809
  const isQueryParam = parsedPattern.queryParams.some(
8784
8810
  (qp) => qp.name === connection.paramName,
8785
8811
  );
8786
8812
  // Allow non-default query parameters, but not path parameters
8787
- return !isQueryParam && resolvedValue !== defaultValue;
8813
+ return (
8814
+ !isQueryParam && connection.options.isCustomValue?.(resolvedValue)
8815
+ );
8788
8816
  });
8789
8817
 
8790
8818
  if (hasNonDefaultPathParams) {
@@ -8814,8 +8842,7 @@ const createRoutePattern = (pattern) => {
8814
8842
  // For non-immediate parents, only allow optimization if all resolved parameters have default values
8815
8843
  const hasNonDefaultParameters = connections.some((connection) => {
8816
8844
  const resolvedValue = resolvedParams[connection.paramName];
8817
- const defaultValue = connection.options.defaultValue;
8818
- return resolvedValue !== defaultValue;
8845
+ return connection.options.isCustomValue?.(resolvedValue);
8819
8846
  });
8820
8847
 
8821
8848
  if (hasNonDefaultParameters) {
@@ -9084,13 +9111,12 @@ const createRoutePattern = (pattern) => {
9084
9111
  // Also check target ancestor's own signal values for parameters not in resolvedParams
9085
9112
  for (const connection of targetAncestor.connections) {
9086
9113
  const { paramName, signal, options } = connection;
9087
- const defaultValue = options.defaultValue;
9088
9114
 
9089
- // Only include if not already processed and has non-default value
9115
+ // Only include if not already processed and has custom value (not default)
9090
9116
  if (
9091
9117
  !(paramName in ancestorParams) &&
9092
9118
  signal?.value !== undefined &&
9093
- signal.value !== defaultValue
9119
+ options.isCustomValue?.(signal.value)
9094
9120
  ) {
9095
9121
  // Don't include path parameters that correspond to literal segments we're optimizing away
9096
9122
  const targetParam = targetParams.find((p) => p.name === paramName);
@@ -9120,13 +9146,12 @@ const createRoutePattern = (pattern) => {
9120
9146
  while (currentParent) {
9121
9147
  for (const connection of currentParent.connections) {
9122
9148
  const { paramName, signal, options } = connection;
9123
- const defaultValue = options.defaultValue;
9124
9149
 
9125
- // Only inherit non-default values that we don't already have
9150
+ // Only inherit custom values (not defaults) that we don't already have
9126
9151
  if (
9127
9152
  !(paramName in ancestorParams) &&
9128
9153
  signal?.value !== undefined &&
9129
- signal.value !== defaultValue
9154
+ options.isCustomValue?.(signal.value)
9130
9155
  ) {
9131
9156
  // Check if this parameter would be redundant with target ancestor's literal segments
9132
9157
  const isRedundant = isParameterRedundantWithLiteralSegments(
@@ -9201,13 +9226,12 @@ const createRoutePattern = (pattern) => {
9201
9226
  // Check parent's signal connections for non-default values to inherit
9202
9227
  for (const parentConnection of currentParent.connections) {
9203
9228
  const { paramName, signal, options } = parentConnection;
9204
- const defaultValue = options.defaultValue;
9205
9229
 
9206
- // Only inherit if we don't have this param and parent has non-default value
9230
+ // Only inherit if we don't have this param and parent has custom value (not default)
9207
9231
  if (
9208
9232
  !(paramName in finalParams) &&
9209
9233
  signal?.value !== undefined &&
9210
- signal.value !== defaultValue
9234
+ options.isCustomValue?.(signal.value)
9211
9235
  ) {
9212
9236
  // Don't inherit if parameter corresponds to a literal in our path
9213
9237
  const shouldInherit = !isParameterRedundantWithLiteralSegments(
@@ -9691,6 +9715,21 @@ const extractSearchParams = (urlObj, connections = []) => {
9691
9715
  /**
9692
9716
  * Build query parameters respecting hierarchical order from ancestor patterns
9693
9717
  */
9718
+ /**
9719
+ * Build hierarchical query parameters from pattern hierarchy
9720
+ *
9721
+ * IMPORTANT: This function implements parameter inheritance - child routes inherit
9722
+ * query parameters from their ancestor routes. This is intentional behavior that
9723
+ * allows child routes to preserve context from parent routes.
9724
+ *
9725
+ * For example:
9726
+ * - Parent route: /map/?lon=123
9727
+ * - Child route: /map/isochrone?iso_lon=456
9728
+ * - Final URL: /map/isochrone?lon=123&iso_lon=456
9729
+ *
9730
+ * The child route inherits 'lon' from its parent, maintaining navigation context.
9731
+ * Only parameters that match their defaults (static or dynamic) are omitted.
9732
+ */
9694
9733
  const buildHierarchicalQueryParams = (
9695
9734
  parsedPattern,
9696
9735
  params,
@@ -10287,12 +10326,15 @@ const updateRoutes = (
10287
10326
  if (newMatching) {
10288
10327
  // When route matches, sync signal with URL parameter value
10289
10328
  // This ensures URL is the source of truth
10290
- if (debug) {
10291
- console.debug(
10292
- `[route] Route matching: setting ${paramName} signal to URL value: ${urlParamValue}`,
10293
- );
10329
+ const currentValue = stateSignal.peek();
10330
+ if (currentValue !== urlParamValue) {
10331
+ if (debug) {
10332
+ console.debug(
10333
+ `[route] Route matching: setting ${paramName} signal to URL value: ${urlParamValue}`,
10334
+ );
10335
+ }
10336
+ stateSignal.value = urlParamValue;
10294
10337
  }
10295
- stateSignal.value = urlParamValue;
10296
10338
  } else {
10297
10339
  // Route doesn't match - check if any matching route extracts this parameter
10298
10340
  let parameterExtractedByMatchingRoute = false;