@jsenv/navi 0.16.25 → 0.16.26

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,199 @@ 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
+ type,
2668
+ persists,
2669
+ localStorageKey,
2670
+ debug,
2671
+ ...options,
2672
+ },
2673
+ });
2674
+ if (debug) {
2675
+ console.debug(
2676
+ `[stateSignal:${signalIdString}] created with initial value=${advancedSignal.value}`,
2677
+ {
2678
+ staticDefaultValue,
2679
+ hasDynamicDefault: Boolean(dynamicDefaultSignal),
2680
+ hasStoredValue: persists && readFromLocalStorage() !== undefined,
2681
+ persists,
2682
+ localStorageKey: persists ? localStorageKey : undefined,
2683
+ },
2684
+ );
2685
+ }
2670
2686
 
2671
2687
  return advancedSignal;
2672
2688
  };
@@ -10287,12 +10303,15 @@ const updateRoutes = (
10287
10303
  if (newMatching) {
10288
10304
  // When route matches, sync signal with URL parameter value
10289
10305
  // 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
- );
10306
+ const currentValue = stateSignal.peek();
10307
+ if (currentValue !== urlParamValue) {
10308
+ if (debug) {
10309
+ console.debug(
10310
+ `[route] Route matching: setting ${paramName} signal to URL value: ${urlParamValue}`,
10311
+ );
10312
+ }
10313
+ stateSignal.value = urlParamValue;
10294
10314
  }
10295
- stateSignal.value = urlParamValue;
10296
10315
  } else {
10297
10316
  // Route doesn't match - check if any matching route extracts this parameter
10298
10317
  let parameterExtractedByMatchingRoute = false;