@jsenv/navi 0.16.49 → 0.16.51

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.
@@ -300,7 +300,9 @@ const getSignalType = (value) => {
300
300
  *
301
301
  * @param {any} a - First value to compare
302
302
  * @param {any} b - Second value to compare
303
- * @param {Set} seenSet - Internal cycle detection set (automatically managed)
303
+ * @param {Object} [options={}] - Comparison options
304
+ * @param {Function} [options.keyComparator] - Custom comparator function for object properties and array elements
305
+ * @param {boolean} [options.ignoreArrayOrder=false] - If true, arrays are considered equal regardless of element order
304
306
  * @returns {boolean} true if values are deeply equal, false otherwise
305
307
  */
306
308
 
@@ -319,7 +321,11 @@ const getSignalType = (value) => {
319
321
  */
320
322
  const SYMBOL_IDENTITY = Symbol.for("navi_object_identity");
321
323
 
322
- const compareTwoJsValues = (rootA, rootB, { keyComparator } = {}) => {
324
+ const compareTwoJsValues = (
325
+ rootA,
326
+ rootB,
327
+ { keyComparator, ignoreArrayOrder = false } = {},
328
+ ) => {
323
329
  const seenSet = new Set();
324
330
  const compare = (a, b) => {
325
331
  if (a === b) {
@@ -370,6 +376,32 @@ const compareTwoJsValues = (rootA, rootB, { keyComparator } = {}) => {
370
376
  if (a.length !== b.length) {
371
377
  return false;
372
378
  }
379
+ if (ignoreArrayOrder) {
380
+ // Unordered array comparison: each element in 'a' must have a match in 'b'
381
+ const usedIndices = new Set();
382
+ for (let i = 0; i < a.length; i++) {
383
+ const aValue = a[i];
384
+ let foundMatch = false;
385
+
386
+ for (let j = 0; j < b.length; j++) {
387
+ if (usedIndices.has(j)) continue; // Already matched with another element
388
+
389
+ const bValue = b[j];
390
+ const comparator = keyComparator || compare;
391
+ if (comparator(aValue, bValue, i, compare)) {
392
+ foundMatch = true;
393
+ usedIndices.add(j);
394
+ break;
395
+ }
396
+ }
397
+
398
+ if (!foundMatch) {
399
+ return false;
400
+ }
401
+ }
402
+ return true;
403
+ }
404
+ // Ordered array comparison (original behavior)
373
405
  let i = 0;
374
406
  while (i < a.length) {
375
407
  const aValue = a[i];
@@ -2393,11 +2425,17 @@ const stateSignal = (defaultValue, options = {}) => {
2393
2425
  if (dynamicDefaultSignal) {
2394
2426
  const dynamicValue = dynamicDefaultSignal.peek();
2395
2427
  if (dynamicValue === undefined) {
2396
- return value !== staticDefaultValue;
2428
+ return !compareTwoJsValues(value, staticDefaultValue, {
2429
+ ignoreArrayOrder: true,
2430
+ });
2397
2431
  }
2398
- return value !== dynamicValue;
2432
+ return !compareTwoJsValues(value, dynamicValue, {
2433
+ ignoreArrayOrder: true,
2434
+ });
2399
2435
  }
2400
- return value !== staticDefaultValue;
2436
+ return !compareTwoJsValues(value, staticDefaultValue, {
2437
+ ignoreArrayOrder: true,
2438
+ });
2401
2439
  };
2402
2440
 
2403
2441
  // Create signal with initial value: use stored value, or undefined to indicate no explicit value
@@ -2553,6 +2591,21 @@ const stateSignal = (defaultValue, options = {}) => {
2553
2591
  }
2554
2592
  effect(() => {
2555
2593
  const value = preactSignal.value;
2594
+
2595
+ if (dynamicDefaultSignal) {
2596
+ // With dynamic defaults: always persist to preserve user intent
2597
+ // even when value matches dynamic defaults that may change
2598
+ if (value !== undefined) {
2599
+ if (debug) {
2600
+ console.debug(
2601
+ `[stateSignal:${signalIdString}] dynamic default: writing to localStorage "${localStorageKey}"=${value}`,
2602
+ );
2603
+ }
2604
+ writeIntoLocalStorage(value);
2605
+ }
2606
+ return;
2607
+ }
2608
+ // Static defaults: only persist custom values
2556
2609
  if (isCustomValue(value)) {
2557
2610
  if (debug) {
2558
2611
  console.debug(
@@ -2630,6 +2683,7 @@ const localStorageTypeMap = {
2630
2683
  date: "string",
2631
2684
  time: "string",
2632
2685
  email: "string",
2686
+ array: "object",
2633
2687
  };
2634
2688
 
2635
2689
  const getCallerInfo = (targetFunction = null, additionalOffset = 0) => {
@@ -7578,8 +7632,17 @@ const buildQueryString = (params) => {
7578
7632
  if (value !== undefined && value !== null) {
7579
7633
  const encodedKey = encodeURIComponent(key);
7580
7634
 
7635
+ // Handle array values - join with commas
7636
+ if (Array.isArray(value)) {
7637
+ if (value.length === 0) ; else {
7638
+ const encodedValue = value
7639
+ .map((item) => encodeURIComponent(String(item)))
7640
+ .join(",");
7641
+ searchParamPairs.push(`${encodedKey}=${encodedValue}`);
7642
+ }
7643
+ }
7581
7644
  // Handle boolean values - if true, just add the key without value
7582
- if (value === true || value === "") {
7645
+ else if (value === true || value === "") {
7583
7646
  searchParamPairs.push(encodedKey);
7584
7647
  } else {
7585
7648
  const encodedValue = encodeParamValue(value, false); // Search params encode slashes
@@ -7702,7 +7765,6 @@ const createRoutePattern = (pattern) => {
7702
7765
  const signalSet = new Set();
7703
7766
  for (const connection of connections) {
7704
7767
  connectionMap.set(connection.paramName, connection);
7705
-
7706
7768
  signalSet.add(connection.signal);
7707
7769
  }
7708
7770
 
@@ -7717,7 +7779,7 @@ const createRoutePattern = (pattern) => {
7717
7779
  const applyOn = (url) => {
7718
7780
  const result = matchUrl(parsedPattern, url, {
7719
7781
  baseUrl,
7720
- connections,
7782
+ connectionMap,
7721
7783
  patternObj: patternObject,
7722
7784
  });
7723
7785
 
@@ -9531,7 +9593,7 @@ const checkIfLiteralCanBeOptionalWithPatternObj = (
9531
9593
  const matchUrl = (
9532
9594
  parsedPattern,
9533
9595
  url,
9534
- { baseUrl, connections = [], patternObj = null },
9596
+ { baseUrl, connectionMap, patternObj = null },
9535
9597
  ) => {
9536
9598
  // Parse the URL
9537
9599
  const urlObj = new URL(url, baseUrl);
@@ -9554,14 +9616,14 @@ const matchUrl = (
9554
9616
  // OR when URL exactly matches baseUrl (treating baseUrl as root)
9555
9617
  if (parsedPattern.segments.length === 0) {
9556
9618
  if (pathname === "/" || pathname === "") {
9557
- return extractSearchParams(urlObj, connections);
9619
+ return extractSearchParams(urlObj, connectionMap);
9558
9620
  }
9559
9621
 
9560
9622
  // Special case: if URL exactly matches baseUrl, treat as root route
9561
9623
  if (baseUrl) {
9562
9624
  const baseUrlObj = new URL(baseUrl);
9563
9625
  if (originalPathname === baseUrlObj.pathname) {
9564
- return extractSearchParams(urlObj, connections);
9626
+ return extractSearchParams(urlObj, connectionMap);
9565
9627
  }
9566
9628
  }
9567
9629
 
@@ -9651,7 +9713,7 @@ const matchUrl = (
9651
9713
  // If pattern has trailing slash, wildcard, or children, allow extra segments
9652
9714
 
9653
9715
  // Add search parameters
9654
- const searchParams = extractSearchParams(urlObj, connections);
9716
+ const searchParams = extractSearchParams(urlObj, connectionMap);
9655
9717
  Object.assign(params, searchParams);
9656
9718
 
9657
9719
  // Don't add defaults here - rawParams should only contain what's in the URL
@@ -9663,36 +9725,75 @@ const matchUrl = (
9663
9725
  /**
9664
9726
  * Extract search parameters from URL
9665
9727
  */
9666
- const extractSearchParams = (urlObj, connections = []) => {
9728
+ const extractSearchParams = (urlObj, connectionMap) => {
9667
9729
  const params = {};
9668
9730
 
9669
- // Create a map for quick signal type lookup
9670
- const signalTypes = new Map();
9671
- for (const connection of connections) {
9672
- if (connection.type) {
9673
- signalTypes.set(connection.paramName, connection.type);
9731
+ // Parse the raw query string manually instead of using urlObj.searchParams
9732
+ // This is necessary for array parameters to handle encoded commas correctly.
9733
+ // urlObj.searchParams automatically decodes %2C to , which breaks our comma-based array splitting.
9734
+ //
9735
+ // Design choice: We use comma-separated values (colors=red,blue,green) instead of
9736
+ // the standard repeated parameters (colors=red&colors=blue&colors=green) because:
9737
+ // 1. More human-readable URLs
9738
+ // 2. Shorter URL length
9739
+ // 3. Easier to copy/paste and manually edit
9740
+ if (!urlObj.search) {
9741
+ return params;
9742
+ }
9743
+
9744
+ const rawQuery = urlObj.search.slice(1); // Remove leading ?
9745
+ const pairs = rawQuery.split("&");
9746
+
9747
+ for (const pair of pairs) {
9748
+ const eqIndex = pair.indexOf("=");
9749
+ let key;
9750
+ let rawValue;
9751
+
9752
+ if (eqIndex > -1) {
9753
+ key = decodeURIComponent(pair.slice(0, eqIndex));
9754
+ rawValue = pair.slice(eqIndex + 1); // Keep raw for array processing
9755
+ } else {
9756
+ key = decodeURIComponent(pair);
9757
+ rawValue = "";
9674
9758
  }
9675
- }
9676
9759
 
9677
- for (const [key, value] of urlObj.searchParams) {
9678
- const signalType = signalTypes.get(key);
9760
+ const connection = connectionMap.get(key);
9761
+ const signalType = connection ? connection.type : null;
9679
9762
 
9680
9763
  // Cast value based on signal type
9681
- if (signalType === "number" || signalType === "float") {
9682
- const numberValue = Number(value);
9683
- params[key] = isNaN(numberValue) ? value : numberValue;
9764
+ if (signalType === "array") {
9765
+ // Handle array query parameters with proper comma encoding:
9766
+ // ?colors=red,blue,green ["red", "blue", "green"]
9767
+ // ?colors=red,blue%2Cgreen → ["red", "blue,green"] (comma in value)
9768
+ // ?colors= → []
9769
+ // ?colors → []
9770
+ if (rawValue === "") {
9771
+ params[key] = [];
9772
+ } else {
9773
+ params[key] = rawValue
9774
+ .split(",")
9775
+ .map((item) => decodeURIComponent(item))
9776
+ .filter((item) => item.trim() !== "");
9777
+ }
9778
+ } else if (signalType === "number" || signalType === "float") {
9779
+ const decodedValue = decodeURIComponent(rawValue);
9780
+ const numberValue = Number(decodedValue);
9781
+ params[key] = isNaN(numberValue) ? decodedValue : numberValue;
9684
9782
  } else if (signalType === "boolean") {
9783
+ const decodedValue = decodeURIComponent(rawValue);
9685
9784
  // Handle boolean query parameters:
9686
9785
  // ?walk=true → true
9687
9786
  // ?walk=1 → true
9688
9787
  // ?walk → true (parameter present without value)
9689
9788
  // ?walk=false → false
9690
9789
  // ?walk=0 → false
9691
- params[key] = value === "true" || value === "1" || value === "";
9790
+ params[key] =
9791
+ decodedValue === "true" || decodedValue === "1" || decodedValue === "";
9692
9792
  } else {
9693
- params[key] = value;
9793
+ params[key] = decodeURIComponent(rawValue);
9694
9794
  }
9695
9795
  }
9796
+
9696
9797
  return params;
9697
9798
  };
9698
9799