@jsenv/navi 0.16.6 → 0.16.7

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.
@@ -2459,8 +2459,9 @@ const stateSignal = (defaultValue, options = {}) => {
2459
2459
  // Convert numeric IDs to strings for consistency
2460
2460
  const signalIdString = String(signalId);
2461
2461
  if (globalSignalRegistry.has(signalIdString)) {
2462
+ const conflictInfo = globalSignalRegistry.get(signalIdString);
2462
2463
  throw new Error(
2463
- `Signal ID conflict: A signal with ID "${signalIdString}" already exists`,
2464
+ `Signal ID conflict: A signal with ID "${signalIdString}" already exists (existing default: ${conflictInfo.options.defaultValue})`,
2464
2465
  );
2465
2466
  }
2466
2467
 
@@ -2475,7 +2476,7 @@ const stateSignal = (defaultValue, options = {}) => {
2475
2476
  if (valueFromLocalStorage !== undefined) {
2476
2477
  if (debug) {
2477
2478
  console.debug(
2478
- `[stateSignal] using value from localStorage "${localStorageKey}"=${valueFromLocalStorage}`,
2479
+ `[stateSignal:${signalIdString}] using value from localStorage "${localStorageKey}"=${valueFromLocalStorage}`,
2479
2480
  );
2480
2481
  }
2481
2482
  return valueFromLocalStorage;
@@ -2485,20 +2486,34 @@ const stateSignal = (defaultValue, options = {}) => {
2485
2486
  if (sourceValue !== undefined) {
2486
2487
  if (debug) {
2487
2488
  console.debug(
2488
- `[stateSignal] using value from source signal=${sourceValue}`,
2489
+ `[stateSignal:${signalIdString}] using value from source signal=${sourceValue}`,
2489
2490
  );
2490
2491
  }
2491
2492
  return sourceValue;
2492
2493
  }
2493
2494
  }
2494
2495
  if (debug) {
2495
- console.debug(`[stateSignal] using default value=${defaultValue}`);
2496
+ console.debug(
2497
+ `[stateSignal:${signalIdString}] using default value=${defaultValue}`,
2498
+ );
2496
2499
  }
2497
2500
  return defaultValue;
2498
2501
  };
2499
2502
 
2500
2503
  const advancedSignal = signal(getFallbackValue());
2501
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
+
2502
2517
  // Set signal ID and create meaningful string representation
2503
2518
  advancedSignal.__signalId = signalIdString;
2504
2519
  advancedSignal.toString = () => `{navi_state_signal:${signalIdString}}`;
@@ -2562,10 +2577,11 @@ const stateSignal = (defaultValue, options = {}) => {
2562
2577
  // we don't have anything in the source signal, keep current value
2563
2578
  if (debug) {
2564
2579
  console.debug(
2565
- `[stateSignal] source signal is undefined, keeping current value`,
2580
+ `[stateSignal:${signalIdString}] source signal is undefined, keeping current value=${advancedSignal.peek()}`,
2566
2581
  {
2567
2582
  sourcePreviousValue,
2568
2583
  sourceValue,
2584
+ currentValue: advancedSignal.peek(),
2569
2585
  },
2570
2586
  );
2571
2587
  }
@@ -2574,10 +2590,14 @@ const stateSignal = (defaultValue, options = {}) => {
2574
2590
  }
2575
2591
  // the case we want to support: source signal value changes -> override current value
2576
2592
  if (debug) {
2577
- console.debug(`[stateSignal] source signal updated`, {
2578
- sourcePreviousValue,
2579
- sourceValue,
2580
- });
2593
+ console.debug(
2594
+ `[stateSignal:${signalIdString}] source signal updated, overriding current value`,
2595
+ {
2596
+ sourcePreviousValue,
2597
+ sourceValue,
2598
+ previousValue: advancedSignal.peek(),
2599
+ },
2600
+ );
2581
2601
  }
2582
2602
  advancedSignal.value = sourceValue;
2583
2603
  sourcePreviousValue = sourceValue;
@@ -2593,14 +2613,14 @@ const stateSignal = (defaultValue, options = {}) => {
2593
2613
  if (value === undefined || value === null || value === defaultValue) {
2594
2614
  if (debug) {
2595
2615
  console.debug(
2596
- `[stateSignal] removing "${localStorageKey}" from localStorage`,
2616
+ `[stateSignal:${signalIdString}] removing "${localStorageKey}" from localStorage (value=${value}, default=${defaultValue})`,
2597
2617
  );
2598
2618
  }
2599
2619
  removeFromLocalStorage();
2600
2620
  } else {
2601
2621
  if (debug) {
2602
2622
  console.debug(
2603
- `[stateSignal] writing into localStorage "${localStorageKey}"=${value}`,
2623
+ `[stateSignal:${signalIdString}] writing into localStorage "${localStorageKey}"=${value}`,
2604
2624
  );
2605
2625
  }
2606
2626
  writeIntoLocalStorage(value);
@@ -2611,10 +2631,39 @@ const stateSignal = (defaultValue, options = {}) => {
2611
2631
  {
2612
2632
  effect(() => {
2613
2633
  const value = advancedSignal.value;
2634
+ const wasValid = validity.valid;
2614
2635
  updateValidity({ oneOf }, validity, value);
2615
- if (!validity.valid && autoFix) {
2616
- advancedSignal.value = autoFix();
2617
- return;
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();
2646
+ if (debug) {
2647
+ console.debug(
2648
+ `[stateSignal:${signalIdString}] auto-fixing invalid value`,
2649
+ {
2650
+ invalidValue: value,
2651
+ fixedValue,
2652
+ },
2653
+ );
2654
+ }
2655
+ advancedSignal.value = fixedValue;
2656
+ return;
2657
+ }
2658
+ } else if (!wasValid && validity.valid) {
2659
+ if (debug) {
2660
+ console.debug(
2661
+ `[stateSignal:${signalIdString}] validation now passes`,
2662
+ {
2663
+ value,
2664
+ },
2665
+ );
2666
+ }
2618
2667
  }
2619
2668
  });
2620
2669
  }
@@ -7642,213 +7691,409 @@ const createRoutePattern = (pattern) => {
7642
7691
  * Build the most precise URL by using route relationships from pattern registry.
7643
7692
  * Each route is responsible for its own URL generation using its own signals.
7644
7693
  */
7645
- const buildMostPreciseUrl = (params = {}) => {
7646
- // Handle parameter resolution internally to preserve user intent detection
7647
- const resolvedParams = resolveParams(params);
7648
7694
 
7649
- // Start with resolved parameters
7650
- let finalParams = { ...resolvedParams };
7695
+ /**
7696
+ * Helper: Filter out default values from parameters for cleaner URLs
7697
+ */
7698
+ const removeDefaultValues = (params) => {
7699
+ const filtered = { ...params };
7651
7700
 
7652
7701
  for (const connection of connections) {
7653
7702
  const { paramName, signal, options } = connection;
7654
7703
  const defaultValue = options.defaultValue;
7655
7704
 
7656
- if (paramName in finalParams) {
7657
- // Parameter was explicitly provided - ALWAYS respect explicit values
7658
- // If it equals the default value, remove it for shorter URLs
7659
- if (finalParams[paramName] === defaultValue) {
7660
- delete finalParams[paramName];
7661
- }
7662
- // Note: Don't fall through to signal logic - explicit params take precedence
7663
- }
7664
- // Parameter was NOT provided, check signal value
7665
- else if (signal?.value !== undefined && signal.value !== defaultValue) {
7666
- // Only include signal value if it's not the default
7667
- finalParams[paramName] = signal.value;
7668
- // If signal.value === defaultValue, omit the parameter for shorter URL
7705
+ if (paramName in filtered && filtered[paramName] === defaultValue) {
7706
+ delete filtered[paramName];
7707
+ } else if (
7708
+ !(paramName in filtered) &&
7709
+ signal?.value !== undefined &&
7710
+ signal.value !== defaultValue
7711
+ ) {
7712
+ filtered[paramName] = signal.value;
7669
7713
  }
7670
7714
  }
7671
7715
 
7672
- // DEEPEST URL GENERATION: Check if we should use a child route instead
7673
- // This happens when:
7674
- // 1. This route's parameters are all defaults (would be omitted)
7675
- // 2. A child route has non-default parameters that should be included
7716
+ return filtered;
7717
+ };
7676
7718
 
7677
- // DEEPEST URL GENERATION: Only activate when NO explicit parameters provided
7678
- // This prevents overriding explicit user intentions with signal-based "smart" routing
7679
- // We need to distinguish between user-provided params and signal-derived params
7680
- let hasUserProvidedParams = false;
7719
+ /**
7720
+ * Helper: Find the best child route that matches current parameters and signals
7721
+ */
7722
+ const findBestChildRoute = (params, relationships) => {
7723
+ const childPatternObjs = relationships?.childPatterns || [];
7724
+ if (pattern === "/" || !childPatternObjs.length) {
7725
+ return null;
7726
+ }
7681
7727
 
7682
- // Check if provided params contain anything beyond what signals would provide
7683
- const signalDerivedParams = {};
7684
- for (const { paramName, signal, options } of connections) {
7685
- if (signal?.value !== undefined) {
7686
- const defaultValue = options.defaultValue;
7687
- // Only include signal value if it's not the default (same logic as above)
7688
- if (signal.value !== defaultValue) {
7689
- signalDerivedParams[paramName] = signal.value;
7690
- }
7728
+ // Try each child pattern object to find the most specific match
7729
+ for (const childPatternObj of childPatternObjs) {
7730
+ const childRouteCandidate = evaluateChildRoute(childPatternObj, params);
7731
+
7732
+ if (childRouteCandidate) {
7733
+ return childRouteCandidate;
7691
7734
  }
7692
7735
  }
7736
+ return null;
7737
+ };
7693
7738
 
7694
- // Check if original params (before resolution) contains anything that's not from signals
7695
- // This preserves user intent detection for explicit parameters
7696
- for (const [key, value] of Object.entries(params)) {
7697
- if (signalDerivedParams[key] !== value) {
7698
- hasUserProvidedParams = true;
7739
+ /**
7740
+ * Helper: Evaluate if a specific child route is suitable for current params/signals
7741
+ */
7742
+ const evaluateChildRoute = (childPatternObj, params) => {
7743
+ // Step 1: Check parameter compatibility
7744
+ const compatibility = checkChildRouteCompatibility(childPatternObj, params);
7745
+ if (!compatibility.isCompatible) {
7746
+ return null;
7747
+ }
7748
+
7749
+ // Step 2: Determine if child route should be used
7750
+ const shouldUseChild = shouldUseChildRoute(
7751
+ childPatternObj,
7752
+ params,
7753
+ compatibility,
7754
+ );
7755
+ if (!shouldUseChild) {
7756
+ return null;
7757
+ }
7758
+
7759
+ // Step 3: Build child route URL with proper parameter filtering
7760
+ return buildChildRouteUrl(childPatternObj, params);
7761
+ };
7762
+
7763
+ /**
7764
+ * Helper: Check if parameters are compatible with child route
7765
+ */
7766
+ const checkChildRouteCompatibility = (childPatternObj, params) => {
7767
+ const childParams = {};
7768
+ let isCompatible = true;
7769
+
7770
+ // Check both parent signals AND user-provided params for child route matching
7771
+ const paramsToCheck = [
7772
+ ...connections,
7773
+ ...Object.entries(params).map(([key, value]) => ({
7774
+ paramName: key,
7775
+ userValue: value,
7776
+ isUserProvided: true,
7777
+ })),
7778
+ ];
7779
+
7780
+ for (const item of paramsToCheck) {
7781
+ const result = processParameterForChildRoute(
7782
+ item,
7783
+ childPatternObj.pattern,
7784
+ );
7785
+
7786
+ if (!result.isCompatible) {
7787
+ isCompatible = false;
7699
7788
  break;
7700
7789
  }
7790
+
7791
+ if (result.shouldInclude) {
7792
+ childParams[result.paramName] = result.paramValue;
7793
+ }
7701
7794
  }
7702
7795
 
7703
- // Also check if original params has extra keys beyond what signals provide
7704
- const providedKeys = new Set(Object.keys(params));
7705
- const signalKeys = new Set(Object.keys(signalDerivedParams));
7706
- for (const key of providedKeys) {
7707
- if (!signalKeys.has(key)) {
7708
- hasUserProvidedParams = true;
7709
- break;
7796
+ return { isCompatible, childParams };
7797
+ };
7798
+
7799
+ /**
7800
+ * Helper: Process a single parameter for child route compatibility
7801
+ */
7802
+ const processParameterForChildRoute = (item, childParsedPattern) => {
7803
+ let paramName;
7804
+ let paramValue;
7805
+
7806
+ if (item.isUserProvided) {
7807
+ paramName = item.paramName;
7808
+ paramValue = item.userValue;
7809
+ } else {
7810
+ const { paramName: name, signal, options } = item;
7811
+ paramName = name;
7812
+ // Only include non-default parent signal values
7813
+ if (
7814
+ signal?.value === undefined ||
7815
+ signal.value === options.defaultValue
7816
+ ) {
7817
+ return { isCompatible: true, shouldInclude: false };
7710
7818
  }
7819
+ paramValue = signal.value;
7711
7820
  }
7712
7821
 
7713
- // ROOT ROUTE PROTECTION: Never apply deepest URL generation to root route "/"
7714
- // Users must always be able to navigate to home page regardless of app state
7715
- const isRootRoute = pattern === "/";
7822
+ // Check if parameter value matches a literal segment in child pattern
7823
+ const matchesChildLiteral = paramMatchesChildLiteral(
7824
+ paramValue,
7825
+ childParsedPattern,
7826
+ );
7716
7827
 
7717
- const relationships = patternRelationships.get(pattern);
7718
- const childPatterns = relationships?.childPatterns || [];
7719
- if (!hasUserProvidedParams && !isRootRoute && childPatterns.length) {
7720
- // Try to find the most specific child pattern that has active signals
7721
- for (const childPattern of childPatterns) {
7722
- const childPatternData = getPatternData(childPattern);
7723
- if (!childPatternData) continue;
7724
-
7725
- // Check if any of this child's parameters have non-default signal values
7726
- let hasActiveParams = false;
7727
- const childParams = {};
7728
-
7729
- // Include parent signal values for child pattern matching
7730
- // But first check if they're compatible with the child pattern
7731
- let parentSignalsCompatibleWithChild = true;
7732
- for (const parentConnection of connections) {
7733
- const { paramName, signal, options } = parentConnection;
7734
- // Only include non-default parent signal values
7735
- if (
7736
- signal?.value !== undefined &&
7737
- signal.value !== options.defaultValue
7738
- ) {
7739
- // Check if child pattern has conflicting literal segments for this parameter
7740
- const childParsedPattern = childPatternData.parsedPattern;
7828
+ if (matchesChildLiteral) {
7829
+ // Compatible - parameter value matches child literal
7830
+ return {
7831
+ isCompatible: true,
7832
+ shouldInclude: !item.isUserProvided,
7833
+ paramName,
7834
+ paramValue,
7835
+ };
7836
+ }
7741
7837
 
7742
- // Check if parent signal value matches a literal segment in child pattern
7743
- const matchesChildLiteral = childParsedPattern.segments.some(
7744
- (segment) =>
7745
- segment.type === "literal" && segment.value === signal.value,
7746
- );
7838
+ // Check for incompatible cases
7839
+ if (item.isUserProvided && !matchesChildLiteral) {
7840
+ // Check if this is a path parameter from parent pattern
7841
+ const isParentPathParam = connections.some(
7842
+ (conn) => conn.paramName === paramName,
7843
+ );
7747
7844
 
7748
- // If parent signal matches a literal in child, don't add as parameter
7749
- // (it's already represented in the child URL path)
7750
- if (matchesChildLiteral) {
7751
- // Compatible - signal value matches child literal, no need to add param
7752
- continue;
7753
- }
7845
+ if (isParentPathParam) {
7846
+ // User provided a path param value that doesn't match this child's literals
7847
+ return { isCompatible: false };
7848
+ }
7849
+ }
7754
7850
 
7755
- // For section parameter specifically, check if child has literal "settings"
7756
- // but parent signal has different value (incompatible case)
7757
- if (paramName === "section" && signal.value !== "settings") {
7758
- const hasSettingsLiteral = childParsedPattern.segments.some(
7759
- (segment) =>
7760
- segment.type === "literal" && segment.value === "settings",
7761
- );
7762
- if (hasSettingsLiteral) {
7763
- parentSignalsCompatibleWithChild = false;
7764
- break;
7765
- }
7766
- }
7851
+ // Special case: section parameter with settings literal
7852
+ if (paramName === "section" && paramValue !== "settings") {
7853
+ const hasSettingsLiteral = childParsedPattern.segments.some(
7854
+ (segment) => segment.type === "literal" && segment.value === "settings",
7855
+ );
7856
+ if (hasSettingsLiteral) {
7857
+ return { isCompatible: false };
7858
+ }
7859
+ }
7767
7860
 
7768
- // Only add parent signal as parameter if it doesn't match child literals
7769
- childParams[paramName] = signal.value;
7770
- }
7861
+ // Compatible but should only include if from signal (not user-provided)
7862
+ return {
7863
+ isCompatible: true,
7864
+ shouldInclude: !item.isUserProvided && !matchesChildLiteral,
7865
+ paramName,
7866
+ paramValue,
7867
+ };
7868
+ };
7869
+
7870
+ /**
7871
+ * Helper: Determine if child route should be used based on active parameters
7872
+ */
7873
+ const shouldUseChildRoute = (childPatternObj, params, compatibility) => {
7874
+ // Check if child has active non-default signal values
7875
+ let hasActiveParams = false;
7876
+ const childParams = { ...compatibility.childParams };
7877
+
7878
+ for (const connection of childPatternObj.connections) {
7879
+ const { paramName, signal, options } = connection;
7880
+ const defaultValue = options.defaultValue;
7881
+
7882
+ if (signal?.value !== undefined) {
7883
+ childParams[paramName] = signal.value;
7884
+ if (signal.value !== defaultValue) {
7885
+ hasActiveParams = true;
7771
7886
  }
7887
+ }
7888
+ }
7772
7889
 
7773
- // Skip this child if parent signals are incompatible
7774
- if (!parentSignalsCompatibleWithChild) {
7775
- continue;
7890
+ // Check if child pattern can be fully satisfied
7891
+ const initialMergedParams = { ...childParams, ...params };
7892
+ const canBuildChildCompletely = childPatternObj.pattern.segments.every(
7893
+ (segment) => {
7894
+ if (segment.type === "literal") return true;
7895
+ if (segment.type === "param") {
7896
+ return (
7897
+ segment.optional || initialMergedParams[segment.name] !== undefined
7898
+ );
7776
7899
  }
7900
+ return true;
7901
+ },
7902
+ );
7777
7903
 
7778
- // Check child connections and see if any have non-default values
7779
- for (const connection of childPatternData.connections) {
7780
- const { paramName, signal, options } = connection;
7781
- const defaultValue = options.defaultValue;
7904
+ const hasProvidedParams = Object.keys(params).length > 0;
7782
7905
 
7783
- if (signal?.value !== undefined) {
7784
- childParams[paramName] = signal.value;
7785
- if (signal.value !== defaultValue) {
7786
- hasActiveParams = true;
7787
- }
7788
- }
7906
+ // Use child route if:
7907
+ // 1. Child has active non-default parameters, OR
7908
+ // 2. User provided params AND child can be built completely
7909
+ return hasActiveParams || (hasProvidedParams && canBuildChildCompletely);
7910
+ };
7911
+
7912
+ /**
7913
+ * Helper: Build URL for selected child route with proper parameter filtering
7914
+ */
7915
+ const buildChildRouteUrl = (childPatternObj, params) => {
7916
+ // Start with child signal values
7917
+ const baseParams = {};
7918
+ for (const connection of childPatternObj.connections) {
7919
+ const { paramName, signal, options } = connection;
7920
+ if (
7921
+ signal?.value !== undefined &&
7922
+ signal.value !== options.defaultValue
7923
+ ) {
7924
+ baseParams[paramName] = signal.value;
7925
+ }
7926
+ }
7927
+
7928
+ // Apply user params with filtering logic
7929
+ for (const [paramName, userValue] of Object.entries(params)) {
7930
+ const childConnection = childPatternObj.connections.find(
7931
+ (conn) => conn.paramName === paramName,
7932
+ );
7933
+
7934
+ if (childConnection) {
7935
+ const { options } = childConnection;
7936
+ const defaultValue = options.defaultValue;
7937
+
7938
+ // Only include if it's NOT the signal's default value
7939
+ if (userValue !== defaultValue) {
7940
+ baseParams[paramName] = userValue;
7941
+ } else {
7942
+ // User provided the default value - complete omission
7943
+ delete baseParams[paramName];
7944
+ }
7945
+ } else {
7946
+ // Check if param corresponds to a literal segment in child pattern
7947
+ const isConsumedByChildPath = childPatternObj.pattern.segments.some(
7948
+ (segment) =>
7949
+ segment.type === "literal" && segment.value === userValue,
7950
+ );
7951
+
7952
+ if (!isConsumedByChildPath) {
7953
+ // Not consumed by child path, keep it as query param
7954
+ baseParams[paramName] = userValue;
7789
7955
  }
7956
+ }
7957
+ }
7958
+
7959
+ // Build child URL
7960
+ const childUrl = childPatternObj.buildUrl(baseParams);
7961
+
7962
+ if (childUrl && !childUrl.includes(":")) {
7963
+ // Check for parent optimization before returning
7964
+ const optimizedUrl = checkChildParentOptimization(
7965
+ childPatternObj.originalPattern,
7966
+ childUrl,
7967
+ baseParams,
7968
+ );
7969
+ return optimizedUrl || childUrl;
7970
+ }
7971
+
7972
+ return null;
7973
+ };
7974
+
7975
+ /**
7976
+ * Helper: Check if parent route optimization applies to child route
7977
+ */
7978
+ const checkChildParentOptimization = (childPattern, childUrl, baseParams) => {
7979
+ if (Object.keys(baseParams).length > 0) {
7980
+ return null; // No optimization if parameters exist
7981
+ }
7790
7982
 
7791
- // If child has non-default parameters, use the child route
7792
- if (hasActiveParams) {
7793
- const childPatternObj = createRoutePattern(childPattern);
7794
- // Use buildUrl (not buildMostPreciseUrl) to avoid infinite recursion
7795
- const childUrl = childPatternObj.buildUrl(childParams);
7796
- if (childUrl) {
7797
- return childUrl;
7983
+ const childRelationships = patternRelationships.get(childPattern);
7984
+ const childParentObjs = childRelationships?.parentPatterns || [];
7985
+
7986
+ for (const childParentObj of childParentObjs) {
7987
+ if (childParentObj.originalPattern === pattern) {
7988
+ // Get the child pattern object from relationships instead of recreating
7989
+ const childPatternObj = childRelationships;
7990
+
7991
+ const allChildParamsAreDefaults = (
7992
+ childPatternObj.connections || []
7993
+ ).every((childConnection) => {
7994
+ const { signal, options } = childConnection;
7995
+ return signal?.value === options.defaultValue;
7996
+ });
7997
+
7998
+ if (allChildParamsAreDefaults) {
7999
+ // Build current route URL for comparison
8000
+ const resolvedParams = resolveParams({});
8001
+ const finalParams = removeDefaultValues(resolvedParams);
8002
+ const currentUrl = buildUrlFromPattern(
8003
+ parsedPattern,
8004
+ finalParams);
8005
+ if (currentUrl.length < childUrl.length) {
8006
+ return currentUrl;
7798
8007
  }
7799
8008
  }
7800
8009
  }
7801
8010
  }
8011
+ return null;
8012
+ };
8013
+
8014
+ const buildMostPreciseUrl = (params = {}) => {
8015
+ // Step 1: Resolve and clean parameters
8016
+ const resolvedParams = resolveParams(params);
8017
+
8018
+ // Step 2: Check for parent route optimization BEFORE removing defaults
8019
+ // This allows optimization when final effective values match defaults
8020
+ const relationships = patternRelationships.get(pattern);
8021
+ const optimizedUrl = checkParentRouteOptimization(
8022
+ resolvedParams,
8023
+ relationships,
8024
+ );
8025
+ if (optimizedUrl) {
8026
+ return optimizedUrl;
8027
+ }
7802
8028
 
7803
- // PARENT PARAMETER INHERITANCE: Inherit query parameters from parent patterns
7804
- // This allows child routes like "/map/isochrone" to inherit "zoom=15" from parent "/map/?zoom=..."
7805
- const parentPatterns = relationships?.parentPatterns || [];
7806
- for (const parentPattern of parentPatterns) {
7807
- const parentPatternData = getPatternData(parentPattern);
7808
- if (!parentPatternData) continue;
8029
+ // Step 3: Remove default values for normal URL building
8030
+ let finalParams = removeDefaultValues(resolvedParams);
7809
8031
 
8032
+ // Step 4: Try to find a more specific child route
8033
+ const childRouteUrl = findBestChildRoute(params, relationships);
8034
+ if (childRouteUrl) {
8035
+ return childRouteUrl;
8036
+ }
8037
+
8038
+ // Step 5: Inherit parameters from parent routes
8039
+ inheritParentParameters(finalParams, relationships);
8040
+
8041
+ // Step 6: Build the current route URL
8042
+ const generatedUrl = buildCurrentRouteUrl(finalParams);
8043
+
8044
+ return generatedUrl;
8045
+ };
8046
+
8047
+ /**
8048
+ * Helper: Inherit query parameters from parent patterns
8049
+ */
8050
+ const inheritParentParameters = (finalParams, relationships) => {
8051
+ const parentPatternObjs = relationships?.parentPatterns || [];
8052
+
8053
+ for (const parentPatternObj of parentPatternObjs) {
7810
8054
  // Check parent's signal connections for non-default values to inherit
7811
- for (const parentConnection of parentPatternData.connections) {
8055
+ for (const parentConnection of parentPatternObj.connections) {
7812
8056
  const { paramName, signal, options } = parentConnection;
7813
8057
  const defaultValue = options.defaultValue;
7814
8058
 
7815
- // If we don't already have this parameter and parent signal has non-default value
8059
+ // Only inherit if we don't have this param and parent has non-default value
7816
8060
  if (
7817
8061
  !(paramName in finalParams) &&
7818
8062
  signal?.value !== undefined &&
7819
8063
  signal.value !== defaultValue
7820
8064
  ) {
7821
- // Check if this parameter corresponds to a literal segment in our path
7822
- // E.g., don't inherit "section=analytics" if our path is "/admin/analytics"
8065
+ // Don't inherit if parameter corresponds to a literal in our path
7823
8066
  const shouldInherit = !isParameterRedundantWithLiteralSegments(
7824
8067
  parsedPattern,
7825
- parentPatternData.parsedPattern,
8068
+ parentPatternObj.pattern,
7826
8069
  paramName,
7827
8070
  signal.value,
7828
8071
  );
7829
8072
 
7830
8073
  if (shouldInherit) {
7831
- // Inherit the parent's signal value
7832
8074
  finalParams[paramName] = signal.value;
7833
8075
  }
7834
8076
  }
7835
8077
  }
7836
8078
  }
8079
+ };
7837
8080
 
8081
+ /**
8082
+ * Helper: Build URL for current route with filtered pattern
8083
+ */
8084
+ const buildCurrentRouteUrl = (finalParams) => {
7838
8085
  if (!parsedPattern.segments) {
7839
8086
  return "/";
7840
8087
  }
7841
8088
 
7842
- // Filter out segments for parameters that are not provided (omitted defaults)
8089
+ // Filter out parameter segments that don't have values
7843
8090
  const filteredPattern = {
7844
8091
  ...parsedPattern,
7845
8092
  segments: parsedPattern.segments.filter((segment) => {
7846
8093
  if (segment.type === "param") {
7847
- // Only keep parameter segments if we have a value for them
7848
8094
  return segment.name in finalParams;
7849
8095
  }
7850
- // Always keep literal segments
7851
- return true;
8096
+ return true; // Keep literal segments
7852
8097
  }),
7853
8098
  };
7854
8099
 
@@ -7863,11 +8108,149 @@ const createRoutePattern = (pattern) => {
7863
8108
  return buildUrlFromPattern(filteredPattern, finalParams);
7864
8109
  };
7865
8110
 
8111
+ /**
8112
+ * Helper: Check if parent route can provide a shorter equivalent URL
8113
+ */
8114
+ const checkParentRouteOptimization = (resolvedParams, relationships) => {
8115
+ // Only consider parent optimization for patterns with signal connections
8116
+ if (connections.length === 0) {
8117
+ return null;
8118
+ }
8119
+
8120
+ // Check if all final effective values equal their defaults
8121
+ const allEffectiveValuesAreDefaults = connections.every((conn) => {
8122
+ // Final effective value is what's in resolvedParams (signals + provided params)
8123
+ const effectiveValue =
8124
+ resolvedParams[conn.paramName] ?? conn.options.defaultValue;
8125
+ return effectiveValue === conn.options.defaultValue;
8126
+ });
8127
+
8128
+ // Only optimize if all effective values equal their defaults
8129
+ if (!allEffectiveValuesAreDefaults) {
8130
+ return null;
8131
+ }
8132
+
8133
+ // Check if there are extra parameters not handled by current route's connections
8134
+ const connectionParamNames = new Set(
8135
+ connections.map((conn) => conn.paramName),
8136
+ );
8137
+ const hasExtraParams = Object.keys(resolvedParams).some(
8138
+ (paramName) => !connectionParamNames.has(paramName),
8139
+ );
8140
+
8141
+ // Don't optimize if there are extra parameters that would be lost
8142
+ if (hasExtraParams) {
8143
+ return null;
8144
+ }
8145
+
8146
+ const possibleParentObjs = relationships?.parentPatterns || [];
8147
+
8148
+ for (const parentPatternObj of possibleParentObjs) {
8149
+ // Skip root route and routes without parameters
8150
+ if (
8151
+ parentPatternObj.originalPattern === "/" ||
8152
+ !parentPatternObj.originalPattern.includes(":")
8153
+ ) {
8154
+ continue;
8155
+ }
8156
+
8157
+ const optimizedParentUrl = evaluateParentOptimization(
8158
+ parentPatternObj,
8159
+ resolvedParams,
8160
+ );
8161
+
8162
+ if (optimizedParentUrl) {
8163
+ return optimizedParentUrl;
8164
+ }
8165
+ }
8166
+
8167
+ return null;
8168
+ };
8169
+
8170
+ /**
8171
+ * Helper: Evaluate a specific parent pattern for URL optimization
8172
+ */
8173
+ const evaluateParentOptimization = (parentPatternObj, resolvedParams) => {
8174
+ // Get literal segments from child pattern to map to parent parameters
8175
+ const childLiterals = getPatternLiterals(parsedPattern);
8176
+
8177
+ // Check if parent would also have all default values
8178
+ // For parent optimization, we consider both explicitly provided params and literal segments
8179
+ const allParentParamsAreDefaults = parentPatternObj.connections.every(
8180
+ (parentConnection) => {
8181
+ const paramName = parentConnection.paramName;
8182
+
8183
+ // If explicitly provided in resolved params, use that
8184
+ if (resolvedParams[paramName] !== undefined) {
8185
+ return (
8186
+ resolvedParams[paramName] === parentConnection.options.defaultValue
8187
+ );
8188
+ }
8189
+
8190
+ // Check if this parent parameter corresponds to a literal segment in child
8191
+ // If parent default matches a child literal, consider it as using the default
8192
+ const defaultValue = parentConnection.options.defaultValue;
8193
+ if (childLiterals.includes(defaultValue)) {
8194
+ return true; // Literal segment effectively provides the default value
8195
+ }
8196
+
8197
+ // Otherwise assume parent would use its default for optimization purposes
8198
+ return true;
8199
+ },
8200
+ );
8201
+
8202
+ if (!allParentParamsAreDefaults) {
8203
+ return null; // Can't optimize if parent has non-default values
8204
+ }
8205
+
8206
+ // Check if parent's default values match our literals
8207
+ const parentPointsToCurrentRoute = parentPatternObj.connections.every(
8208
+ (parentConnection) => {
8209
+ const { options } = parentConnection;
8210
+ const defaultValue = options.defaultValue;
8211
+ return childLiterals.includes(defaultValue);
8212
+ },
8213
+ );
8214
+
8215
+ if (parentPointsToCurrentRoute) {
8216
+ // Build parent URL using defaults, not current signal values
8217
+ const parentDefaultParams = {};
8218
+ for (const parentConnection of parentPatternObj.connections) {
8219
+ parentDefaultParams[parentConnection.paramName] =
8220
+ parentConnection.options.defaultValue;
8221
+ }
8222
+ // Build parent URL and check if it can be optimized further
8223
+ let parentUrl = parentPatternObj.buildUrl(parentDefaultParams);
8224
+
8225
+ // Check if parent can optimize itself by removing default parameters
8226
+ if (parentUrl && parentUrl !== "/") {
8227
+ // Check if all parent's default params are actually defaults
8228
+ const parentAllDefaults = parentPatternObj.connections.every((conn) => {
8229
+ const paramValue = parentDefaultParams[conn.paramName];
8230
+ return paramValue === conn.options.defaultValue;
8231
+ });
8232
+
8233
+ if (parentAllDefaults) {
8234
+ // Try to build parent URL without any parameters to see if it's shorter
8235
+ const parentMinimalUrl = parentPatternObj.buildUrl({});
8236
+ if (parentMinimalUrl && parentMinimalUrl.length < parentUrl.length) {
8237
+ parentUrl = parentMinimalUrl;
8238
+ }
8239
+ }
8240
+
8241
+ return parentUrl;
8242
+ }
8243
+ }
8244
+
8245
+ return null;
8246
+ };
8247
+
7866
8248
  return {
7867
8249
  originalPattern: pattern, // Return the original pattern string
7868
8250
  pattern: parsedPattern,
7869
8251
  cleanPattern, // Return the clean pattern string
7870
8252
  connections, // Return signal connections along with pattern
8253
+ specificity: calculatePatternSpecificity(parsedPattern), // Pre-calculate specificity
7871
8254
  applyOn,
7872
8255
  buildUrl,
7873
8256
  buildMostPreciseUrl,
@@ -7875,6 +8258,50 @@ const createRoutePattern = (pattern) => {
7875
8258
  };
7876
8259
  };
7877
8260
 
8261
+ /**
8262
+ * Helper: Extract literal values from pattern segments
8263
+ */
8264
+ const getPatternLiterals = (pattern) => {
8265
+ return pattern.segments
8266
+ .filter((seg) => seg.type === "literal")
8267
+ .map((seg) => seg.value);
8268
+ };
8269
+
8270
+ /**
8271
+ * Helper: Check if parameter matches any literal in child pattern
8272
+ */
8273
+ const paramMatchesChildLiteral = (paramValue, childParsedPattern) => {
8274
+ return childParsedPattern.segments.some(
8275
+ (segment) => segment.type === "literal" && segment.value === paramValue,
8276
+ );
8277
+ };
8278
+
8279
+ /**
8280
+ * Calculate pattern specificity score for route matching
8281
+ * Higher score = more specific route
8282
+ */
8283
+ const calculatePatternSpecificity = (parsedPattern) => {
8284
+ let specificity = 0;
8285
+
8286
+ // Count path segments (ignoring query params for specificity)
8287
+ const pathSegments = parsedPattern.segments || [];
8288
+
8289
+ for (const segment of pathSegments) {
8290
+ if (segment.type === "literal") {
8291
+ // Literal segments are more specific than parameters
8292
+ specificity += 100; // High score for literal segments
8293
+ } else if (segment.type === "param") {
8294
+ // Parameter segments are less specific
8295
+ specificity += 10; // Lower score for parameters
8296
+ }
8297
+ }
8298
+
8299
+ // Add base score for number of path segments (more segments = more specific)
8300
+ specificity += pathSegments.length;
8301
+
8302
+ return specificity;
8303
+ };
8304
+
7878
8305
  /**
7879
8306
  * Parse a route pattern string into structured segments
7880
8307
  */
@@ -8163,6 +8590,46 @@ const extractSearchParams = (urlObj, connections = []) => {
8163
8590
  return params;
8164
8591
  };
8165
8592
 
8593
+ /**
8594
+ * Helper: Check if a parameter represents parent route inheritance
8595
+ * This detects when a parameter doesn't match the current route's parameters
8596
+ * but the route has literal segments that might correspond to parent route parameters
8597
+ */
8598
+ const detectParentParameterInheritance = (
8599
+ paramName,
8600
+ paramValue,
8601
+ parsedPattern,
8602
+ pathParamNames,
8603
+ queryParamNames,
8604
+ ) => {
8605
+ // Parameter doesn't belong to current route
8606
+ const isExtraParam =
8607
+ !pathParamNames.has(paramName) && !queryParamNames.has(paramName);
8608
+
8609
+ // Route has literal segments (suggesting it might be a child of a parameterized parent)
8610
+ const hasLiteralSegments = parsedPattern.segments.some(
8611
+ (s) => s.type === "literal",
8612
+ );
8613
+
8614
+ // Common parent parameter names (heuristic)
8615
+ const commonParentParams = new Set([
8616
+ "section",
8617
+ "category",
8618
+ "type",
8619
+ "area",
8620
+ "zone",
8621
+ ]);
8622
+ const looksLikeParentParam = commonParentParams.has(paramName);
8623
+
8624
+ return {
8625
+ isParentInheritance:
8626
+ isExtraParam && hasLiteralSegments && looksLikeParentParam,
8627
+ isExtraParam,
8628
+ hasLiteralSegments,
8629
+ looksLikeParentParam,
8630
+ };
8631
+ };
8632
+
8166
8633
  /**
8167
8634
  * Build a URL from a pattern and parameters
8168
8635
  */
@@ -8267,16 +8734,59 @@ const buildUrlFromPattern = (parsedPattern, params = {}) => {
8267
8734
  }
8268
8735
 
8269
8736
  // Add remaining parameters as additional query parameters (excluding path and pattern query params)
8737
+ // Handle parameter inheritance and extra parameters
8738
+ const extraParams = [];
8739
+
8270
8740
  for (const [key, value] of Object.entries(params)) {
8271
8741
  if (
8272
8742
  !pathParamNames.has(key) &&
8273
8743
  !queryParamNames.has(key) &&
8274
8744
  value !== undefined
8275
8745
  ) {
8276
- searchParams.set(key, value);
8746
+ // This parameter doesn't match any path or query parameter in this route pattern,
8747
+ // so it will be treated as an extra query parameter.
8748
+ //
8749
+ // COMMON SCENARIOS:
8750
+ // 1. Parent route parameter inheritance: When a child route has literal segments
8751
+ // that correspond to parent route parameters. For example:
8752
+ // - Parent: /admin/:section/
8753
+ // - Child: /admin/settings/:tab (has "settings" as literal)
8754
+ // - Calling child.buildUrl({section: "toto"}) → /admin/settings?section=toto
8755
+ // The "section" param becomes a query param because "settings" is hardcoded.
8756
+ //
8757
+ // 2. Extra state parameters: Completely additional parameters for URL state
8758
+ // - Calling route.buildUrl({filter: "active"}) → /route?filter=active
8759
+
8760
+ // Check if this parameter value is redundant with literal segments in the path
8761
+ // E.g., don't add "section=settings" if path is already "/admin/settings"
8762
+ const isRedundantWithPath = parsedPattern.segments.some(
8763
+ (segment) => segment.type === "literal" && segment.value === value,
8764
+ );
8765
+
8766
+ if (!isRedundantWithPath) {
8767
+ extraParams.push([key, value]);
8768
+
8769
+ // Optional: Detect and log parent parameter inheritance for debugging
8770
+ detectParentParameterInheritance(
8771
+ key,
8772
+ value,
8773
+ parsedPattern,
8774
+ pathParamNames,
8775
+ queryParamNames,
8776
+ );
8777
+ }
8778
+ // Note: Redundant parameters are intentionally omitted for cleaner URLs
8277
8779
  }
8278
8780
  }
8279
8781
 
8782
+ // Sort extra params alphabetically for consistent order
8783
+ extraParams.sort(([a], [b]) => a.localeCompare(b));
8784
+
8785
+ // Add sorted extra params to searchParams
8786
+ for (const [key, value] of extraParams) {
8787
+ searchParams.set(key, value);
8788
+ }
8789
+
8280
8790
  const search = searchParams.toString();
8281
8791
 
8282
8792
  // No longer handle trailing slash inheritance here
@@ -8386,7 +8896,9 @@ const setupPatterns = (patternDefinitions) => {
8386
8896
  patternRegistry.clear();
8387
8897
  patternRelationships.clear();
8388
8898
 
8389
- // Phase 1: Register all patterns
8899
+ // Phase 1: Register all patterns and create pattern objects
8900
+ const patternObjects = new Map(); // pattern string -> pattern object
8901
+
8390
8902
  for (const [key, urlPatternRaw] of Object.entries(patternDefinitions)) {
8391
8903
  const [cleanPattern, connections] = detectSignals(urlPatternRaw);
8392
8904
  const parsedPattern = parsePattern(cleanPattern);
@@ -8402,6 +8914,10 @@ const setupPatterns = (patternDefinitions) => {
8402
8914
  };
8403
8915
 
8404
8916
  patternRegistry.set(urlPatternRaw, patternData);
8917
+
8918
+ // Create the full pattern object for this pattern
8919
+ const patternObj = createRoutePattern(urlPatternRaw);
8920
+ patternObjects.set(urlPatternRaw, patternObj);
8405
8921
  }
8406
8922
 
8407
8923
  // Phase 2: Build relationships between all patterns
@@ -8417,30 +8933,24 @@ const setupPatterns = (patternDefinitions) => {
8417
8933
 
8418
8934
  // Check if current pattern is a child of other pattern using clean patterns
8419
8935
  if (isChildPattern(currentData.cleanPattern, otherData.cleanPattern)) {
8420
- currentData.parentPatterns.push(otherPattern);
8421
- otherData.childPatterns.push(currentPattern);
8936
+ // Store pattern objects instead of pattern strings
8937
+ currentData.parentPatterns.push(patternObjects.get(otherPattern));
8938
+ otherData.childPatterns.push(patternObjects.get(currentPattern));
8422
8939
  }
8423
8940
  }
8424
8941
 
8425
- // Store relationships for easy access
8942
+ // Store relationships for easy access with pattern objects
8426
8943
  patternRelationships.set(currentPattern, {
8427
8944
  pattern: currentData.parsedPattern,
8428
8945
  parsedPattern: currentData.parsedPattern,
8429
8946
  connections: currentData.connections,
8430
- childPatterns: currentData.childPatterns, // Store child patterns
8431
- parentPatterns: currentData.parentPatterns, // Store parent patterns
8947
+ childPatterns: currentData.childPatterns, // Now contains pattern objects
8948
+ parentPatterns: currentData.parentPatterns, // Now contains pattern objects
8432
8949
  originalPattern: currentPattern,
8433
8950
  });
8434
8951
  }
8435
8952
  };
8436
8953
 
8437
- /**
8438
- * Get pattern data for a registered pattern
8439
- */
8440
- const getPatternData = (urlPatternRaw) => {
8441
- return patternRegistry.get(urlPatternRaw);
8442
- };
8443
-
8444
8954
  /**
8445
8955
  * Clear all registered patterns
8446
8956
  */
@@ -8687,8 +9197,7 @@ const getRoutePrivateProperties = (route) => {
8687
9197
 
8688
9198
  const registerRoute = (routePattern) => {
8689
9199
  const urlPatternRaw = routePattern.originalPattern;
8690
- const patternData = getPatternData(urlPatternRaw);
8691
- const { cleanPattern, connections } = patternData;
9200
+ const { cleanPattern, connections } = routePattern;
8692
9201
 
8693
9202
  const cleanupCallbackSet = new Set();
8694
9203
  const cleanup = () => {
@@ -8710,6 +9219,7 @@ const registerRoute = (routePattern) => {
8710
9219
  relativeUrl: null,
8711
9220
  url: null,
8712
9221
  action: null,
9222
+ specificity: routePattern.specificity, // Expose pattern specificity publicly
8713
9223
  cleanup,
8714
9224
  toString: () => {
8715
9225
  return `route "${cleanPattern}"`;
@@ -8872,16 +9382,22 @@ const registerRoute = (routePattern) => {
8872
9382
  }
8873
9383
  }
8874
9384
 
8875
- // Find the most specific route (the one with the longest pattern path)
9385
+ // Find the most specific route using pre-calculated specificity scores
8876
9386
  let mostSpecificRoute = route;
8877
- let maxSegments = route.pattern.split("/").filter((s) => s !== "").length;
9387
+ const routePrivateProperties = getRoutePrivateProperties(route);
9388
+ let maxSpecificity = routePrivateProperties?.routePattern?.specificity || 0;
8878
9389
 
8879
9390
  for (const matchingRoute of allMatchingRoutes) {
8880
- const segments = matchingRoute.pattern
8881
- .split("/")
8882
- .filter((s) => s !== "").length;
8883
- if (segments > maxSegments) {
8884
- maxSegments = segments;
9391
+ if (matchingRoute === route) {
9392
+ continue;
9393
+ }
9394
+ const matchingRoutePrivateProperties =
9395
+ getRoutePrivateProperties(matchingRoute);
9396
+ const specificity =
9397
+ matchingRoutePrivateProperties?.routePattern?.specificity || 0;
9398
+
9399
+ if (specificity > maxSpecificity) {
9400
+ maxSpecificity = specificity;
8885
9401
  mostSpecificRoute = matchingRoute;
8886
9402
  }
8887
9403
  }
@@ -18541,12 +19057,12 @@ const TabRoute = ({
18541
19057
  expand: true,
18542
19058
  discrete: true,
18543
19059
  padding: padding,
19060
+ paddingX: paddingX,
19061
+ paddingY: paddingY,
18544
19062
  paddingLeft: paddingLeft,
18545
19063
  paddingRight: paddingRight,
18546
19064
  paddingTop: paddingTop,
18547
19065
  paddingBottom: paddingBottom,
18548
- paddingX: paddingX,
18549
- paddingY: paddingY,
18550
19066
  alignX: alignX,
18551
19067
  alignY: alignY,
18552
19068
  children: children