@jsenv/navi 0.16.24 → 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
  };
@@ -7723,7 +7739,11 @@ const createRoutePattern = (pattern) => {
7723
7739
  }
7724
7740
  }
7725
7741
 
7726
- const parsedPattern = parsePattern(cleanPattern, parameterDefaults);
7742
+ const parsedPattern = parsePattern(
7743
+ cleanPattern,
7744
+ parameterDefaults,
7745
+ connections,
7746
+ );
7727
7747
 
7728
7748
  // Create signalSet to track all signals this pattern depends on
7729
7749
  const signalSet = new Set();
@@ -9334,7 +9354,11 @@ const canParameterReachChildRoute = (
9334
9354
  /**
9335
9355
  * Parse a route pattern string into structured segments
9336
9356
  */
9337
- const parsePattern = (pattern, parameterDefaults = new Map()) => {
9357
+ const parsePattern = (
9358
+ pattern,
9359
+ parameterDefaults = new Map(),
9360
+ connections = [],
9361
+ ) => {
9338
9362
  // Handle root route
9339
9363
  if (pattern === "/") {
9340
9364
  return {
@@ -9403,7 +9427,27 @@ const parsePattern = (pattern, parameterDefaults = new Map()) => {
9403
9427
  if (seg.startsWith(":")) {
9404
9428
  // Parameter segment
9405
9429
  const paramName = seg.slice(1).replace("?", ""); // Remove : and optional ?
9406
- const isOptional = seg.endsWith("?") || parameterDefaults.has(paramName);
9430
+
9431
+ // Check if parameter should be optional:
9432
+ // 1. Explicitly marked with ?
9433
+ // 2. Has a default value
9434
+ // 3. Connected signal has undefined value and no explicit default (allows /map to match /map/:panel)
9435
+ let isOptional = seg.endsWith("?") || parameterDefaults.has(paramName);
9436
+
9437
+ if (!isOptional) {
9438
+ // Check if connected signal has undefined value (making parameter optional for index routes)
9439
+ const connection = connections.find(
9440
+ (conn) => conn.paramName === paramName,
9441
+ );
9442
+ if (
9443
+ connection &&
9444
+ connection.signal &&
9445
+ connection.signal.value === undefined &&
9446
+ !parameterDefaults.has(paramName)
9447
+ ) {
9448
+ isOptional = true;
9449
+ }
9450
+ }
9407
9451
 
9408
9452
  return {
9409
9453
  type: "param",
@@ -9597,14 +9641,18 @@ const matchUrl = (
9597
9641
  // Check for remaining URL segments
9598
9642
  // Patterns with trailing slashes can match additional URL segments (like wildcards)
9599
9643
  // Patterns without trailing slashes should match exactly (unless they're wildcards)
9644
+ // BUT: if pattern has children, it can also match additional segments (hierarchical matching)
9645
+ const hasChildren =
9646
+ patternObj && patternObj.children && patternObj.children.length > 0;
9600
9647
  if (
9601
9648
  !parsedPattern.wildcard &&
9602
9649
  !parsedPattern.trailingSlash &&
9650
+ !hasChildren &&
9603
9651
  urlSegmentIndex < urlSegments.length
9604
9652
  ) {
9605
- return null; // Pattern without trailing slash should not match extra segments
9653
+ return null; // Pattern without trailing slash/wildcard/children should not match extra segments
9606
9654
  }
9607
- // If pattern has trailing slash or wildcard, allow extra segments (no additional check needed)
9655
+ // If pattern has trailing slash, wildcard, or children, allow extra segments
9608
9656
 
9609
9657
  // Add search parameters
9610
9658
  const searchParams = extractSearchParams(urlObj, connections);
@@ -10255,84 +10303,117 @@ const updateRoutes = (
10255
10303
  if (newMatching) {
10256
10304
  // When route matches, sync signal with URL parameter value
10257
10305
  // This ensures URL is the source of truth
10258
- if (debug) {
10259
- console.debug(
10260
- `[route] Route matching: setting ${paramName} signal to URL value: ${urlParamValue}`,
10261
- );
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;
10262
10314
  }
10263
- stateSignal.value = urlParamValue;
10264
10315
  } else {
10265
- // When route doesn't match, check if we're navigating to a parent route
10266
- let parentRouteMatching = false;
10316
+ // Route doesn't match - check if any matching route extracts this parameter
10317
+ let parameterExtractedByMatchingRoute = false;
10318
+ let matchingRouteInSameFamily = false;
10319
+
10267
10320
  for (const otherRoute of routeSet) {
10268
10321
  if (otherRoute === route || !otherRoute.matching) {
10269
10322
  continue;
10270
10323
  }
10271
10324
  const otherRouteProperties = getRoutePrivateProperties(otherRoute);
10325
+ const otherParams = otherRouteProperties.rawParamsSignal.value;
10326
+
10327
+ // Check if this matching route extracts the parameter
10328
+ if (paramName in otherParams) {
10329
+ parameterExtractedByMatchingRoute = true;
10330
+ }
10331
+
10332
+ // Check if this matching route is in the same family using parent-child relationships
10333
+ const thisPatternObj = routePattern;
10272
10334
  const otherPatternObj = otherRouteProperties.routePattern;
10273
10335
 
10274
- // Check if the other route pattern is a parent of this route pattern
10275
- // Using the built relationships in the pattern objects
10276
- let currentParent = routePattern.parent;
10277
- let foundParent = false;
10336
+ // Routes are in same family if they share a hierarchical relationship:
10337
+ // 1. One is parent/ancestor of the other
10338
+ // 2. They share a common parent/ancestor
10339
+ let inSameFamily = false;
10340
+
10341
+ // Check if other route is ancestor of this route
10342
+ let currentParent = thisPatternObj.parent;
10278
10343
  while (currentParent) {
10279
10344
  if (currentParent === otherPatternObj) {
10280
- foundParent = true;
10345
+ inSameFamily = true;
10281
10346
  break;
10282
10347
  }
10283
10348
  currentParent = currentParent.parent;
10284
10349
  }
10285
10350
 
10286
- if (!foundParent) {
10287
- continue;
10351
+ // Check if this route is ancestor of other route
10352
+ if (!inSameFamily) {
10353
+ currentParent = otherPatternObj.parent;
10354
+ while (currentParent) {
10355
+ if (currentParent === thisPatternObj) {
10356
+ inSameFamily = true;
10357
+ break;
10358
+ }
10359
+ currentParent = currentParent.parent;
10360
+ }
10288
10361
  }
10289
10362
 
10290
- // Found a parent route that's matching, but check if there's a more specific
10291
- // sibling route also matching (indicating sibling navigation, not parent navigation)
10292
- let hasMatchingSibling = false;
10293
- for (const siblingCandidateRoute of routeSet) {
10294
- if (
10295
- siblingCandidateRoute === route ||
10296
- siblingCandidateRoute === otherRoute ||
10297
- !siblingCandidateRoute.matching
10298
- ) {
10299
- continue;
10363
+ // Check if they share a common parent (siblings or cousins)
10364
+ if (!inSameFamily) {
10365
+ const thisAncestors = new Set();
10366
+ currentParent = thisPatternObj.parent;
10367
+ while (currentParent) {
10368
+ thisAncestors.add(currentParent);
10369
+ currentParent = currentParent.parent;
10300
10370
  }
10301
10371
 
10302
- const siblingProperties = getRoutePrivateProperties(
10303
- siblingCandidateRoute,
10304
- );
10305
- const siblingPatternObj = siblingProperties.routePattern;
10306
-
10307
- // Check if this is a sibling (shares the same parent)
10308
- if (siblingPatternObj.parent === currentParent) {
10309
- hasMatchingSibling = true;
10310
- break;
10372
+ currentParent = otherPatternObj.parent;
10373
+ while (currentParent) {
10374
+ if (thisAncestors.has(currentParent)) {
10375
+ inSameFamily = true;
10376
+ break;
10377
+ }
10378
+ currentParent = currentParent.parent;
10311
10379
  }
10312
10380
  }
10313
10381
 
10314
- // Only treat as parent navigation if no sibling is matching
10315
- if (!hasMatchingSibling) {
10316
- parentRouteMatching = true;
10317
- break; // Found the parent route, no need to check other routes
10382
+ if (inSameFamily) {
10383
+ matchingRouteInSameFamily = true;
10318
10384
  }
10319
10385
  }
10320
10386
 
10321
- if (parentRouteMatching) {
10322
- // We're navigating to a parent route - clear this signal to reflect the hierarchy
10387
+ // Only reset signal if:
10388
+ // 1. We're navigating within the same route family (not to completely unrelated routes)
10389
+ // 2. AND no matching route extracts this parameter from URL
10390
+ // 3. AND parameter has no default value (making it truly optional)
10391
+ if (matchingRouteInSameFamily && !parameterExtractedByMatchingRoute) {
10323
10392
  const defaultValue = routePattern.parameterDefaults?.get(paramName);
10324
- if (debug) {
10393
+ if (defaultValue === undefined) {
10394
+ // Parameter is not extracted within same family and has no default - reset it
10395
+ if (debug) {
10396
+ console.debug(
10397
+ `[route] Same family navigation, ${paramName} not extracted and has no default: resetting signal`,
10398
+ );
10399
+ }
10400
+ stateSignal.value = undefined;
10401
+ } else if (debug) {
10402
+ // Parameter has a default value - preserve current signal value
10325
10403
  console.debug(
10326
- `[route] Parent route ${parentRouteMatching} matching: clearing ${paramName} signal to default: ${defaultValue}`,
10404
+ `[route] Parameter ${paramName} has default value ${defaultValue}: preserving signal value: ${stateSignal.value}`,
10327
10405
  );
10328
10406
  }
10329
- stateSignal.value = defaultValue;
10330
10407
  } else if (debug) {
10331
- // We're navigating to a different route family - preserve signal for future URL building
10332
- // Keep current signal value unchanged
10333
- console.debug(
10334
- `[route] Different route family: preserving ${paramName} signal value: ${stateSignal.value}`,
10335
- );
10408
+ if (!matchingRouteInSameFamily) {
10409
+ console.debug(
10410
+ `[route] Different route family: preserving ${paramName} signal value: ${stateSignal.value}`,
10411
+ );
10412
+ } else {
10413
+ console.debug(
10414
+ `[route] Parameter ${paramName} extracted by matching route: preserving signal value: ${stateSignal.value}`,
10415
+ );
10416
+ }
10336
10417
  }
10337
10418
  }
10338
10419
  }