@jsenv/navi 0.15.10 → 0.16.1

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.
@@ -2178,18 +2178,35 @@ const valueInLocalStorage = (
2178
2178
  window.localStorage.removeItem(key);
2179
2179
  return;
2180
2180
  }
2181
- const validityMessage = getValidityMessage(value);
2181
+
2182
+ let valueToSet = value;
2183
+ let validityMessage = getValidityMessage(valueToSet);
2184
+
2185
+ // If validation fails, try to convert the value
2186
+ if (validityMessage && converter) {
2187
+ const convertedValue = tryConvertValue(valueToSet, type);
2188
+ if (convertedValue !== valueToSet) {
2189
+ const convertedValidityMessage = getValidityMessage(convertedValue);
2190
+ if (!convertedValidityMessage) {
2191
+ // Conversion successful and valid
2192
+ valueToSet = convertedValue;
2193
+ validityMessage = "";
2194
+ }
2195
+ }
2196
+ }
2197
+
2182
2198
  if (validityMessage) {
2183
2199
  console.warn(
2184
2200
  `The value to set in localStorage "${key}" is invalid: ${validityMessage}`,
2185
2201
  );
2186
2202
  }
2203
+
2187
2204
  if (converter && converter.encode) {
2188
- const valueEncoded = converter.encode(value);
2205
+ const valueEncoded = converter.encode(valueToSet);
2189
2206
  window.localStorage.setItem(key, valueEncoded);
2190
2207
  return;
2191
2208
  }
2192
- window.localStorage.setItem(key, value);
2209
+ window.localStorage.setItem(key, valueToSet);
2193
2210
  };
2194
2211
  const remove = () => {
2195
2212
  window.localStorage.removeItem(key);
@@ -2198,8 +2215,34 @@ const valueInLocalStorage = (
2198
2215
  return [get, set, remove];
2199
2216
  };
2200
2217
 
2218
+ const tryConvertValue = (value, type) => {
2219
+ const validator = typeConverters[type];
2220
+ if (!validator) {
2221
+ return value;
2222
+ }
2223
+ if (!validator.cast) {
2224
+ return value;
2225
+ }
2226
+ const fromType = typeof value;
2227
+ const castFunction = validator.cast[fromType];
2228
+ if (!castFunction) {
2229
+ return value;
2230
+ }
2231
+ const convertedValue = castFunction(value);
2232
+ return convertedValue;
2233
+ };
2234
+
2201
2235
  const createNumberValidator = ({ min, max, step } = {}) => {
2202
2236
  return {
2237
+ cast: {
2238
+ string: (value) => {
2239
+ const parsed = parseFloat(value);
2240
+ if (!isNaN(parsed) && isFinite(parsed)) {
2241
+ return parsed;
2242
+ }
2243
+ return value;
2244
+ },
2245
+ },
2203
2246
  decode: (value) => {
2204
2247
  const valueParsed = parseFloat(value);
2205
2248
  return valueParsed;
@@ -2233,6 +2276,16 @@ const createNumberValidator = ({ min, max, step } = {}) => {
2233
2276
  };
2234
2277
  const typeConverters = {
2235
2278
  boolean: {
2279
+ cast: {
2280
+ string: (value) => {
2281
+ if (value === "true") return true;
2282
+ if (value === "false") return false;
2283
+ return value;
2284
+ },
2285
+ number: (value) => {
2286
+ return Boolean(value);
2287
+ },
2288
+ },
2236
2289
  checkValidity: (value) => {
2237
2290
  if (typeof value !== "boolean") {
2238
2291
  return `must be a boolean`;
@@ -2247,6 +2300,10 @@ const typeConverters = {
2247
2300
  },
2248
2301
  },
2249
2302
  string: {
2303
+ cast: {
2304
+ number: String,
2305
+ boolean: String,
2306
+ },
2250
2307
  checkValidity: (value) => {
2251
2308
  if (typeof value !== "string") {
2252
2309
  return `must be a string`;
@@ -2260,6 +2317,24 @@ const typeConverters = {
2260
2317
  integer: createNumberValidator({ step: 1 }),
2261
2318
  positive_integer: createNumberValidator({ min: 0, step: 1 }),
2262
2319
  percentage: {
2320
+ cast: {
2321
+ number: (value) => {
2322
+ if (value >= 0 && value <= 100) {
2323
+ return `${value}%`;
2324
+ }
2325
+ return value;
2326
+ },
2327
+ string: (value) => {
2328
+ if (value.endsWith("%")) {
2329
+ return value;
2330
+ }
2331
+ const parsed = parseFloat(value);
2332
+ if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
2333
+ return `${parsed}%`;
2334
+ }
2335
+ return value;
2336
+ },
2337
+ },
2263
2338
  checkValidity: (value) => {
2264
2339
  if (typeof value !== "string") {
2265
2340
  return `must be a percentage`;
@@ -2279,6 +2354,16 @@ const typeConverters = {
2279
2354
  },
2280
2355
  },
2281
2356
  object: {
2357
+ cast: {
2358
+ string: (value) => {
2359
+ try {
2360
+ return JSON.parse(value);
2361
+ } catch {
2362
+ // Invalid JSON, can't convert
2363
+ return value;
2364
+ }
2365
+ },
2366
+ },
2282
2367
  decode: (value) => {
2283
2368
  const valueParsed = JSON.parse(value);
2284
2369
  return valueParsed;
@@ -2296,8 +2381,16 @@ const typeConverters = {
2296
2381
  },
2297
2382
  };
2298
2383
 
2384
+ // Global signal registry for route template detection
2385
+ const globalSignalRegistry = new Map();
2386
+ let signalIdCounter = 0;
2387
+ const generateSignalId = () => {
2388
+ const id = signalIdCounter++;
2389
+ return id;
2390
+ };
2391
+
2299
2392
  /**
2300
- * Creates an advanced signal with optional source signal synchronization and local storage persistence.
2393
+ * Creates an advanced signal with optional source signal synchronization, local storage persistence, and validation.
2301
2394
  *
2302
2395
  * The sourceSignal option creates a fallback mechanism where:
2303
2396
  * 1. The signal initially takes the value from sourceSignal (if defined) or falls back to defaultValue
@@ -2311,16 +2404,37 @@ const typeConverters = {
2311
2404
  *
2312
2405
  * @param {any} defaultValue - The default value to use when no other value is available
2313
2406
  * @param {Object} [options={}] - Configuration options
2407
+ * @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.
2314
2408
  * @param {import("@preact/signals").Signal} [options.sourceSignal] - Source signal to synchronize with. When the source signal changes, this signal will be updated
2315
- * @param {string} [options.localStorageKey] - Key for local storage persistence. When provided, the signal value will be saved to and restored from localStorage
2409
+ * @param {boolean} [options.persists=false] - Whether to persist the signal value in localStorage using the signal ID as key
2316
2410
  * @param {"string" | "number" | "boolean" | "object"} [options.type="string"] - Type for localStorage serialization/deserialization
2317
- * @returns {import("@preact/signals").Signal} A signal that can be synchronized with a source signal and/or persisted in localStorage
2411
+ * @param {Array} [options.oneOf] - Array of valid values for validation. Signal will be marked invalid if value is not in this array
2412
+ * @param {Function} [options.autoFix] - Function to call when validation fails to automatically fix the value
2413
+ * @param {boolean} [options.debug=false] - Enable debug logging for this signal's operations
2414
+ * @returns {import("@preact/signals").Signal} A signal that can be synchronized with a source signal and/or persisted in localStorage. The signal includes a `validity` property for validation state.
2318
2415
  *
2319
2416
  * @example
2320
2417
  * // Basic signal with default value
2321
2418
  * const count = stateSignal(0);
2322
2419
  *
2323
2420
  * @example
2421
+ * // Signal with custom ID and persistence
2422
+ * const theme = stateSignal("light", {
2423
+ * id: "user-theme",
2424
+ * persists: true,
2425
+ * type: "string"
2426
+ * });
2427
+ *
2428
+ * @example
2429
+ * // Signal with validation and auto-fix
2430
+ * const tab = stateSignal("overview", {
2431
+ * id: "current-tab",
2432
+ * oneOf: ["overview", "details", "settings"],
2433
+ * autoFix: () => "overview",
2434
+ * persists: true
2435
+ * });
2436
+ *
2437
+ * @example
2324
2438
  * // Position that follows backend data but allows temporary overrides
2325
2439
  * const backendPosition = signal({ x: 100, y: 50 });
2326
2440
  * const currentPosition = stateSignal({ x: 0, y: 0 }, { sourceSignal: backendPosition });
@@ -2329,122 +2443,192 @@ const typeConverters = {
2329
2443
  * // User drags: currentPosition.value = { x: 150, y: 80 } (manual override)
2330
2444
  * // Backend updates: backendPosition.value = { x: 200, y: 60 }
2331
2445
  * // Result: currentPosition.value = { x: 200, y: 60 } (reset to new backend value)
2332
- *
2333
- * @example
2334
- * // Signal with localStorage persistence
2335
- * const userPreference = stateSignal("light", {
2336
- * localStorageKey: "theme",
2337
- * type: "string"
2338
- * });
2339
- *
2340
- * @example
2341
- * // Combined: follows source with localStorage backup
2342
- * const serverConfig = signal({ timeout: 5000 });
2343
- * const appConfig = stateSignal({ timeout: 3000 }, {
2344
- * sourceSignal: serverConfig,
2345
- * localStorageKey: "app-config",
2346
- * type: "object"
2347
- * });
2348
2446
  */
2349
- const stateSignal = (
2350
- defaultValue,
2351
- { sourceSignal, localStorageKey, type } = {},
2352
- ) => {
2353
- const advancedSignal = signal();
2354
- if (sourceSignal) {
2355
- connectSignalToSource(advancedSignal, sourceSignal, defaultValue);
2356
- } else {
2357
- advancedSignal.value = defaultValue;
2358
- }
2359
- if (localStorageKey) {
2360
- connectSignalWithLocalStorage(advancedSignal, localStorageKey, { type });
2447
+ const NO_LOCAL_STORAGE = [() => undefined, () => {}, () => {}];
2448
+ const stateSignal = (defaultValue, options = {}) => {
2449
+ const {
2450
+ id,
2451
+ type = "string",
2452
+ oneOf,
2453
+ autoFix,
2454
+ sourceSignal,
2455
+ persists = false,
2456
+ debug,
2457
+ } = options;
2458
+ const signalId = id || generateSignalId();
2459
+ // Convert numeric IDs to strings for consistency
2460
+ const signalIdString = String(signalId);
2461
+ if (globalSignalRegistry.has(signalIdString)) {
2462
+ throw new Error(
2463
+ `Signal ID conflict: A signal with ID "${signalIdString}" already exists`,
2464
+ );
2361
2465
  }
2362
- return advancedSignal;
2363
- };
2364
2466
 
2365
- const connectSignalToSource = (signal, sourceSignal, defaultValue) => {
2366
- connectSignalFallbacks(signal, [sourceSignal], defaultValue);
2367
- updateSignalOnChange(sourceSignal, signal);
2368
- };
2369
- const connectSignalFallbacks = (signal, fallbackSignals, defaultValue) => {
2370
- if (fallbackSignals.length === 0) {
2371
- signal.value = defaultValue;
2372
- return () => {};
2373
- }
2374
- if (fallbackSignals.length === 1) {
2375
- const [fallbackSignal] = fallbackSignals;
2376
- const applyFallback = () => {
2377
- const value = signal.value;
2378
- const fallbackValue = fallbackSignal.value;
2379
- if (value !== undefined) {
2467
+ // Determine localStorage key: use id if persists=true, or legacy localStorage option
2468
+ const localStorageKey = signalIdString;
2469
+ const [readFromLocalStorage, writeIntoLocalStorage, removeFromLocalStorage] =
2470
+ persists
2471
+ ? valueInLocalStorage(localStorageKey, { type })
2472
+ : NO_LOCAL_STORAGE;
2473
+ const getFallbackValue = () => {
2474
+ const valueFromLocalStorage = readFromLocalStorage();
2475
+ if (valueFromLocalStorage !== undefined) {
2476
+ if (debug) {
2477
+ console.debug(
2478
+ `[stateSignal] using value from localStorage "${localStorageKey}"=${valueFromLocalStorage}`,
2479
+ );
2480
+ }
2481
+ return valueFromLocalStorage;
2482
+ }
2483
+ if (sourceSignal) {
2484
+ const sourceValue = sourceSignal.peek();
2485
+ if (sourceValue !== undefined) {
2486
+ if (debug) {
2487
+ console.debug(
2488
+ `[stateSignal] using value from source signal=${sourceValue}`,
2489
+ );
2490
+ }
2491
+ return sourceValue;
2492
+ }
2493
+ }
2494
+ if (debug) {
2495
+ console.debug(`[stateSignal] using default value=${defaultValue}`);
2496
+ }
2497
+ return defaultValue;
2498
+ };
2499
+
2500
+ const advancedSignal = signal(getFallbackValue());
2501
+
2502
+ // Set signal ID and create meaningful string representation
2503
+ advancedSignal.__signalId = signalIdString;
2504
+ advancedSignal.toString = () => `{navi_state_signal:${signalIdString}}`;
2505
+
2506
+ // Store signal with its options for later route connection
2507
+ globalSignalRegistry.set(signalIdString, {
2508
+ signal: advancedSignal,
2509
+ options: {
2510
+ getFallbackValue,
2511
+ defaultValue,
2512
+ type,
2513
+ persists,
2514
+ localStorageKey,
2515
+ debug,
2516
+ ...options,
2517
+ },
2518
+ });
2519
+
2520
+ const validity = { valid: true };
2521
+ advancedSignal.validity = validity;
2522
+
2523
+ // ensure current value always fallback to
2524
+ // 1. source signal
2525
+ // 2. local storage
2526
+ // 3. default value
2527
+ {
2528
+ let firstRun = true;
2529
+ effect(() => {
2530
+ const value = advancedSignal.value;
2531
+ if (sourceSignal) {
2532
+ // eslint-disable-next-line no-unused-expressions
2533
+ sourceSignal.value;
2534
+ }
2535
+ if (firstRun) {
2536
+ firstRun = true;
2380
2537
  return;
2381
2538
  }
2382
- if (fallbackValue !== undefined) {
2383
- signal.value = fallbackValue;
2539
+ if (value !== undefined) {
2384
2540
  return;
2385
2541
  }
2386
- signal.value = defaultValue;
2387
- };
2388
- applyFallback();
2389
- return effect(() => {
2390
- applyFallback();
2542
+ advancedSignal.value = getFallbackValue();
2391
2543
  });
2392
2544
  }
2393
- const applyFallback = () => {
2394
- const fallbackValues = fallbackSignals.map((s) => s.value);
2395
- const value = signal.value;
2396
- if (value !== undefined) {
2397
- return;
2545
+ // When source signal value is updated, it overrides current signal value
2546
+ source_signal_override: {
2547
+ if (!sourceSignal) {
2548
+ break source_signal_override;
2398
2549
  }
2399
- for (const fallbackValue of fallbackValues) {
2400
- if (fallbackValue === undefined) {
2401
- continue;
2550
+
2551
+ let isFirstRun = true;
2552
+ let sourcePreviousValue;
2553
+ effect(() => {
2554
+ const sourceValue = sourceSignal.value;
2555
+ if (isFirstRun) {
2556
+ // first run
2557
+ isFirstRun = false;
2558
+ sourcePreviousValue = sourceValue;
2559
+ return;
2402
2560
  }
2403
- signal.value = fallbackValue;
2404
- return;
2561
+ if (sourceValue === undefined) {
2562
+ // we don't have anything in the source signal, keep current value
2563
+ if (debug) {
2564
+ console.debug(
2565
+ `[stateSignal] source signal is undefined, keeping current value`,
2566
+ {
2567
+ sourcePreviousValue,
2568
+ sourceValue,
2569
+ },
2570
+ );
2571
+ }
2572
+ sourcePreviousValue = undefined;
2573
+ return;
2574
+ }
2575
+ // the case we want to support: source signal value changes -> override current value
2576
+ if (debug) {
2577
+ console.debug(`[stateSignal] source signal updated`, {
2578
+ sourcePreviousValue,
2579
+ sourceValue,
2580
+ });
2581
+ }
2582
+ advancedSignal.value = sourceValue;
2583
+ sourcePreviousValue = sourceValue;
2584
+ });
2585
+ }
2586
+ // Read/write into local storage when enabled
2587
+ persist_in_local_storage: {
2588
+ if (!localStorageKey) {
2589
+ break persist_in_local_storage;
2405
2590
  }
2406
- signal.value = defaultValue;
2407
- };
2408
- applyFallback();
2409
- return effect(() => {
2410
- applyFallback();
2411
- });
2412
- };
2413
- const updateSignalOnChange = (sourceSignal, targetSignal) => {
2414
- let sourcePreviousValue = sourceSignal.value;
2415
- return effect(() => {
2416
- const sourceValue = sourceSignal.value;
2417
- if (sourcePreviousValue !== undefined && sourceValue !== undefined) {
2418
- // console.log(
2419
- // "value modified from",
2420
- // sourcePreviousValue,
2421
- // "to",
2422
- // sourceValue,
2423
- // );
2424
- targetSignal.value = sourceValue;
2425
- }
2426
- sourcePreviousValue = sourceValue;
2427
- });
2591
+ effect(() => {
2592
+ const value = advancedSignal.value;
2593
+ if (value === undefined || value === null || value === defaultValue) {
2594
+ if (debug) {
2595
+ console.debug(
2596
+ `[stateSignal] removing "${localStorageKey}" from localStorage`,
2597
+ );
2598
+ }
2599
+ removeFromLocalStorage();
2600
+ } else {
2601
+ if (debug) {
2602
+ console.debug(
2603
+ `[stateSignal] writing into localStorage "${localStorageKey}"=${value}`,
2604
+ );
2605
+ }
2606
+ writeIntoLocalStorage(value);
2607
+ }
2608
+ });
2609
+ }
2610
+ // update validity object according to the advanced signal value
2611
+ {
2612
+ effect(() => {
2613
+ const value = advancedSignal.value;
2614
+ updateValidity({ oneOf }, validity, value);
2615
+ if (!validity.valid && autoFix) {
2616
+ advancedSignal.value = autoFix();
2617
+ return;
2618
+ }
2619
+ });
2620
+ }
2621
+
2622
+ return advancedSignal;
2428
2623
  };
2429
2624
 
2430
- const connectSignalWithLocalStorage = (
2431
- signal,
2432
- key,
2433
- { type = "string" } = {},
2434
- ) => {
2435
- const [get, set, remove] = valueInLocalStorage(key, { type });
2436
- const valueFromLocalStorage = get();
2437
- if (valueFromLocalStorage !== undefined) {
2438
- signal.value = valueFromLocalStorage;
2625
+ const updateValidity = (rules, validity, value) => {
2626
+ const { oneOf } = rules;
2627
+ if (oneOf && !oneOf.includes(value)) {
2628
+ validity.valid = false;
2629
+ return;
2439
2630
  }
2440
- effect(() => {
2441
- const value = signal.value;
2442
- if (value === undefined || value === null) {
2443
- remove();
2444
- } else {
2445
- set(value);
2446
- }
2447
- });
2631
+ validity.valid = true;
2448
2632
  };
2449
2633
 
2450
2634
  const getCallerInfo = (targetFunction = null, additionalOffset = 0) => {
@@ -7313,380 +7497,950 @@ const useUITransitionContentId = value => {
7313
7497
  }, []);
7314
7498
  };
7315
7499
 
7316
- const rawUrlPartSymbol = Symbol("raw_url_part");
7317
- const rawUrlPart = (value) => {
7318
- return {
7319
- [rawUrlPartSymbol]: true,
7500
+ /**
7501
+ * Custom route pattern matching system
7502
+ * Replaces URLPattern with a simpler, more predictable approach
7503
+ */
7504
+
7505
+
7506
+ // Base URL management
7507
+ let baseFileUrl;
7508
+ let baseUrl;
7509
+ const setBaseUrl = (value) => {
7510
+ baseFileUrl = new URL(
7320
7511
  value,
7321
- };
7322
- };
7512
+ typeof window === "undefined" ? "http://localhost/" : window.location,
7513
+ ).href;
7514
+ baseUrl = new URL(".", baseFileUrl).href;
7515
+ };
7516
+ setBaseUrl(
7517
+ typeof window === "undefined"
7518
+ ? "/"
7519
+ : window.location.origin,
7520
+ );
7521
+
7522
+ // Pattern registry for building relationships before routes are created
7523
+ const patternRegistry = new Map(); // pattern -> patternData
7524
+ const patternRelationships = new Map(); // pattern -> relationships
7323
7525
 
7324
- const removeOptionalParts = (url) => {
7325
- // Only remove optional parts that still have ? (weren't replaced with actual values)
7326
- // Find the first unused optional part and remove everything from there onwards
7327
- let result = url;
7526
+ // Function to detect signals in route patterns and connect them
7527
+ const detectSignals = (routePattern) => {
7528
+ const signalConnections = [];
7529
+ let updatedPattern = routePattern;
7328
7530
 
7329
- // Find the first occurrence of an unused optional part (still has ?)
7330
- const optionalPartMatch = result.match(/(\/?\*|\/:[^/?]*|\{[^}]*\})\?/);
7531
+ // Look for signals in the new syntax: :paramName={navi_state_signal:id} or ?paramName={navi_state_signal:id} or &paramName={navi_state_signal:id}
7532
+ // Using curly braces to avoid conflicts with underscores in signal IDs
7533
+ const signalParamRegex = /([?:&])(\w+)=(\{navi_state_signal:[^}]+\})/g;
7534
+ let match;
7331
7535
 
7332
- if (optionalPartMatch) {
7333
- // Remove everything from the start of the first unused optional part
7334
- const optionalStartIndex = optionalPartMatch.index;
7335
- result = result.substring(0, optionalStartIndex);
7536
+ while ((match = signalParamRegex.exec(routePattern)) !== null) {
7537
+ const [fullMatch, prefix, paramName, signalString] = match;
7336
7538
 
7337
- // Clean up trailing slashes
7338
- result = result.replace(/\/$/, "");
7539
+ // Extract the signal ID from the new format: {navi_state_signal:id}
7540
+ const signalIdMatch = signalString.match(/\{navi_state_signal:([^}]+)\}/);
7541
+ if (!signalIdMatch) {
7542
+ console.warn(
7543
+ `[detectSignals] Failed to extract signal ID from: ${signalString}`,
7544
+ );
7545
+ continue;
7546
+ }
7547
+
7548
+ const signalId = signalIdMatch[1];
7549
+ const signalData = globalSignalRegistry.get(signalId);
7550
+
7551
+ if (signalData) {
7552
+ const { signal, options } = signalData;
7553
+
7554
+ let replacement;
7555
+ if (prefix === ":") {
7556
+ // Path parameter: :section=__jsenv_signal_1__ becomes :section
7557
+ replacement = `${prefix}${paramName}`;
7558
+ } else if (prefix === "?") {
7559
+ // First search parameter: ?city=__jsenv_signal_1__ becomes ?city
7560
+ replacement = `${prefix}${paramName}`;
7561
+ } else if (prefix === "&") {
7562
+ // Additional search parameter: &lon=__jsenv_signal_1__ becomes &lon
7563
+ replacement = `${prefix}${paramName}`;
7564
+ }
7565
+ updatedPattern = updatedPattern.replace(fullMatch, replacement);
7566
+
7567
+ signalConnections.push({
7568
+ signal,
7569
+ paramName,
7570
+ options,
7571
+ });
7572
+ } else {
7573
+ console.warn(
7574
+ `[detectSignals] Signal not found in registry for ID: "${signalId}"`,
7575
+ );
7576
+ console.warn(
7577
+ `[detectSignals] Available signal IDs in registry:`,
7578
+ Array.from(globalSignalRegistry.keys()),
7579
+ );
7580
+ console.warn(`[detectSignals] Full pattern: "${routePattern}"`);
7581
+ }
7339
7582
  }
7340
7583
 
7341
- return result;
7584
+ return [updatedPattern, signalConnections];
7342
7585
  };
7343
7586
 
7344
- const buildRouteRelativeUrl = (
7345
- urlPatternInput,
7346
- params,
7347
- { extraParamEffect = "inject_as_search_param" } = {},
7348
- ) => {
7349
- let relativeUrl = urlPatternInput;
7350
- let hasRawUrlPartWithInvalidChars = false;
7351
- let stringQueryParams = "";
7587
+ /**
7588
+ * Creates a custom route pattern matcher
7589
+ */
7590
+ const createRoutePattern = (pattern) => {
7591
+ // Detect and process signals in the pattern first
7592
+ const [cleanPattern, connections] = detectSignals(pattern);
7352
7593
 
7353
- // Handle string params (query string) - store for later appending
7354
- if (typeof params === "string") {
7355
- stringQueryParams = params;
7356
- // Remove leading ? if present for processing
7357
- if (stringQueryParams.startsWith("?")) {
7358
- stringQueryParams = stringQueryParams.slice(1);
7594
+ // Build parameter defaults from signal connections
7595
+ const parameterDefaults = new Map();
7596
+ for (const connection of connections) {
7597
+ const { paramName, options } = connection;
7598
+ if (options.defaultValue !== undefined) {
7599
+ parameterDefaults.set(paramName, options.defaultValue);
7359
7600
  }
7360
- // Set params to empty object so the rest of the function processes the URL pattern
7361
- params = null;
7362
7601
  }
7363
7602
 
7364
- // Encode parameter values for URL usage, with special handling for raw URL parts.
7365
- // When a parameter is wrapped with rawUrlPart(), it bypasses encoding and is
7366
- // inserted as-is into the URL. This allows including pre-encoded values or
7367
- // special characters that should not be percent-encoded.
7368
- //
7369
- // For wildcard parameters (isWildcard=true), we preserve slashes as path separators.
7370
- // For named parameters and search params (isWildcard=false), we encode slashes.
7371
- const encodeParamValue = (value, isWildcard = false) => {
7372
- if (value && value[rawUrlPartSymbol]) {
7373
- const rawValue = value.value;
7374
- // Check if raw value contains invalid URL characters
7375
- if (/[\s<>{}|\\^`]/.test(rawValue)) {
7376
- hasRawUrlPartWithInvalidChars = true;
7377
- }
7378
- return rawValue;
7379
- }
7380
-
7381
- if (isWildcard) {
7382
- // For wildcards, only encode characters that are invalid in URL paths,
7383
- // but preserve slashes as they are path separators
7384
- return value
7385
- ? value.replace(/[^a-zA-Z0-9\-._~!$&'()*+,;=:@/]/g, (char) => {
7386
- return encodeURIComponent(char);
7387
- })
7388
- : value;
7603
+ const parsedPattern = parsePattern(cleanPattern, parameterDefaults);
7604
+
7605
+ const applyOn = (url) => {
7606
+ const result = matchUrl(parsedPattern, url, {
7607
+ parameterDefaults,
7608
+ baseUrl,
7609
+ });
7610
+
7611
+ return result;
7612
+ };
7613
+
7614
+ const buildUrl = (params = {}) => {
7615
+ return buildUrlFromPattern(parsedPattern, params);
7616
+ };
7617
+
7618
+ const resolveParams = (providedParams = {}) => {
7619
+ let resolvedParams = { ...providedParams };
7620
+
7621
+ // Process all connections for parameter resolution
7622
+ for (const connection of connections) {
7623
+ const { paramName, signal } = connection;
7624
+
7625
+ if (paramName in providedParams) ; else if (signal?.value !== undefined) {
7626
+ // Parameter was not provided, check signal value
7627
+ resolvedParams[paramName] = signal.value;
7628
+ }
7389
7629
  }
7390
7630
 
7391
- // For named parameters and search params, encode everything including slashes
7392
- return encodeURIComponent(value);
7631
+ return resolvedParams;
7393
7632
  };
7394
- const extraParamMap = new Map();
7395
- let wildcardIndex = 0; // Declare wildcard index in the main scope
7396
-
7397
- if (params) {
7398
- const keys = Object.keys(params);
7399
-
7400
- // First, handle special case: optional groups immediately followed by wildcards
7401
- // This handles patterns like {/}?* where the optional part should be included when wildcard has content
7402
- relativeUrl = relativeUrl.replace(/\{([^}]*)\}\?\*/g, (match, group) => {
7403
- const paramKey = wildcardIndex.toString();
7404
- const paramValue = params[paramKey];
7405
-
7406
- if (paramValue) {
7407
- // Don't add to extraParamMap since we're processing it here
7408
- // For wildcards, preserve slashes as path separators
7409
- const wildcardValue = encodeParamValue(paramValue, true);
7410
- wildcardIndex++;
7411
- // Include the optional group content when wildcard has value
7412
- return group + wildcardValue;
7413
- }
7414
- wildcardIndex++;
7415
- // Remove the optional group and wildcard when no value
7416
- return "";
7417
- });
7418
7633
 
7419
- // Replace named parameters (:param and {param}) and remove optional markers
7420
- for (const key of keys) {
7421
- // Skip numeric keys (wildcards) if they were already processed
7422
- if (!isNaN(key) && parseInt(key) < wildcardIndex) {
7423
- continue;
7634
+ /**
7635
+ * Build the most precise URL by using route relationships from pattern registry.
7636
+ * Each route is responsible for its own URL generation using its own signals.
7637
+ */
7638
+ const buildMostPreciseUrl = (params = {}) => {
7639
+ // Handle parameter resolution internally to preserve user intent detection
7640
+ const resolvedParams = resolveParams(params);
7641
+
7642
+ // Start with resolved parameters
7643
+ let finalParams = { ...resolvedParams };
7644
+
7645
+ for (const connection of connections) {
7646
+ const { paramName, signal, options } = connection;
7647
+ const defaultValue = options.defaultValue;
7648
+
7649
+ if (paramName in finalParams) {
7650
+ // Parameter was explicitly provided - ALWAYS respect explicit values
7651
+ // If it equals the default value, remove it for shorter URLs
7652
+ if (finalParams[paramName] === defaultValue) {
7653
+ delete finalParams[paramName];
7654
+ }
7655
+ // Note: Don't fall through to signal logic - explicit params take precedence
7656
+ }
7657
+ // Parameter was NOT provided, check signal value
7658
+ else if (signal?.value !== undefined && signal.value !== defaultValue) {
7659
+ // Only include signal value if it's not the default
7660
+ finalParams[paramName] = signal.value;
7661
+ // If signal.value === defaultValue, omit the parameter for shorter URL
7662
+ }
7663
+ }
7664
+
7665
+ // DEEPEST URL GENERATION: Check if we should use a child route instead
7666
+ // This happens when:
7667
+ // 1. This route's parameters are all defaults (would be omitted)
7668
+ // 2. A child route has non-default parameters that should be included
7669
+
7670
+ // DEEPEST URL GENERATION: Only activate when NO explicit parameters provided
7671
+ // This prevents overriding explicit user intentions with signal-based "smart" routing
7672
+ // We need to distinguish between user-provided params and signal-derived params
7673
+ let hasUserProvidedParams = false;
7674
+
7675
+ // Check if provided params contain anything beyond what signals would provide
7676
+ const signalDerivedParams = {};
7677
+ for (const { paramName, signal, options } of connections) {
7678
+ if (signal?.value !== undefined) {
7679
+ const defaultValue = options.defaultValue;
7680
+ // Only include signal value if it's not the default (same logic as above)
7681
+ if (signal.value !== defaultValue) {
7682
+ signalDerivedParams[paramName] = signal.value;
7683
+ }
7684
+ }
7685
+ }
7686
+
7687
+ // Check if original params (before resolution) contains anything that's not from signals
7688
+ // This preserves user intent detection for explicit parameters
7689
+ for (const [key, value] of Object.entries(params)) {
7690
+ if (signalDerivedParams[key] !== value) {
7691
+ hasUserProvidedParams = true;
7692
+ break;
7693
+ }
7694
+ }
7695
+
7696
+ // Also check if original params has extra keys beyond what signals provide
7697
+ const providedKeys = new Set(Object.keys(params));
7698
+ const signalKeys = new Set(Object.keys(signalDerivedParams));
7699
+ for (const key of providedKeys) {
7700
+ if (!signalKeys.has(key)) {
7701
+ hasUserProvidedParams = true;
7702
+ break;
7703
+ }
7704
+ }
7705
+
7706
+ // ROOT ROUTE PROTECTION: Never apply deepest URL generation to root route "/"
7707
+ // Users must always be able to navigate to home page regardless of app state
7708
+ const isRootRoute = pattern === "/";
7709
+
7710
+ const relationships = patternRelationships.get(pattern);
7711
+ const childPatterns = relationships?.childPatterns || [];
7712
+ if (!hasUserProvidedParams && !isRootRoute && childPatterns.length) {
7713
+ // Try to find the most specific child pattern that has active signals
7714
+ for (const childPattern of childPatterns) {
7715
+ const childPatternData = getPatternData(childPattern);
7716
+ if (!childPatternData) continue;
7717
+
7718
+ // Check if any of this child's parameters have non-default signal values
7719
+ let hasActiveParams = false;
7720
+ const childParams = {};
7721
+
7722
+ // Include parent signal values for child pattern matching
7723
+ // But first check if they're compatible with the child pattern
7724
+ let parentSignalsCompatibleWithChild = true;
7725
+ for (const parentConnection of connections) {
7726
+ const { paramName, signal, options } = parentConnection;
7727
+ // Only include non-default parent signal values
7728
+ if (
7729
+ signal?.value !== undefined &&
7730
+ signal.value !== options.defaultValue
7731
+ ) {
7732
+ // Check if child pattern has conflicting literal segments for this parameter
7733
+ const childParsedPattern = childPatternData.parsedPattern;
7734
+
7735
+ // Check if parent signal value matches a literal segment in child pattern
7736
+ const matchesChildLiteral = childParsedPattern.segments.some(
7737
+ (segment) =>
7738
+ segment.type === "literal" && segment.value === signal.value,
7739
+ );
7740
+
7741
+ // If parent signal matches a literal in child, don't add as parameter
7742
+ // (it's already represented in the child URL path)
7743
+ if (matchesChildLiteral) {
7744
+ // Compatible - signal value matches child literal, no need to add param
7745
+ continue;
7746
+ }
7747
+
7748
+ // For section parameter specifically, check if child has literal "settings"
7749
+ // but parent signal has different value (incompatible case)
7750
+ if (paramName === "section" && signal.value !== "settings") {
7751
+ const hasSettingsLiteral = childParsedPattern.segments.some(
7752
+ (segment) =>
7753
+ segment.type === "literal" && segment.value === "settings",
7754
+ );
7755
+ if (hasSettingsLiteral) {
7756
+ parentSignalsCompatibleWithChild = false;
7757
+ break;
7758
+ }
7759
+ }
7760
+
7761
+ // Only add parent signal as parameter if it doesn't match child literals
7762
+ childParams[paramName] = signal.value;
7763
+ }
7764
+ }
7765
+
7766
+ // Skip this child if parent signals are incompatible
7767
+ if (!parentSignalsCompatibleWithChild) {
7768
+ continue;
7769
+ }
7770
+
7771
+ // Check child connections and see if any have non-default values
7772
+ for (const connection of childPatternData.connections) {
7773
+ const { paramName, signal, options } = connection;
7774
+ const defaultValue = options.defaultValue;
7775
+
7776
+ if (signal?.value !== undefined) {
7777
+ childParams[paramName] = signal.value;
7778
+ if (signal.value !== defaultValue) {
7779
+ hasActiveParams = true;
7780
+ }
7781
+ }
7782
+ }
7783
+
7784
+ // If child has non-default parameters, use the child route
7785
+ if (hasActiveParams) {
7786
+ const childPatternObj = createRoutePattern(childPattern);
7787
+ // Use buildUrl (not buildMostPreciseUrl) to avoid infinite recursion
7788
+ const childUrl = childPatternObj.buildUrl(childParams);
7789
+ if (childUrl) {
7790
+ return childUrl;
7791
+ }
7792
+ }
7424
7793
  }
7794
+ }
7795
+
7796
+ // PARENT PARAMETER INHERITANCE: Inherit query parameters from parent patterns
7797
+ // This allows child routes like "/map/isochrone" to inherit "zoom=15" from parent "/map/?zoom=..."
7798
+ const parentPatterns = relationships?.parentPatterns || [];
7799
+ for (const parentPattern of parentPatterns) {
7800
+ const parentPatternData = getPatternData(parentPattern);
7801
+ if (!parentPatternData) continue;
7802
+
7803
+ // Check parent's signal connections for non-default values to inherit
7804
+ for (const parentConnection of parentPatternData.connections) {
7805
+ const { paramName, signal, options } = parentConnection;
7806
+ const defaultValue = options.defaultValue;
7807
+
7808
+ // If we don't already have this parameter and parent signal has non-default value
7809
+ if (
7810
+ !(paramName in finalParams) &&
7811
+ signal?.value !== undefined &&
7812
+ signal.value !== defaultValue
7813
+ ) {
7814
+ // Check if this parameter corresponds to a literal segment in our path
7815
+ // E.g., don't inherit "section=analytics" if our path is "/admin/analytics"
7816
+ const shouldInherit = !isParameterRedundantWithLiteralSegments(
7817
+ parsedPattern,
7818
+ parentPatternData.parsedPattern,
7819
+ paramName,
7820
+ signal.value,
7821
+ );
7425
7822
 
7426
- const value = params[key];
7427
- const encodedValue = encodeParamValue(value, false); // Named parameters should encode slashes
7428
- const beforeReplace = relativeUrl;
7429
-
7430
- // Replace parameter and remove optional marker if present
7431
- relativeUrl = relativeUrl.replace(`:${key}?`, encodedValue);
7432
- relativeUrl = relativeUrl.replace(`:${key}`, encodedValue);
7433
- relativeUrl = relativeUrl.replace(`{${key}}?`, encodedValue);
7434
- relativeUrl = relativeUrl.replace(`{${key}}`, encodedValue);
7435
-
7436
- // If the URL did not change we'll maybe delete that param
7437
- if (relativeUrl === beforeReplace) {
7438
- extraParamMap.set(key, value);
7439
- }
7440
- }
7441
- // Handle complex optional groups like {/time/:duration}?
7442
- // Replace parameters inside optional groups and remove the optional marker
7443
- relativeUrl = relativeUrl.replace(/\{([^}]*)\}\?/g, (match, group) => {
7444
- let processedGroup = group;
7445
- let hasReplacements = false;
7446
-
7447
- // Check if any parameters in the group were provided
7448
- for (const key of keys) {
7449
- if (params[key] !== undefined) {
7450
- const encodedValue = encodeParamValue(params[key], false); // Named parameters encode slashes
7451
- const paramPattern = new RegExp(`:${key}\\b`);
7452
- if (paramPattern.test(processedGroup)) {
7453
- processedGroup = processedGroup.replace(paramPattern, encodedValue);
7454
- hasReplacements = true;
7455
- extraParamMap.delete(key);
7823
+ if (shouldInherit) {
7824
+ // Inherit the parent's signal value
7825
+ finalParams[paramName] = signal.value;
7456
7826
  }
7457
7827
  }
7458
7828
  }
7829
+ }
7459
7830
 
7460
- // Also check for literal parts that match parameter names (like /time where time is a param)
7461
- for (const key of keys) {
7462
- if (params[key] !== undefined) {
7463
- const encodedValue = encodeParamValue(params[key], false); // Named parameters encode slashes
7464
- // Check for literal parts like /time that match parameter names
7465
- const literalPattern = new RegExp(`\\/${key}\\b`);
7466
- if (literalPattern.test(processedGroup)) {
7467
- processedGroup = processedGroup.replace(
7468
- literalPattern,
7469
- `/${encodedValue}`,
7470
- );
7471
- hasReplacements = true;
7472
- extraParamMap.delete(key);
7831
+ if (!parsedPattern.segments) {
7832
+ return "/";
7833
+ }
7834
+
7835
+ // Filter out segments for parameters that are not provided (omitted defaults)
7836
+ const filteredPattern = {
7837
+ ...parsedPattern,
7838
+ segments: parsedPattern.segments.filter((segment) => {
7839
+ if (segment.type === "param") {
7840
+ // Only keep parameter segments if we have a value for them
7841
+ return segment.name in finalParams;
7842
+ }
7843
+ // Always keep literal segments
7844
+ return true;
7845
+ }),
7846
+ };
7847
+
7848
+ // Remove trailing slash if we filtered out segments
7849
+ if (
7850
+ filteredPattern.segments.length < parsedPattern.segments.length &&
7851
+ parsedPattern.trailingSlash
7852
+ ) {
7853
+ filteredPattern.trailingSlash = false;
7854
+ }
7855
+
7856
+ return buildUrlFromPattern(filteredPattern, finalParams);
7857
+ };
7858
+
7859
+ return {
7860
+ originalPattern: pattern, // Return the original pattern string
7861
+ pattern: parsedPattern,
7862
+ cleanPattern, // Return the clean pattern string
7863
+ connections, // Return signal connections along with pattern
7864
+ applyOn,
7865
+ buildUrl,
7866
+ buildMostPreciseUrl,
7867
+ resolveParams,
7868
+ };
7869
+ };
7870
+
7871
+ /**
7872
+ * Parse a route pattern string into structured segments
7873
+ */
7874
+ const parsePattern = (pattern, parameterDefaults = new Map()) => {
7875
+ // Handle root route
7876
+ if (pattern === "/") {
7877
+ return {
7878
+ original: pattern,
7879
+ segments: [],
7880
+ trailingSlash: true,
7881
+ wildcard: false,
7882
+ queryParams: [],
7883
+ };
7884
+ }
7885
+
7886
+ // Separate path and query portions
7887
+ const [pathPortion, queryPortion] = pattern.split("?");
7888
+
7889
+ // Parse query parameters if present
7890
+ const queryParams = [];
7891
+ if (queryPortion) {
7892
+ // Split query parameters by & and parse each one
7893
+ const querySegments = queryPortion.split("&");
7894
+ for (const querySegment of querySegments) {
7895
+ if (querySegment.includes("=")) {
7896
+ // Parameter with potential value: tab=value or just tab
7897
+ const [paramName, paramValue] = querySegment.split("=", 2);
7898
+ queryParams.push({
7899
+ type: "query_param",
7900
+ name: paramName,
7901
+ hasDefaultValue: paramValue === undefined, // No value means it uses signal/default
7902
+ });
7903
+ } else {
7904
+ // Parameter without value: tab
7905
+ queryParams.push({
7906
+ type: "query_param",
7907
+ name: querySegment,
7908
+ hasDefaultValue: true,
7909
+ });
7910
+ }
7911
+ }
7912
+ }
7913
+
7914
+ // Remove leading slash for processing the path portion
7915
+ let cleanPattern = pathPortion.startsWith("/")
7916
+ ? pathPortion.slice(1)
7917
+ : pathPortion;
7918
+
7919
+ // Check for wildcard first
7920
+ const wildcard = cleanPattern.endsWith("*");
7921
+ if (wildcard) {
7922
+ cleanPattern = cleanPattern.slice(0, -1); // Remove *
7923
+ // Also remove the slash before * if present
7924
+ if (cleanPattern.endsWith("/")) {
7925
+ cleanPattern = cleanPattern.slice(0, -1);
7926
+ }
7927
+ }
7928
+
7929
+ // Check for trailing slash (after wildcard check)
7930
+ const trailingSlash = !wildcard && pathPortion.endsWith("/");
7931
+ if (trailingSlash) {
7932
+ cleanPattern = cleanPattern.slice(0, -1); // Remove trailing /
7933
+ }
7934
+
7935
+ // Split into segments (filter out empty segments)
7936
+ const segmentStrings = cleanPattern
7937
+ ? cleanPattern.split("/").filter((s) => s !== "")
7938
+ : [];
7939
+ const segments = segmentStrings.map((seg, index) => {
7940
+ if (seg.startsWith(":")) {
7941
+ // Parameter segment
7942
+ const paramName = seg.slice(1).replace("?", ""); // Remove : and optional ?
7943
+ const isOptional = seg.endsWith("?") || parameterDefaults.has(paramName);
7944
+
7945
+ return {
7946
+ type: "param",
7947
+ name: paramName,
7948
+ optional: isOptional,
7949
+ index,
7950
+ };
7951
+ }
7952
+ // Literal segment
7953
+ return {
7954
+ type: "literal",
7955
+ value: seg,
7956
+ index,
7957
+ };
7958
+ });
7959
+
7960
+ return {
7961
+ original: pattern,
7962
+ segments,
7963
+ queryParams, // Add query parameters to the parsed pattern
7964
+ trailingSlash,
7965
+ wildcard,
7966
+ };
7967
+ };
7968
+
7969
+ /**
7970
+ * Check if a literal segment can be treated as optional based on parent route signal defaults
7971
+ */
7972
+ const checkIfLiteralCanBeOptional = (literalValue, patternRegistry) => {
7973
+ // Look through all registered patterns for parent patterns that might have this literal as a default
7974
+ for (const [, patternData] of patternRegistry) {
7975
+ // Check if any signal connection has this literal value as default
7976
+ for (const connection of patternData.connections) {
7977
+ if (connection.options.defaultValue === literalValue) {
7978
+ return true; // This literal matches a signal default, so it can be optional
7979
+ }
7980
+ }
7981
+ }
7982
+ return false;
7983
+ };
7984
+
7985
+ /**
7986
+ * Match a URL against a parsed pattern
7987
+ */
7988
+ const matchUrl = (parsedPattern, url, { parameterDefaults, baseUrl }) => {
7989
+ // Parse the URL
7990
+ const urlObj = new URL(url, baseUrl);
7991
+ let pathname = urlObj.pathname;
7992
+ const originalPathname = pathname; // Store original pathname before baseUrl processing
7993
+
7994
+ // If baseUrl is provided, calculate the pathname relative to the baseUrl's directory
7995
+ if (baseUrl) {
7996
+ const baseUrlObj = new URL(baseUrl);
7997
+ // if the base url is a file, we want to be relative to the directory containing that file
7998
+ const baseDir = baseUrlObj.pathname.endsWith("/")
7999
+ ? baseUrlObj.pathname
8000
+ : baseUrlObj.pathname.substring(0, baseUrlObj.pathname.lastIndexOf("/"));
8001
+ if (pathname.startsWith(baseDir)) {
8002
+ pathname = pathname.slice(baseDir.length);
8003
+ }
8004
+ }
8005
+
8006
+ // Handle root route - only matches empty path or just "/"
8007
+ // OR when URL exactly matches baseUrl (treating baseUrl as root)
8008
+ if (parsedPattern.segments.length === 0) {
8009
+ if (pathname === "/" || pathname === "") {
8010
+ return extractSearchParams(urlObj);
8011
+ }
8012
+
8013
+ // Special case: if URL exactly matches baseUrl, treat as root route
8014
+ if (baseUrl) {
8015
+ const baseUrlObj = new URL(baseUrl);
8016
+ if (originalPathname === baseUrlObj.pathname) {
8017
+ return extractSearchParams(urlObj);
8018
+ }
8019
+ }
8020
+
8021
+ return null;
8022
+ }
8023
+
8024
+ // Remove leading slash and split into segments
8025
+ let urlSegments = pathname.startsWith("/")
8026
+ ? pathname
8027
+ .slice(1)
8028
+ .split("/")
8029
+ .filter((s) => s !== "")
8030
+ : pathname.split("/").filter((s) => s !== "");
8031
+
8032
+ // Handle trailing slash flexibility: if pattern has trailing slash but URL doesn't (or vice versa)
8033
+ // and we're at the end of segments, allow the match
8034
+ const urlHasTrailingSlash = pathname.endsWith("/") && pathname !== "/";
8035
+ const patternHasTrailingSlash = parsedPattern.trailingSlash;
8036
+
8037
+ const params = {};
8038
+ let urlSegmentIndex = 0;
8039
+
8040
+ // Process each pattern segment
8041
+ for (let i = 0; i < parsedPattern.segments.length; i++) {
8042
+ const patternSeg = parsedPattern.segments[i];
8043
+
8044
+ if (patternSeg.type === "literal") {
8045
+ // Check if URL has this segment
8046
+ if (urlSegmentIndex >= urlSegments.length) {
8047
+ // URL is too short for this literal segment
8048
+ // Check if this literal segment can be treated as optional based on parent route defaults
8049
+ const canBeOptional = checkIfLiteralCanBeOptional(
8050
+ patternSeg.value,
8051
+ patternRegistry,
8052
+ );
8053
+ if (canBeOptional) {
8054
+ // Skip this literal segment, don't increment urlSegmentIndex
8055
+ continue;
8056
+ }
8057
+ return null; // URL too short and literal is not optional
8058
+ }
8059
+
8060
+ const urlSeg = urlSegments[urlSegmentIndex];
8061
+ if (urlSeg !== patternSeg.value) {
8062
+ // Literal mismatch - this route doesn't match this URL
8063
+ return null;
8064
+ }
8065
+ urlSegmentIndex++;
8066
+ } else if (patternSeg.type === "param") {
8067
+ // Parameter segment
8068
+ if (urlSegmentIndex >= urlSegments.length) {
8069
+ // No URL segment for this parameter
8070
+ if (patternSeg.optional) {
8071
+ // Optional parameter - use default if available
8072
+ const defaultValue = parameterDefaults.get(patternSeg.name);
8073
+ if (defaultValue !== undefined) {
8074
+ params[patternSeg.name] = defaultValue;
8075
+ }
8076
+ continue;
8077
+ }
8078
+ // Required parameter missing - but check if we can use trailing slash logic
8079
+ // If this is the last segment and we have a trailing slash difference, it might still match
8080
+ const isLastSegment = i === parsedPattern.segments.length - 1;
8081
+ if (isLastSegment && patternHasTrailingSlash && !urlHasTrailingSlash) {
8082
+ // Pattern expects trailing slash segment, URL doesn't have it
8083
+ const defaultValue = parameterDefaults.get(patternSeg.name);
8084
+ if (defaultValue !== undefined) {
8085
+ params[patternSeg.name] = defaultValue;
8086
+ continue;
7473
8087
  }
7474
8088
  }
8089
+ return null; // Required parameter missing
7475
8090
  }
7476
8091
 
7477
- // If we made replacements, include the group (without the optional marker)
7478
- // If no replacements, return empty string (remove the optional group)
7479
- return hasReplacements ? processedGroup : "";
7480
- });
8092
+ // Capture URL segment as parameter value
8093
+ const urlSeg = urlSegments[urlSegmentIndex];
8094
+ params[patternSeg.name] = decodeURIComponent(urlSeg);
8095
+ urlSegmentIndex++;
8096
+ }
7481
8097
  }
7482
8098
 
7483
- // Clean up any double slashes or trailing slashes that might result
7484
- relativeUrl = relativeUrl.replace(/\/+/g, "/").replace(/\/$/, "");
8099
+ // Check for remaining URL segments
8100
+ // Patterns with trailing slashes can match additional URL segments (like wildcards)
8101
+ // Patterns without trailing slashes should match exactly (unless they're wildcards)
8102
+ if (
8103
+ !parsedPattern.wildcard &&
8104
+ !parsedPattern.trailingSlash &&
8105
+ urlSegmentIndex < urlSegments.length
8106
+ ) {
8107
+ return null; // Pattern without trailing slash should not match extra segments
8108
+ }
8109
+ // If pattern has trailing slash or wildcard, allow extra segments (no additional check needed)
8110
+
8111
+ // Add search parameters
8112
+ const searchParams = extractSearchParams(urlObj);
8113
+ Object.assign(params, searchParams);
8114
+
8115
+ // Apply remaining parameter defaults for unmatched parameters
8116
+ for (const [paramName, defaultValue] of parameterDefaults) {
8117
+ if (!(paramName in params)) {
8118
+ params[paramName] = defaultValue;
8119
+ }
8120
+ }
8121
+
8122
+ return params;
8123
+ };
8124
+
8125
+ /**
8126
+ * Extract search parameters from URL
8127
+ */
8128
+ const extractSearchParams = (urlObj) => {
8129
+ const params = {};
8130
+ for (const [key, value] of urlObj.searchParams) {
8131
+ params[key] = value;
8132
+ }
8133
+ return params;
8134
+ };
8135
+
8136
+ /**
8137
+ * Build a URL from a pattern and parameters
8138
+ */
8139
+ const buildUrlFromPattern = (parsedPattern, params = {}) => {
8140
+ if (parsedPattern.segments.length === 0) {
8141
+ // Root route
8142
+ const searchParams = new URLSearchParams();
8143
+ for (const [key, value] of Object.entries(params)) {
8144
+ if (value !== undefined) {
8145
+ searchParams.set(key, value);
8146
+ }
8147
+ }
8148
+ const search = searchParams.toString();
8149
+ return `/${search ? `?${search}` : ""}`;
8150
+ }
7485
8151
 
7486
- // Handle remaining wildcards (those not processed by optional group + wildcard above)
7487
- if (params) {
7488
- relativeUrl = relativeUrl.replace(/\*/g, () => {
7489
- const paramKey = wildcardIndex.toString();
7490
- const paramValue = params[paramKey];
7491
- if (paramValue) {
7492
- extraParamMap.delete(paramKey);
7493
- const replacement = encodeParamValue(paramValue, true); // Wildcards preserve slashes
7494
- wildcardIndex++;
7495
- return replacement;
8152
+ const segments = [];
8153
+
8154
+ for (const patternSeg of parsedPattern.segments) {
8155
+ if (patternSeg.type === "literal") {
8156
+ segments.push(patternSeg.value);
8157
+ } else if (patternSeg.type === "param") {
8158
+ const value = params[patternSeg.name];
8159
+
8160
+ // If value is provided, include it
8161
+ if (value !== undefined) {
8162
+ segments.push(encodeURIComponent(value));
8163
+ } else if (!patternSeg.optional) {
8164
+ // For required parameters without values, keep the placeholder
8165
+ segments.push(`:${patternSeg.name}`);
7496
8166
  }
7497
- wildcardIndex++;
7498
- return "*";
8167
+ // Optional parameters with undefined values are omitted
8168
+ }
8169
+ }
8170
+
8171
+ let path = `/${segments.join("/")}`;
8172
+
8173
+ // Handle trailing slash - only add if it serves a purpose
8174
+ if (parsedPattern.trailingSlash && !path.endsWith("/") && path !== "/") {
8175
+ // Only add trailing slash if the original pattern suggests there could be more content
8176
+ // For patterns like "/admin/:section/" where the slash is at the very end,
8177
+ // it's not needed in the generated URL if there are no more segments
8178
+ const lastSegment =
8179
+ parsedPattern.segments[parsedPattern.segments.length - 1];
8180
+ const hasMorePotentialContent =
8181
+ parsedPattern.wildcard || (lastSegment && lastSegment.type === "literal"); // Only add slash after literals, not parameters
8182
+
8183
+ if (hasMorePotentialContent) {
8184
+ path += "/";
8185
+ }
8186
+ } else if (
8187
+ !parsedPattern.trailingSlash &&
8188
+ path.endsWith("/") &&
8189
+ path !== "/"
8190
+ ) {
8191
+ // Remove trailing slash for patterns without trailing slash
8192
+ path = path.slice(0, -1);
8193
+ }
8194
+
8195
+ // Check if we'll have query parameters to decide on trailing slash removal
8196
+ const willHaveQueryParams =
8197
+ parsedPattern.queryParams?.some((qp) => {
8198
+ const value = params[qp.name];
8199
+ return value !== undefined;
8200
+ }) ||
8201
+ Object.entries(params).some(([key, value]) => {
8202
+ const isPathParam = parsedPattern.segments.some(
8203
+ (s) => s.type === "param" && s.name === key,
8204
+ );
8205
+ const isQueryParam = parsedPattern.queryParams?.some(
8206
+ (qp) => qp.name === key,
8207
+ );
8208
+ return value !== undefined && !isPathParam && !isQueryParam;
7499
8209
  });
8210
+
8211
+ // Remove trailing slash when we have query params for prettier URLs
8212
+ if (willHaveQueryParams && path.endsWith("/") && path !== "/") {
8213
+ path = path.slice(0, -1);
8214
+ }
8215
+
8216
+ // Add search parameters
8217
+ const pathParamNames = new Set(
8218
+ parsedPattern.segments.filter((s) => s.type === "param").map((s) => s.name),
8219
+ );
8220
+
8221
+ // Add query parameters defined in the pattern first
8222
+ const queryParamNames = new Set();
8223
+ const searchParams = new URLSearchParams();
8224
+
8225
+ // Handle pattern-defined query parameters (from ?tab, &lon, etc.)
8226
+ if (parsedPattern.queryParams) {
8227
+ for (const queryParam of parsedPattern.queryParams) {
8228
+ const paramName = queryParam.name;
8229
+ queryParamNames.add(paramName);
8230
+
8231
+ const value = params[paramName];
8232
+ if (value !== undefined) {
8233
+ searchParams.set(paramName, value);
8234
+ }
8235
+ // If no value provided, don't add the parameter to keep URLs clean
8236
+ }
8237
+ }
8238
+
8239
+ // Add remaining parameters as additional query parameters (excluding path and pattern query params)
8240
+ for (const [key, value] of Object.entries(params)) {
8241
+ if (
8242
+ !pathParamNames.has(key) &&
8243
+ !queryParamNames.has(key) &&
8244
+ value !== undefined
8245
+ ) {
8246
+ searchParams.set(key, value);
8247
+ }
7500
8248
  }
7501
8249
 
7502
- // Handle optional parts after parameter replacement
7503
- // This includes patterns like /*?, {/time/*}?, :param?, etc.
7504
- relativeUrl = removeOptionalParts(relativeUrl);
7505
- // we did not replace anything, or not enough to remove the last "*"
7506
- if (relativeUrl.endsWith("*")) {
7507
- relativeUrl = relativeUrl.slice(0, -1);
8250
+ const search = searchParams.toString();
8251
+
8252
+ // No longer handle trailing slash inheritance here
8253
+
8254
+ return path + (search ? `?${search}` : "");
8255
+ };
8256
+
8257
+ /**
8258
+ * Check if childPattern is a child route of parentPattern
8259
+ * E.g., "/admin/settings/:tab" is a child of "/admin/:section/"
8260
+ * Also, "/admin/?tab=something" is a child of "/admin/"
8261
+ */
8262
+ const isChildPattern = (childPattern, parentPattern) => {
8263
+ // Split path and query parts
8264
+ const [childPath, childQuery] = childPattern.split("?");
8265
+ const [parentPath, parentQuery] = parentPattern.split("?");
8266
+
8267
+ // Remove trailing slashes for path comparison
8268
+ const cleanChild = childPath.replace(/\/$/, "");
8269
+ const cleanParent = parentPath.replace(/\/$/, "");
8270
+
8271
+ // CASE 1: Same path, child has query params, parent doesn't
8272
+ // E.g., "/admin/?tab=something" is child of "/admin/"
8273
+ if (cleanChild === cleanParent && childQuery && !parentQuery) {
8274
+ return true;
8275
+ }
8276
+
8277
+ // CASE 2: Traditional path-based child relationship
8278
+ // Convert patterns to comparable segments for proper comparison
8279
+ const childSegments = cleanChild.split("/").filter((s) => s);
8280
+ const parentSegments = cleanParent.split("/").filter((s) => s);
8281
+
8282
+ // Child must have at least as many segments as parent
8283
+ if (childSegments.length < parentSegments.length) {
8284
+ return false;
8285
+ }
8286
+
8287
+ let hasMoreSpecificSegment = false;
8288
+
8289
+ // Check if parent segments match child segments (allowing for parameters)
8290
+ for (let i = 0; i < parentSegments.length; i++) {
8291
+ const parentSeg = parentSegments[i];
8292
+ const childSeg = childSegments[i];
8293
+
8294
+ // If parent has parameter, child can have anything in that position
8295
+ if (parentSeg.startsWith(":")) {
8296
+ // Child is more specific if it has a literal value for a parent parameter
8297
+ // But if child also starts with ":", it's also a parameter (not more specific)
8298
+ if (!childSeg.startsWith(":")) {
8299
+ hasMoreSpecificSegment = true;
8300
+ }
8301
+ continue;
8302
+ }
8303
+
8304
+ // If parent has literal, child must match exactly
8305
+ if (parentSeg !== childSeg) {
8306
+ return false;
8307
+ }
8308
+ }
8309
+
8310
+ // Child must be more specific (more segments OR more specific segments)
8311
+ return childSegments.length > parentSegments.length || hasMoreSpecificSegment;
8312
+ };
8313
+
8314
+ /**
8315
+ * Check if a parameter is redundant because the child pattern already has it as a literal segment
8316
+ * E.g., parameter "section" is redundant for pattern "/admin/settings/:tab" because "settings" is literal
8317
+ */
8318
+ const isParameterRedundantWithLiteralSegments = (
8319
+ childPattern,
8320
+ parentPattern,
8321
+ paramName,
8322
+ ) => {
8323
+ // Find which segment position corresponds to this parameter in the parent
8324
+ let paramSegmentIndex = -1;
8325
+ for (let i = 0; i < parentPattern.segments.length; i++) {
8326
+ const segment = parentPattern.segments[i];
8327
+ if (segment.type === "param" && segment.name === paramName) {
8328
+ paramSegmentIndex = i;
8329
+ break;
8330
+ }
7508
8331
  }
7509
8332
 
7510
- // Normalize trailing slash: always favor URLs without trailing slash
7511
- // except for root path which should remain "/"
7512
- if (relativeUrl.endsWith("/") && relativeUrl.length > 1) {
7513
- relativeUrl = relativeUrl.slice(0, -1);
8333
+ // If parameter not found in parent segments, it's not redundant with path
8334
+ if (paramSegmentIndex === -1) {
8335
+ return false;
7514
8336
  }
7515
8337
 
7516
- // Add remaining parameters as search params
7517
- if (extraParamMap.size > 0) {
7518
- if (extraParamEffect === "inject_as_search_param") {
7519
- const searchParamPairs = [];
7520
- for (const [key, value] of extraParamMap) {
7521
- if (value !== undefined && value !== null) {
7522
- const encodedKey = encodeURIComponent(key);
7523
- // Handle boolean values - if true, just add the key without value
7524
- if (value === true) {
7525
- searchParamPairs.push(encodedKey);
7526
- } else {
7527
- const encodedValue = encodeParamValue(value, false); // Search params encode slashes
7528
- searchParamPairs.push(`${encodedKey}=${encodedValue}`);
7529
- }
7530
- }
7531
- }
7532
- if (searchParamPairs.length > 0) {
7533
- const searchString = searchParamPairs.join("&");
7534
- relativeUrl += (relativeUrl.includes("?") ? "&" : "?") + searchString;
7535
- }
7536
- } else if (extraParamEffect === "warn") {
7537
- console.warn(
7538
- `Unknown parameters given to "${urlPatternInput}":`,
7539
- Array.from(extraParamMap.keys()),
7540
- );
8338
+ // Check if child has a literal segment at the same position
8339
+ if (childPattern.segments.length > paramSegmentIndex) {
8340
+ const childSegment = childPattern.segments[paramSegmentIndex];
8341
+ if (childSegment.type === "literal") {
8342
+ // Child has a literal segment where parent has parameter
8343
+ // This means the child is more specific and shouldn't inherit this parameter
8344
+ return true; // Redundant - child already specifies this position with a literal
7541
8345
  }
7542
8346
  }
7543
8347
 
7544
- // Append string query params if any
7545
- if (stringQueryParams) {
7546
- relativeUrl += (relativeUrl.includes("?") ? "&" : "?") + stringQueryParams;
7547
- }
7548
-
7549
- return {
7550
- relativeUrl,
7551
- hasRawUrlPartWithInvalidChars,
7552
- };
8348
+ return false;
7553
8349
  };
7554
8350
 
7555
- const createRoutePattern = (urlPatternInput, baseUrl) => {
7556
- // Remove leading slash from urlPattern to make it relative to baseUrl
7557
- const normalizedUrlPattern = urlPatternInput.startsWith("/")
7558
- ? urlPatternInput.slice(1)
7559
- : urlPatternInput;
7560
- const urlPattern = new URLPattern(normalizedUrlPattern, baseUrl, {
7561
- ignoreCase: true,
7562
- });
8351
+ /**
8352
+ * Register all patterns at once and build their relationships
8353
+ */
8354
+ const setupPatterns = (patternDefinitions) => {
8355
+ // Clear existing patterns
8356
+ patternRegistry.clear();
8357
+ patternRelationships.clear();
7563
8358
 
7564
- // Analyze pattern once to detect optional params (named and wildcard indices)
7565
- // Note: Wildcard indices are stored as strings ("0", "1", ...) to match keys from extractParams
7566
- const optionalParamKeySet = new Set();
7567
- normalizedUrlPattern.replace(/:([A-Za-z0-9_]+)\?/g, (_m, name) => {
7568
- optionalParamKeySet.add(name);
7569
- return "";
7570
- });
7571
- let wildcardIndex = 0;
7572
- normalizedUrlPattern.replace(/\*(\?)?/g, (_m, opt) => {
7573
- if (opt === "?") {
7574
- optionalParamKeySet.add(String(wildcardIndex));
7575
- }
7576
- wildcardIndex++;
7577
- return "";
7578
- });
8359
+ // Phase 1: Register all patterns
8360
+ for (const [key, urlPatternRaw] of Object.entries(patternDefinitions)) {
8361
+ const [cleanPattern, connections] = detectSignals(urlPatternRaw);
8362
+ const parsedPattern = parsePattern(cleanPattern);
7579
8363
 
7580
- const applyOn = (url) => {
8364
+ const patternData = {
8365
+ key,
8366
+ urlPatternRaw,
8367
+ cleanPattern,
8368
+ connections,
8369
+ parsedPattern,
8370
+ childPatterns: [],
8371
+ parentPatterns: [],
8372
+ };
7581
8373
 
7582
- // Check if the URL matches the route pattern
7583
- const match = urlPattern.exec(url);
7584
- if (match) {
7585
- return extractParams(match, url);
7586
- }
8374
+ patternRegistry.set(urlPatternRaw, patternData);
8375
+ }
7587
8376
 
7588
- // If no match, try with normalized URLs (trailing slash handling)
7589
- const urlObj = new URL(url, baseUrl);
7590
- const pathname = urlObj.pathname;
8377
+ // Phase 2: Build relationships between all patterns
8378
+ const allPatterns = Array.from(patternRegistry.keys());
7591
8379
 
7592
- // Try removing trailing slash from pathname
7593
- if (pathname.endsWith("/") && pathname.length > 1) {
7594
- const pathnameWithoutSlash = pathname.slice(0, -1);
7595
- urlObj.pathname = pathnameWithoutSlash;
7596
- const normalizedUrl = urlObj.href;
7597
- const matchWithoutTrailingSlash = urlPattern.exec(normalizedUrl);
7598
- if (matchWithoutTrailingSlash) {
7599
- return extractParams(matchWithoutTrailingSlash, url);
7600
- }
7601
- }
7602
- // Try adding trailing slash to pathname
7603
- else if (!pathname.endsWith("/")) {
7604
- const pathnameWithSlash = `${pathname}/`;
7605
- urlObj.pathname = pathnameWithSlash;
7606
- const normalizedUrl = urlObj.href;
7607
- const matchWithTrailingSlash = urlPattern.exec(normalizedUrl);
7608
- if (matchWithTrailingSlash) {
7609
- return extractParams(matchWithTrailingSlash, url);
7610
- }
7611
- }
7612
- return null;
7613
- };
8380
+ for (const currentPattern of allPatterns) {
8381
+ const currentData = patternRegistry.get(currentPattern);
7614
8382
 
7615
- const extractParams = (match, originalUrl) => {
7616
- const params = {};
7617
-
7618
- // Extract search parameters from the original URL
7619
- const urlObj = new URL(originalUrl, baseUrl);
7620
- for (const [key, value] of urlObj.searchParams) {
7621
- params[key] = value;
7622
- }
7623
-
7624
- // Collect all parameters from URLPattern groups, handling both named and numbered groups
7625
- let wildcardOffset = 0;
7626
- for (const property of URL_PATTERN_PROPERTIES_WITH_GROUP_SET) {
7627
- const urlPartMatch = match[property];
7628
- if (urlPartMatch && urlPartMatch.groups) {
7629
- let localWildcardCount = 0;
7630
- for (const key of Object.keys(urlPartMatch.groups)) {
7631
- const value = urlPartMatch.groups[key];
7632
- const keyAsNumber = parseInt(key, 10);
7633
- if (!isNaN(keyAsNumber)) {
7634
- // Skip group "0" from search params as it captures the entire search string
7635
- if (property === "search" && key === "0") {
7636
- continue;
7637
- }
7638
- if (value) {
7639
- // Only include non-empty values and non-ignored wildcard indices
7640
- const wildcardKey = String(wildcardOffset + keyAsNumber);
7641
- if (!optionalParamKeySet.has(wildcardKey)) {
7642
- params[wildcardKey] = decodeURIComponent(value);
7643
- }
7644
- localWildcardCount++;
7645
- }
7646
- } else if (!optionalParamKeySet.has(key)) {
7647
- // Named group (:param or {param}) - only include if not ignored
7648
- params[key] = decodeURIComponent(value);
7649
- }
7650
- }
7651
- // Update wildcard offset for next URL part
7652
- wildcardOffset += localWildcardCount;
8383
+ for (const otherPattern of allPatterns) {
8384
+ if (currentPattern === otherPattern) continue;
8385
+
8386
+ const otherData = patternRegistry.get(otherPattern);
8387
+
8388
+ // Check if current pattern is a child of other pattern using clean patterns
8389
+ if (isChildPattern(currentData.cleanPattern, otherData.cleanPattern)) {
8390
+ currentData.parentPatterns.push(otherPattern);
8391
+ otherData.childPatterns.push(currentPattern);
7653
8392
  }
7654
8393
  }
7655
- return params;
7656
- };
7657
8394
 
7658
- return {
7659
- urlPattern,
7660
- applyOn,
7661
- };
8395
+ // Store relationships for easy access
8396
+ patternRelationships.set(currentPattern, {
8397
+ pattern: currentData.parsedPattern,
8398
+ parsedPattern: currentData.parsedPattern,
8399
+ connections: currentData.connections,
8400
+ childPatterns: currentData.childPatterns, // Store child patterns
8401
+ parentPatterns: currentData.parentPatterns, // Store parent patterns
8402
+ originalPattern: currentPattern,
8403
+ });
8404
+ }
7662
8405
  };
7663
8406
 
7664
- const URL_PATTERN_PROPERTIES_WITH_GROUP_SET = new Set([
7665
- "protocol",
7666
- "username",
7667
- "password",
7668
- "hostname",
7669
- "pathname",
7670
- "search",
7671
- "hash",
7672
- ]);
7673
-
7674
8407
  /**
7675
- *
7676
- *
8408
+ * Get pattern data for a registered pattern
7677
8409
  */
8410
+ const getPatternData = (urlPatternRaw) => {
8411
+ return patternRegistry.get(urlPatternRaw);
8412
+ };
7678
8413
 
8414
+ /**
8415
+ * Clear all registered patterns
8416
+ */
8417
+ const clearPatterns = () => {
8418
+ patternRegistry.clear();
8419
+ patternRelationships.clear();
8420
+ };
7679
8421
 
7680
- let baseUrl;
7681
- if (typeof window === "undefined") {
7682
- baseUrl = "http://localhost/";
7683
- } else {
7684
- baseUrl = window.location.origin;
7685
- }
8422
+ const resolveRouteUrl = (relativeUrl) => {
8423
+ if (relativeUrl[0] === "/") {
8424
+ // we remove the leading slash because we want to resolve against baseUrl which may
8425
+ // not be the root url
8426
+ relativeUrl = relativeUrl.slice(1);
8427
+ }
7686
8428
 
7687
- const setBaseUrl = (value) => {
7688
- baseUrl = new URL(value, window.location).href;
8429
+ // we don't use URL constructor on PURPOSE (in case the relativeUrl contains invalid url chars)
8430
+ // and we want to support use cases where people WANT to produce invalid urls (for example rawUrlPart with spaces)
8431
+ // because these urls will be handled by non standard clients (like a backend service allowing url like stuff)
8432
+ if (baseUrl.endsWith("/")) {
8433
+ return `${baseUrl}${relativeUrl}`;
8434
+ }
8435
+ return `${baseUrl}/${relativeUrl}`;
7689
8436
  };
8437
+
8438
+ /**
8439
+ * Route management with pattern-first architecture
8440
+ * Routes work with relative URLs, patterns handle base URL resolution
8441
+ */
8442
+
8443
+
7690
8444
  // Controls what happens to actions when their route stops matching:
7691
8445
  // 'abort' - Cancel the action immediately when route stops matching
7692
8446
  // 'keep-loading' - Allow action to continue running after route stops matching
@@ -7695,19 +8449,23 @@ const setBaseUrl = (value) => {
7695
8449
  // However, since route reactivation triggers action reload anyway, the old data won't be used
7696
8450
  // so it's better to abort the action to avoid unnecessary resource usage.
7697
8451
  const ROUTE_DEACTIVATION_STRATEGY = "abort"; // 'abort', 'keep-loading'
8452
+ const ROUTE_NOT_MATCHING_PARAMS = {};
7698
8453
 
7699
8454
  const routeSet = new Set();
7700
8455
  // Store previous route states to detect changes
8456
+ const routePrivatePropertiesMap = new Map();
8457
+
7701
8458
  const routePreviousStateMap = new WeakMap();
7702
8459
  // Store abort controllers per action to control their lifecycle based on route state
7703
8460
  const actionAbortControllerWeakMap = new WeakMap();
8461
+
7704
8462
  const updateRoutes = (
7705
8463
  url,
7706
8464
  {
8465
+ navigationType = "push",
8466
+ isVisited = () => false,
7707
8467
  // state
7708
- replace,
7709
- isVisited,
7710
- },
8468
+ } = {},
7711
8469
  ) => {
7712
8470
  const routeMatchInfoSet = new Set();
7713
8471
  for (const route of routeSet) {
@@ -7717,32 +8475,19 @@ const updateRoutes = (
7717
8475
  // Get previous state
7718
8476
  const previousState = routePreviousStateMap.get(route) || {
7719
8477
  matching: false,
7720
- exactMatching: false,
7721
- params: null,
8478
+ params: ROUTE_NOT_MATCHING_PARAMS,
7722
8479
  };
7723
8480
  const oldMatching = previousState.matching;
7724
- const oldExactMatching = previousState.exactMatching;
7725
8481
  const oldParams = previousState.params;
7726
- const extractedParams = routePattern.applyOn(url);
7727
- const newMatching = Boolean(extractedParams);
7728
-
7729
- // Calculate exact matching - true when matching but no wildcards have content
7730
- let newExactMatching = false;
7731
- if (newMatching && extractedParams) {
7732
- // Check if any wildcard parameters (numeric keys) have meaningful content
7733
- const hasWildcardContent = Object.keys(extractedParams).some((key) => {
7734
- const keyAsNumber = parseInt(key, 10);
7735
- if (!isNaN(keyAsNumber)) {
7736
- // This is a wildcard parameter (numeric key)
7737
- const value = extractedParams[key];
7738
- return value && value.trim() !== "";
7739
- }
7740
- return false;
7741
- });
7742
- newExactMatching = !hasWildcardContent;
7743
- }
8482
+
8483
+ // Use custom pattern matching - much simpler than URLPattern approach
8484
+ let extractedParams = routePattern.applyOn(url);
8485
+ let newMatching = Boolean(extractedParams);
8486
+
7744
8487
  let newParams;
8488
+
7745
8489
  if (extractedParams) {
8490
+ // No need for complex wildcard correction - custom system handles it properly
7746
8491
  if (compareTwoJsValues(oldParams, extractedParams)) {
7747
8492
  // No change in parameters, keep the old params
7748
8493
  newParams = oldParams;
@@ -7750,7 +8495,7 @@ const updateRoutes = (
7750
8495
  newParams = extractedParams;
7751
8496
  }
7752
8497
  } else {
7753
- newParams = null;
8498
+ newParams = ROUTE_NOT_MATCHING_PARAMS;
7754
8499
  }
7755
8500
 
7756
8501
  const routeMatchInfo = {
@@ -7758,8 +8503,6 @@ const updateRoutes = (
7758
8503
  routePrivateProperties,
7759
8504
  oldMatching,
7760
8505
  newMatching,
7761
- oldExactMatching,
7762
- newExactMatching,
7763
8506
  oldParams,
7764
8507
  newParams,
7765
8508
  };
@@ -7767,7 +8510,6 @@ const updateRoutes = (
7767
8510
  // Store current state for next comparison
7768
8511
  routePreviousStateMap.set(route, {
7769
8512
  matching: newMatching,
7770
- exactMatching: newExactMatching,
7771
8513
  params: newParams,
7772
8514
  });
7773
8515
  }
@@ -7779,14 +8521,12 @@ const updateRoutes = (
7779
8521
  route,
7780
8522
  routePrivateProperties,
7781
8523
  newMatching,
7782
- newExactMatching,
7783
8524
  newParams,
7784
8525
  } of routeMatchInfoSet) {
7785
8526
  const { updateStatus } = routePrivateProperties;
7786
8527
  const visited = isVisited(route.url);
7787
8528
  updateStatus({
7788
8529
  matching: newMatching,
7789
- exactMatching: newExactMatching,
7790
8530
  params: newParams,
7791
8531
  visited,
7792
8532
  });
@@ -7807,7 +8547,11 @@ const updateRoutes = (
7807
8547
  const routeAction = route.action;
7808
8548
  const currentAction = routeAction.getCurrentAction();
7809
8549
  if (shouldLoad) {
7810
- if (replace || currentAction.aborted || currentAction.error) {
8550
+ if (
8551
+ navigationType === "replace" ||
8552
+ currentAction.aborted ||
8553
+ currentAction.error
8554
+ ) {
7811
8555
  shouldLoad = false;
7812
8556
  }
7813
8557
  }
@@ -7885,12 +8629,15 @@ const updateRoutes = (
7885
8629
  };
7886
8630
  };
7887
8631
 
7888
- const routePrivatePropertiesMap = new Map();
7889
8632
  const getRoutePrivateProperties = (route) => {
7890
8633
  return routePrivatePropertiesMap.get(route);
7891
8634
  };
7892
8635
 
7893
- const createRoute = (urlPatternInput) => {
8636
+ const registerRoute = (routePattern) => {
8637
+ const urlPatternRaw = routePattern.originalPattern;
8638
+ const patternData = getPatternData(urlPatternRaw);
8639
+ const { cleanPattern, connections } = patternData;
8640
+
7894
8641
  const cleanupCallbackSet = new Set();
7895
8642
  const cleanup = () => {
7896
8643
  for (const cleanupCallback of cleanupCallbackSet) {
@@ -7898,14 +8645,14 @@ const createRoute = (urlPatternInput) => {
7898
8645
  }
7899
8646
  cleanupCallbackSet.clear();
7900
8647
  };
7901
-
7902
8648
  const [publishStatus, subscribeStatus] = createPubSub();
8649
+
7903
8650
  const route = {
7904
- urlPattern: urlPatternInput,
8651
+ urlPattern: cleanPattern,
8652
+ pattern: cleanPattern,
7905
8653
  isRoute: true,
7906
8654
  matching: false,
7907
- exactMatching: false,
7908
- params: null,
8655
+ params: ROUTE_NOT_MATCHING_PARAMS,
7909
8656
  buildUrl: null,
7910
8657
  bindAction: null,
7911
8658
  relativeUrl: null,
@@ -7913,167 +8660,178 @@ const createRoute = (urlPatternInput) => {
7913
8660
  action: null,
7914
8661
  cleanup,
7915
8662
  toString: () => {
7916
- return `route "${urlPatternInput}"`;
8663
+ return `route "${cleanPattern}"`;
7917
8664
  },
7918
8665
  replaceParams: undefined,
7919
8666
  subscribeStatus,
7920
8667
  };
7921
8668
  routeSet.add(route);
7922
-
7923
8669
  const routePrivateProperties = {
7924
- routePattern: null,
8670
+ routePattern,
8671
+ originalPattern: urlPatternRaw,
8672
+ pattern: cleanPattern,
7925
8673
  matchingSignal: null,
7926
- exactMatchingSignal: null,
7927
8674
  paramsSignal: null,
8675
+ rawParamsSignal: null,
7928
8676
  visitedSignal: null,
7929
8677
  relativeUrlSignal: null,
7930
8678
  urlSignal: null,
7931
- updateStatus: ({ matching, exactMatching, params, visited }) => {
8679
+ updateStatus: ({ matching, params, visited }) => {
7932
8680
  let someChange = false;
7933
8681
  matchingSignal.value = matching;
7934
- exactMatchingSignal.value = exactMatching;
7935
- paramsSignal.value = params;
7936
- visitedSignal.value = visited;
8682
+
7937
8683
  if (route.matching !== matching) {
7938
8684
  route.matching = matching;
7939
8685
  someChange = true;
7940
8686
  }
7941
- if (route.exactMatching !== exactMatching) {
7942
- route.exactMatching = exactMatching;
7943
- someChange = true;
7944
- }
7945
- if (route.params !== params) {
7946
- route.params = params;
7947
- someChange = true;
7948
- }
8687
+ visitedSignal.value = visited;
7949
8688
  if (route.visited !== visited) {
7950
8689
  route.visited = visited;
7951
8690
  someChange = true;
7952
8691
  }
8692
+ // Store raw params (from URL) - paramsSignal will reactively compute merged params
8693
+ rawParamsSignal.value = params;
8694
+ // Get merged params for comparison (computed signal will handle the merging)
8695
+ const mergedParams = paramsSignal.value;
8696
+ if (route.params !== mergedParams) {
8697
+ route.params = mergedParams;
8698
+ someChange = true;
8699
+ }
7953
8700
  if (someChange) {
7954
- publishStatus({ matching, exactMatching, params, visited });
8701
+ publishStatus({
8702
+ matching,
8703
+ params: mergedParams,
8704
+ visited,
8705
+ });
7955
8706
  }
7956
8707
  },
7957
8708
  };
7958
8709
  routePrivatePropertiesMap.set(route, routePrivateProperties);
7959
8710
 
7960
- const buildRelativeUrl = (params, options) =>
7961
- buildRouteRelativeUrl(urlPatternInput, params, options);
7962
- route.buildRelativeUrl = (params, options) => {
7963
- const { relativeUrl } = buildRelativeUrl(params, options);
7964
- return relativeUrl;
7965
- };
8711
+ const matchingSignal = signal(false);
8712
+ const rawParamsSignal = signal(ROUTE_NOT_MATCHING_PARAMS);
8713
+ const paramsSignal = computed(() => {
8714
+ const rawParams = rawParamsSignal.value;
8715
+ // Pattern system handles parameter defaults, routes just work with raw params
8716
+ return rawParams || {};
8717
+ });
8718
+ const visitedSignal = signal(false);
8719
+ for (const { signal: stateSignal, paramName, options = {} } of connections) {
8720
+ const { debug } = options;
7966
8721
 
7967
- route.matchesParams = (otherParams) => {
7968
- let params = route.params;
7969
- if (params) {
7970
- const paramsWithoutWildcards = {};
7971
- for (const key of Object.keys(params)) {
7972
- if (!Number.isInteger(Number(key))) {
7973
- paramsWithoutWildcards[key] = params[key];
7974
- }
8722
+ if (debug) {
8723
+ console.debug(
8724
+ `[route] connecting param "${paramName}" to signal`,
8725
+ stateSignal,
8726
+ );
8727
+ }
8728
+
8729
+ // URL -> Signal synchronization
8730
+ effect(() => {
8731
+ const matching = matchingSignal.value;
8732
+ const params = rawParamsSignal.value;
8733
+ const urlParamValue = params[paramName];
8734
+
8735
+ if (!matching) {
8736
+ return;
8737
+ }
8738
+ if (debug) {
8739
+ console.debug(
8740
+ `[stateSignal] URL -> Signal: ${paramName}=${urlParamValue}`,
8741
+ );
8742
+ }
8743
+ stateSignal.value = urlParamValue;
8744
+ });
8745
+
8746
+ // Signal -> URL synchronization
8747
+ effect(() => {
8748
+ const value = stateSignal.value;
8749
+ const params = rawParamsSignal.value;
8750
+ const urlParamValue = params[paramName];
8751
+ const matching = matchingSignal.value;
8752
+
8753
+ if (!matching || value === urlParamValue) {
8754
+ return;
8755
+ }
8756
+
8757
+ if (debug) {
8758
+ console.debug(`[stateSignal] Signal -> URL: ${paramName}=${value}`);
7975
8759
  }
7976
- params = paramsWithoutWildcards;
8760
+
8761
+ route.replaceParams({ [paramName]: value });
8762
+ });
8763
+ }
8764
+
8765
+ route.navTo = (params) => {
8766
+ if (!browserIntegration$1) {
8767
+ return Promise.resolve();
7977
8768
  }
7978
- const paramsIsFalsyOrEmpty = !params || Object.keys(params).length === 0;
7979
- const otherParamsFalsyOrEmpty =
7980
- !otherParams || Object.keys(otherParams).length === 0;
7981
- if (paramsIsFalsyOrEmpty) {
7982
- return otherParamsFalsyOrEmpty;
8769
+ return browserIntegration$1.navTo(route.buildUrl(params));
8770
+ };
8771
+ route.redirectTo = (params) => {
8772
+ if (!browserIntegration$1) {
8773
+ return Promise.resolve();
7983
8774
  }
7984
- if (otherParamsFalsyOrEmpty) {
7985
- return false;
8775
+ return browserIntegration$1.navTo(route.buildUrl(params), {
8776
+ replace: true,
8777
+ });
8778
+ };
8779
+ route.replaceParams = (newParams) => {
8780
+ const matching = matchingSignal.peek();
8781
+ if (!matching) {
8782
+ console.warn(
8783
+ `Cannot replace params on route ${route} because it is not matching the current URL.`,
8784
+ );
8785
+ return null;
7986
8786
  }
7987
- return compareTwoJsValues(params, otherParams);
8787
+ if (route.action) {
8788
+ // For action: merge with resolved params (includes defaults) so action gets complete params
8789
+ const currentResolvedParams = routePattern.resolveParams();
8790
+ const updatedActionParams = { ...currentResolvedParams, ...newParams };
8791
+ route.action.replaceParams(updatedActionParams);
8792
+ }
8793
+ return route.redirectTo(newParams);
7988
8794
  };
7989
-
7990
- /**
7991
- * Builds a complete URL for this route with the given parameters.
7992
- *
7993
- * Takes parameters and substitutes them into the route's URL pattern,
7994
- * automatically URL-encoding values unless wrapped with rawUrlPart().
7995
- * Extra parameters not in the pattern are added as search parameters.
7996
- *
7997
- * @param {Object} params - Parameters to substitute into the URL pattern
7998
- * @returns {string} Complete URL with base URL and encoded parameters
7999
- *
8000
- * @example
8001
- * // For a route with pattern "/items/:id"
8002
- * // Normal parameter encoding
8003
- * route.buildUrl({ id: "hello world" }) // → "https://example.com/items/hello%20world"
8004
- * // Raw parameter (no encoding)
8005
- * route.buildUrl({ id: rawUrlPart("hello world") }) // → "https://example.com/items/hello world"
8006
- *
8007
- */
8008
- const buildUrl = (params = {}) => {
8009
- const { relativeUrl, hasRawUrlPartWithInvalidChars } =
8010
- buildRelativeUrl(params);
8011
- let processedRelativeUrl = relativeUrl;
8012
- if (processedRelativeUrl[0] === "/") {
8013
- // we remove the leading slash because we want to resolve against baseUrl which may
8014
- // not be the root url
8015
- processedRelativeUrl = processedRelativeUrl.slice(1);
8016
- }
8017
- if (hasRawUrlPartWithInvalidChars) {
8018
- if (!baseUrl.endsWith("/")) {
8019
- return `${baseUrl}/${processedRelativeUrl}`;
8020
- }
8021
- return `${baseUrl}${processedRelativeUrl}`;
8022
- }
8023
- const url = new URL(processedRelativeUrl, baseUrl).href;
8024
- return url;
8795
+ route.buildRelativeUrl = (params) => {
8796
+ // buildMostPreciseUrl now handles parameter resolution internally
8797
+ return routePattern.buildMostPreciseUrl(params);
8798
+ };
8799
+ route.buildUrl = (params) => {
8800
+ const routeRelativeUrl = route.buildRelativeUrl(params);
8801
+ const routeUrl = resolveRouteUrl(routeRelativeUrl);
8802
+ return routeUrl;
8803
+ };
8804
+ route.matchesParams = (providedParams) => {
8805
+ const currentParams = route.params;
8806
+ const resolvedParams = routePattern.resolveParams({
8807
+ ...currentParams,
8808
+ ...providedParams,
8809
+ });
8810
+ const same = compareTwoJsValues(currentParams, resolvedParams);
8811
+ return same;
8025
8812
  };
8026
- route.buildUrl = buildUrl;
8027
8813
 
8028
- const matchingSignal = signal(false);
8029
- const exactMatchingSignal = signal(false);
8030
- const paramsSignal = signal(null);
8031
- const visitedSignal = signal(false);
8814
+ // relativeUrl/url
8032
8815
  const relativeUrlSignal = computed(() => {
8033
- const params = paramsSignal.value;
8034
- const { relativeUrl } = buildRelativeUrl(params);
8816
+ const rawParams = rawParamsSignal.value;
8817
+ const relativeUrl = route.buildRelativeUrl(rawParams);
8035
8818
  return relativeUrl;
8036
8819
  });
8820
+ const urlSignal = computed(() => {
8821
+ const routeUrl = route.buildUrl();
8822
+ return routeUrl;
8823
+ });
8037
8824
  const disposeRelativeUrlEffect = effect(() => {
8038
8825
  route.relativeUrl = relativeUrlSignal.value;
8039
8826
  });
8040
- cleanupCallbackSet.add(disposeRelativeUrlEffect);
8041
-
8042
- const urlSignal = computed(() => {
8043
- const relativeUrl = relativeUrlSignal.value;
8044
- const url = new URL(relativeUrl, baseUrl).href;
8045
- return url;
8046
- });
8047
8827
  const disposeUrlEffect = effect(() => {
8048
8828
  route.url = urlSignal.value;
8049
8829
  });
8830
+ cleanupCallbackSet.add(disposeRelativeUrlEffect);
8050
8831
  cleanupCallbackSet.add(disposeUrlEffect);
8051
8832
 
8052
- const replaceParams = (newParams) => {
8053
- const currentParams = paramsSignal.peek();
8054
- const updatedParams = { ...currentParams, ...newParams };
8055
- const updatedUrl = route.buildUrl(updatedParams);
8056
- if (route.action) {
8057
- route.action.replaceParams(updatedParams);
8058
- }
8059
- browserIntegration$1.navTo(updatedUrl, { replace: true });
8060
- };
8061
- route.replaceParams = replaceParams;
8062
-
8063
- const bindAction = (action) => {
8064
- /*
8065
- *
8066
- * here I need to check the store for that action (if any)
8067
- * and listen store changes to do this:
8068
- *
8069
- * When we detect changes we want to update the route params
8070
- * so we'll need to use navTo(buildUrl(params), { replace: true })
8071
- *
8072
- * reinserted is useful because the item id might have changed
8073
- * but not the mutable key
8074
- *
8075
- */
8076
-
8833
+ // action stuff (for later)
8834
+ route.bindAction = (action) => {
8077
8835
  const { store } = action.meta;
8078
8836
  if (store) {
8079
8837
  const { mutableIdKeys } = store;
@@ -8100,75 +8858,40 @@ const createRoute = (urlPatternInput) => {
8100
8858
  }
8101
8859
  }
8102
8860
 
8103
- /*
8104
- store.registerPropertyLifecycle(activeItemSignal, key, {
8105
- changed: (value) => {
8106
- route.replaceParams({
8107
- [key]: value,
8108
- });
8109
- },
8110
- dropped: () => {
8111
- route.reload();
8112
- },
8113
- reinserted: () => {
8114
- // this will reload all routes which works but
8115
- // - most of the time only "route" is impacted, any other route could stay as is
8116
- // - we already have the data, reloading the route will refetch the backend which is unnecessary
8117
- // we could just remove routing error (which is cause by 404 likely)
8118
- // to actually let the data be displayed
8119
- // because they are available, but in reality the route has no data
8120
- // because the fetch failed
8121
- // so conceptually reloading is fine,
8122
- // the only thing that bothers me a little is that it reloads all routes
8123
- route.reload();
8124
- },
8125
- });
8126
- */
8127
-
8128
8861
  const actionBoundToThisRoute = action.bindParams(paramsSignal);
8129
8862
  route.action = actionBoundToThisRoute;
8130
8863
  return actionBoundToThisRoute;
8131
8864
  };
8132
- route.bindAction = bindAction;
8133
8865
 
8134
- {
8135
- routePrivateProperties.matchingSignal = matchingSignal;
8136
- routePrivateProperties.exactMatchingSignal = exactMatchingSignal;
8137
- routePrivateProperties.paramsSignal = paramsSignal;
8138
- routePrivateProperties.visitedSignal = visitedSignal;
8139
- routePrivateProperties.relativeUrlSignal = relativeUrlSignal;
8140
- routePrivateProperties.urlSignal = urlSignal;
8141
- routePrivateProperties.cleanupCallbackSet = cleanupCallbackSet;
8142
- const routePattern = createRoutePattern(urlPatternInput, baseUrl);
8143
- routePrivateProperties.routePattern = routePattern;
8144
- }
8866
+ // Store private properties for internal access
8867
+ routePrivateProperties.matchingSignal = matchingSignal;
8868
+ routePrivateProperties.paramsSignal = paramsSignal;
8869
+ routePrivateProperties.rawParamsSignal = rawParamsSignal;
8870
+ routePrivateProperties.visitedSignal = visitedSignal;
8871
+ routePrivateProperties.relativeUrlSignal = relativeUrlSignal;
8872
+ routePrivateProperties.urlSignal = urlSignal;
8873
+ routePrivateProperties.cleanupCallbackSet = cleanupCallbackSet;
8145
8874
 
8146
8875
  return route;
8147
8876
  };
8877
+
8148
8878
  const useRouteStatus = (route) => {
8149
8879
  const routePrivateProperties = getRoutePrivateProperties(route);
8150
8880
  if (!routePrivateProperties) {
8151
8881
  throw new Error(`Cannot find route private properties for ${route}`);
8152
8882
  }
8153
8883
 
8154
- const {
8155
- urlSignal,
8156
- matchingSignal,
8157
- exactMatchingSignal,
8158
- paramsSignal,
8159
- visitedSignal,
8160
- } = routePrivateProperties;
8884
+ const { urlSignal, matchingSignal, paramsSignal, visitedSignal } =
8885
+ routePrivateProperties;
8161
8886
 
8162
8887
  const url = urlSignal.value;
8163
8888
  const matching = matchingSignal.value;
8164
- const exactMatching = exactMatchingSignal.value;
8165
8889
  const params = paramsSignal.value;
8166
8890
  const visited = visitedSignal.value;
8167
8891
 
8168
8892
  return {
8169
8893
  url,
8170
8894
  matching,
8171
- exactMatching,
8172
8895
  params,
8173
8896
  visited,
8174
8897
  };
@@ -8178,7 +8901,6 @@ let browserIntegration$1;
8178
8901
  const setBrowserIntegration = (integration) => {
8179
8902
  browserIntegration$1 = integration;
8180
8903
  };
8181
-
8182
8904
  let onRouteDefined = () => {};
8183
8905
  const setOnRouteDefined = (v) => {
8184
8906
  onRouteDefined = v;
@@ -8199,23 +8921,47 @@ const setOnRouteDefined = (v) => {
8199
8921
  // at any given time (url can be shared, reloaded, etc..)
8200
8922
  // Later I'll consider adding ability to have dynamic import into the mix
8201
8923
  // (An async function returning an action)
8924
+
8202
8925
  const setupRoutes = (routeDefinition) => {
8203
- // Clean up existing routes
8204
- for (const route of routeSet) {
8205
- route.cleanup();
8926
+ // Prevent calling setupRoutes when routes already exist - enforce clean setup
8927
+ if (routeSet.size > 0) {
8928
+ throw new Error(
8929
+ "Routes already exist. Call clearAllRoutes() first to clean up existing routes before creating new ones. This prevents cross-test pollution and ensures clean state.",
8930
+ );
8206
8931
  }
8207
- routeSet.clear();
8208
-
8932
+ // PHASE 1: Register all patterns and build their relationships
8933
+ setupPatterns(routeDefinition);
8934
+ // PHASE 2: Create route patterns with signal connections and parameter defaults
8935
+ const routePatterns = {};
8936
+ for (const key of Object.keys(routeDefinition)) {
8937
+ const urlPatternRaw = routeDefinition[key];
8938
+ routePatterns[key] = createRoutePattern(urlPatternRaw);
8939
+ }
8940
+ // PHASE 3: Create routes using pre-created patterns
8209
8941
  const routes = {};
8210
8942
  for (const key of Object.keys(routeDefinition)) {
8211
- const value = routeDefinition[key];
8212
- const route = createRoute(value);
8943
+ const routePattern = routePatterns[key];
8944
+ const route = registerRoute(routePattern);
8213
8945
  routes[key] = route;
8214
8946
  }
8215
8947
  onRouteDefined();
8948
+
8216
8949
  return routes;
8217
8950
  };
8218
8951
 
8952
+ // for unit tests
8953
+ const clearAllRoutes = () => {
8954
+ for (const route of routeSet) {
8955
+ route.cleanup();
8956
+ }
8957
+ routeSet.clear();
8958
+ routePrivatePropertiesMap.clear();
8959
+ // Clear patterns as well
8960
+ clearPatterns();
8961
+ // Don't clear signal registry here - let tests manage it explicitly
8962
+ // This prevents clearing signals that are still being used across multiple route registrations
8963
+ };
8964
+
8219
8965
  const arraySignal = (initialValue = []) => {
8220
8966
  const theSignal = signal(initialValue);
8221
8967
 
@@ -8340,7 +9086,9 @@ computed(() => {
8340
9086
  return reasonArray;
8341
9087
  });
8342
9088
 
8343
- const documentUrlSignal = signal(window.location.href);
9089
+ const documentUrlSignal = signal(
9090
+ typeof window === "undefined" ? "http://localhost" : window.location.href,
9091
+ );
8344
9092
  const useDocumentUrl = () => {
8345
9093
  return documentUrlSignal.value;
8346
9094
  };
@@ -8477,11 +9225,10 @@ const setupBrowserIntegrationViaHistory = ({
8477
9225
  { reason = "replaceDocumentState called" } = {},
8478
9226
  ) => {
8479
9227
  const url = window.location.href;
8480
- window.history.replaceState(newState, null, url);
8481
9228
  handleRoutingTask(url, {
8482
- replace: true,
8483
- state: newState,
8484
9229
  reason,
9230
+ navigationType: "replace",
9231
+ state: newState,
8485
9232
  });
8486
9233
  };
8487
9234
 
@@ -8504,42 +9251,52 @@ const setupBrowserIntegrationViaHistory = ({
8504
9251
  return;
8505
9252
  }
8506
9253
  visitedUrlSet.add(url);
8507
-
8508
- // Increment signal to notify subscribers that visited URLs changed
8509
- visitedUrlsSignal.value++;
9254
+ visitedUrlsSignal.value++; // Increment signal to notify subscribers that visited URLs changed
8510
9255
 
8511
9256
  const historyState = getDocumentState() || {};
8512
- const hsitoryStateWithVisitedUrls = {
9257
+ const historyStateWithVisitedUrls = {
8513
9258
  ...historyState,
8514
9259
  jsenv_visited_urls: Array.from(visitedUrlSet),
8515
9260
  };
8516
9261
  window.history.replaceState(
8517
- hsitoryStateWithVisitedUrls,
9262
+ historyStateWithVisitedUrls,
8518
9263
  null,
8519
9264
  window.location.href,
8520
9265
  );
8521
- updateDocumentState(hsitoryStateWithVisitedUrls);
9266
+ updateDocumentState(historyStateWithVisitedUrls);
8522
9267
  };
8523
9268
 
8524
9269
  let abortController = null;
8525
- const handleRoutingTask = (url, { state, replace, reason }) => {
8526
- markUrlAsVisited(url);
9270
+ const handleRoutingTask = (
9271
+ url,
9272
+ {
9273
+ reason,
9274
+ navigationType, // "push", "reload", "replace", "traverse"
9275
+ state,
9276
+ },
9277
+ ) => {
9278
+ if (navigationType === "push") {
9279
+ window.history.pushState(state, null, url);
9280
+ } else if (navigationType === "replace") {
9281
+ window.history.replaceState(state, null, url);
9282
+ }
9283
+
8527
9284
  updateDocumentUrl(url);
8528
9285
  updateDocumentState(state);
9286
+ markUrlAsVisited(url);
8529
9287
  if (abortController) {
8530
9288
  abortController.abort(`navigating to ${url}`);
8531
9289
  }
8532
9290
  abortController = new AbortController();
8533
-
9291
+ const abortSignal = abortController.signal;
8534
9292
  const { allResult, requestedResult } = applyRouting(url, {
8535
9293
  globalAbortSignal: globalAbortController.signal,
8536
- abortSignal: abortController.signal,
8537
- state,
8538
- replace,
8539
- isVisited,
9294
+ abortSignal,
8540
9295
  reason,
9296
+ navigationType,
9297
+ isVisited,
9298
+ state,
8541
9299
  });
8542
-
8543
9300
  executeWithCleanup(
8544
9301
  () => allResult,
8545
9302
  () => {
@@ -8583,11 +9340,10 @@ const setupBrowserIntegrationViaHistory = ({
8583
9340
  return;
8584
9341
  }
8585
9342
  e.preventDefault();
8586
- const state = null;
8587
- history.pushState(state, null, href);
8588
9343
  handleRoutingTask(href, {
8589
- state,
8590
9344
  reason: `"click" on a[href="${href}"]`,
9345
+ navigationType: "push",
9346
+ state: null,
8591
9347
  });
8592
9348
  },
8593
9349
  { capture: true },
@@ -8596,7 +9352,8 @@ const setupBrowserIntegrationViaHistory = ({
8596
9352
  window.addEventListener(
8597
9353
  "submit",
8598
9354
  () => {
8599
- // TODO: Handle form submissions
9355
+ // Handle form submissions?
9356
+ // Not needed yet
8600
9357
  },
8601
9358
  { capture: true },
8602
9359
  );
@@ -8605,21 +9362,17 @@ const setupBrowserIntegrationViaHistory = ({
8605
9362
  const url = window.location.href;
8606
9363
  const state = popstateEvent.state;
8607
9364
  handleRoutingTask(url, {
8608
- state,
8609
9365
  reason: `"popstate" event for ${url}`,
9366
+ navigationType: "traverse",
9367
+ state,
8610
9368
  });
8611
9369
  });
8612
9370
 
8613
- const navTo = async (url, { state = null, replace } = {}) => {
8614
- if (replace) {
8615
- window.history.replaceState(state, null, url);
8616
- } else {
8617
- window.history.pushState(state, null, url);
8618
- }
9371
+ const navTo = async (url, { replace, state = null } = {}) => {
8619
9372
  handleRoutingTask(url, {
8620
- state,
8621
- replace,
8622
9373
  reason: `navTo called with "${url}"`,
9374
+ navigationType: replace ? "replace" : "push",
9375
+ state,
8623
9376
  });
8624
9377
  };
8625
9378
 
@@ -8631,6 +9384,8 @@ const setupBrowserIntegrationViaHistory = ({
8631
9384
  const url = window.location.href;
8632
9385
  const state = history.state;
8633
9386
  handleRoutingTask(url, {
9387
+ reason: "reload called",
9388
+ navigationType: "reload",
8634
9389
  state,
8635
9390
  });
8636
9391
  };
@@ -8646,11 +9401,10 @@ const setupBrowserIntegrationViaHistory = ({
8646
9401
  const init = () => {
8647
9402
  const url = window.location.href;
8648
9403
  const state = history.state;
8649
- history.replaceState(state, null, url);
8650
9404
  handleRoutingTask(url, {
8651
- state,
8652
- replace: true,
8653
9405
  reason: "routing initialization",
9406
+ navigationType: "replace",
9407
+ state,
8654
9408
  });
8655
9409
  };
8656
9410
 
@@ -8686,7 +9440,7 @@ const applyRouting = (
8686
9440
  globalAbortSignal,
8687
9441
  abortSignal,
8688
9442
  // state
8689
- replace,
9443
+ navigationType,
8690
9444
  isVisited,
8691
9445
  reason,
8692
9446
  },
@@ -8698,9 +9452,9 @@ const applyRouting = (
8698
9452
  routeLoadRequestedMap,
8699
9453
  activeRouteSet,
8700
9454
  } = updateRoutes(url, {
8701
- replace,
8702
- // state,
9455
+ navigationType,
8703
9456
  isVisited,
9457
+ // state,
8704
9458
  });
8705
9459
  if (loadSet.size === 0 && reloadSet.size === 0) {
8706
9460
  return {
@@ -8937,7 +9691,8 @@ const Route = ({
8937
9691
  index,
8938
9692
  fallback,
8939
9693
  meta,
8940
- children
9694
+ children,
9695
+ routeParams
8941
9696
  }) => {
8942
9697
  const forceRender = useForceRender();
8943
9698
  const hasDiscoveredRef = useRef(false);
@@ -8949,6 +9704,7 @@ const Route = ({
8949
9704
  index: index,
8950
9705
  fallback: fallback,
8951
9706
  meta: meta,
9707
+ routeParams: routeParams,
8952
9708
  onMatchingInfoChange: matchingInfo => {
8953
9709
  hasDiscoveredRef.current = true;
8954
9710
  matchingInfoRef.current = matchingInfo;
@@ -8977,6 +9733,7 @@ const MatchingRouteManager = ({
8977
9733
  index,
8978
9734
  fallback,
8979
9735
  meta,
9736
+ routeParams,
8980
9737
  onMatchingInfoChange,
8981
9738
  children
8982
9739
  }) => {
@@ -9018,6 +9775,7 @@ const MatchingRouteManager = ({
9018
9775
  index,
9019
9776
  fallback,
9020
9777
  meta,
9778
+ routeParams,
9021
9779
  indexCandidate,
9022
9780
  fallbackCandidate,
9023
9781
  candidateSet,
@@ -9036,6 +9794,7 @@ const initRouteObserver = ({
9036
9794
  index,
9037
9795
  fallback,
9038
9796
  meta,
9797
+ routeParams,
9039
9798
  indexCandidate,
9040
9799
  fallbackCandidate,
9041
9800
  candidateSet,
@@ -9092,6 +9851,12 @@ const initRouteObserver = ({
9092
9851
  // we have a route and it does not match no need to go further
9093
9852
  return null;
9094
9853
  }
9854
+
9855
+ // Check if routeParams match current route parameters
9856
+ if (routeParams && !route.matchesParams(routeParams)) {
9857
+ return null; // routeParams don't match, don't render
9858
+ }
9859
+
9095
9860
  // we have a route and it is matching
9096
9861
  // we search the first matching child to put it in the slot
9097
9862
  const matchingChildInfo = findMatchingChildInfo();
@@ -17322,26 +18087,23 @@ const RouteLink = ({
17322
18087
  if (!route) {
17323
18088
  throw new Error("route prop is required");
17324
18089
  }
17325
- const routeStatus = useRouteStatus(route);
18090
+ useRouteStatus(route);
17326
18091
  const url = route.buildUrl(routeParams);
17327
- let isCurrent;
17328
- if (routeStatus.exactMatching) {
17329
- isCurrent = true;
17330
- } else if (routeStatus.matching) {
17331
- isCurrent = routeParams ? route.matchesParams(routeParams) : false;
17332
- } else {
17333
- isCurrent = false;
17334
- }
17335
18092
  return jsx(Link, {
17336
18093
  ...rest,
17337
18094
  href: url,
17338
- pseudoState: {
17339
- ":-navi-href-current": isCurrent
17340
- },
17341
18095
  children: children || route.buildRelativeUrl(routeParams)
17342
18096
  });
17343
18097
  };
17344
18098
 
18099
+ const rawUrlPartSymbol = Symbol("raw_url_part");
18100
+ const rawUrlPart = (value) => {
18101
+ return {
18102
+ [rawUrlPartSymbol]: true,
18103
+ value,
18104
+ };
18105
+ };
18106
+
17345
18107
  installImportMetaCss(import.meta);Object.assign(PSEUDO_CLASSES, {
17346
18108
  ":-navi-tab-selected": {
17347
18109
  attribute: "data-tab-selected"
@@ -17649,6 +18411,10 @@ const TabRoute = ({
17649
18411
  padding = 2,
17650
18412
  paddingX,
17651
18413
  paddingY,
18414
+ paddingLeft,
18415
+ paddingRight,
18416
+ paddingTop,
18417
+ paddingBottom,
17652
18418
  alignX,
17653
18419
  alignY,
17654
18420
  ...props
@@ -17675,6 +18441,10 @@ const TabRoute = ({
17675
18441
  padding: padding,
17676
18442
  paddingX: paddingX,
17677
18443
  paddingY: paddingY,
18444
+ paddingLeft: paddingLeft,
18445
+ paddingRight: paddingRight,
18446
+ paddingTop: paddingTop,
18447
+ paddingBottom: paddingBottom,
17678
18448
  alignX: alignX,
17679
18449
  alignY: alignY,
17680
18450
  children: children
@@ -25548,5 +26318,5 @@ const UserSvg = () => jsx("svg", {
25548
26318
  })
25549
26319
  });
25550
26320
 
25551
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, BadgeCount, Box, Button, ButtonCopyToClipboard, Caption, CheckSvg, Checkbox, CheckboxList, Code, Col, Colgroup, ConstructionSvg, Details, DialogLayout, Editable, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Form, HeartSvg, HomeSvg, Icon, Image, Input, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, MessageBox, Paragraph, Radio, RadioList, Route, RouteLink, Routes, RowNumberCol, RowNumberTableCell, SINGLE_SPACE_CONSTRAINT, SVGMaskOverlay, SearchSvg, Select, SelectionContext, Separator, SettingsSvg, StarSvg, SummaryMarker, Svg, Tab, TabList, Table, TableCell, Tbody, Text, Thead, Title, Tr, UITransition, UserSvg, ViewportLayout, actionIntegratedVia, addCustomMessage, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSelectionKeyboardShortcuts, enableDebugActions, enableDebugOnDocumentLoading, forwardActionRequested, installCustomConstraintValidation, isCellSelected, isColumnSelected, isRowSelected, localStorageSignal, navBack, navForward, navTo, openCallout, rawUrlPart, reload, removeCustomMessage, requestAction, rerunActions, resource, setBaseUrl, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, updateActions, useActionData, useActionStatus, useCalloutClose, useCellsAndColumns, useConstraintValidityState, useDependenciesDiff, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useMatchingRouteInfo, useNavState$1 as useNavState, useRouteStatus, useRunOnMount, useSelectableElement, useSelectionController, useSignalSync, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage };
26321
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, BadgeCount, Box, Button, ButtonCopyToClipboard, Caption, CheckSvg, Checkbox, CheckboxList, Code, Col, Colgroup, ConstructionSvg, Details, DialogLayout, Editable, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Form, HeartSvg, HomeSvg, Icon, Image, Input, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, MessageBox, Paragraph, Radio, RadioList, Route, RouteLink, Routes, RowNumberCol, RowNumberTableCell, SINGLE_SPACE_CONSTRAINT, SVGMaskOverlay, SearchSvg, Select, SelectionContext, Separator, SettingsSvg, StarSvg, SummaryMarker, Svg, Tab, TabList, Table, TableCell, Tbody, Text, Thead, Title, Tr, UITransition, UserSvg, ViewportLayout, actionIntegratedVia, addCustomMessage, clearAllRoutes, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSelectionKeyboardShortcuts, enableDebugActions, enableDebugOnDocumentLoading, forwardActionRequested, installCustomConstraintValidation, isCellSelected, isColumnSelected, isRowSelected, localStorageSignal, navBack, navForward, navTo, openCallout, rawUrlPart, reload, removeCustomMessage, requestAction, rerunActions, resource, setBaseUrl, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, updateActions, useActionData, useActionStatus, useCalloutClose, useCellsAndColumns, useConstraintValidityState, useDependenciesDiff, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useMatchingRouteInfo, useNavState$1 as useNavState, useRouteStatus, useRunOnMount, useSelectableElement, useSelectionController, useSignalSync, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage };
25552
26322
  //# sourceMappingURL=jsenv_navi.js.map