@jsenv/navi 0.15.9 → 0.16.0

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,857 @@ 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
7525
+
7526
+ // Function to detect signals in route patterns and connect them
7527
+ const detectSignals = (routePattern) => {
7528
+ const signalConnections = [];
7529
+ let updatedPattern = routePattern;
7323
7530
 
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;
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;
7328
7535
 
7329
- // Find the first occurrence of an unused optional part (still has ?)
7330
- const optionalPartMatch = result.match(/(\/?\*|\/:[^/?]*|\{[^}]*\})\?/);
7536
+ while ((match = signalParamRegex.exec(routePattern)) !== null) {
7537
+ const [fullMatch, prefix, paramName, signalString] = match;
7331
7538
 
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);
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;
7336
7553
 
7337
- // Clean up trailing slashes
7338
- result = result.replace(/\/$/, "");
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;
7424
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;
7425
7734
 
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);
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;
7456
7791
  }
7457
7792
  }
7458
7793
  }
7794
+ }
7459
7795
 
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);
7796
+ if (!parsedPattern.segments) {
7797
+ return "/";
7798
+ }
7799
+
7800
+ // Filter out segments for parameters that are not provided (omitted defaults)
7801
+ const filteredPattern = {
7802
+ ...parsedPattern,
7803
+ segments: parsedPattern.segments.filter((segment) => {
7804
+ if (segment.type === "param") {
7805
+ // Only keep parameter segments if we have a value for them
7806
+ return segment.name in finalParams;
7807
+ }
7808
+ // Always keep literal segments
7809
+ return true;
7810
+ }),
7811
+ };
7812
+
7813
+ // Remove trailing slash if we filtered out segments
7814
+ if (
7815
+ filteredPattern.segments.length < parsedPattern.segments.length &&
7816
+ parsedPattern.trailingSlash
7817
+ ) {
7818
+ filteredPattern.trailingSlash = false;
7819
+ }
7820
+
7821
+ return buildUrlFromPattern(filteredPattern, finalParams);
7822
+ };
7823
+
7824
+ return {
7825
+ originalPattern: pattern, // Return the original pattern string
7826
+ pattern: parsedPattern,
7827
+ cleanPattern, // Return the clean pattern string
7828
+ connections, // Return signal connections along with pattern
7829
+ applyOn,
7830
+ buildUrl,
7831
+ buildMostPreciseUrl,
7832
+ resolveParams,
7833
+ };
7834
+ };
7835
+
7836
+ /**
7837
+ * Parse a route pattern string into structured segments
7838
+ */
7839
+ const parsePattern = (pattern, parameterDefaults = new Map()) => {
7840
+ // Handle root route
7841
+ if (pattern === "/") {
7842
+ return {
7843
+ original: pattern,
7844
+ segments: [],
7845
+ trailingSlash: true,
7846
+ wildcard: false,
7847
+ queryParams: [],
7848
+ };
7849
+ }
7850
+
7851
+ // Separate path and query portions
7852
+ const [pathPortion, queryPortion] = pattern.split("?");
7853
+
7854
+ // Parse query parameters if present
7855
+ const queryParams = [];
7856
+ if (queryPortion) {
7857
+ // Split query parameters by & and parse each one
7858
+ const querySegments = queryPortion.split("&");
7859
+ for (const querySegment of querySegments) {
7860
+ if (querySegment.includes("=")) {
7861
+ // Parameter with potential value: tab=value or just tab
7862
+ const [paramName, paramValue] = querySegment.split("=", 2);
7863
+ queryParams.push({
7864
+ type: "query_param",
7865
+ name: paramName,
7866
+ hasDefaultValue: paramValue === undefined, // No value means it uses signal/default
7867
+ });
7868
+ } else {
7869
+ // Parameter without value: tab
7870
+ queryParams.push({
7871
+ type: "query_param",
7872
+ name: querySegment,
7873
+ hasDefaultValue: true,
7874
+ });
7875
+ }
7876
+ }
7877
+ }
7878
+
7879
+ // Remove leading slash for processing the path portion
7880
+ let cleanPattern = pathPortion.startsWith("/")
7881
+ ? pathPortion.slice(1)
7882
+ : pathPortion;
7883
+
7884
+ // Check for wildcard first
7885
+ const wildcard = cleanPattern.endsWith("*");
7886
+ if (wildcard) {
7887
+ cleanPattern = cleanPattern.slice(0, -1); // Remove *
7888
+ // Also remove the slash before * if present
7889
+ if (cleanPattern.endsWith("/")) {
7890
+ cleanPattern = cleanPattern.slice(0, -1);
7891
+ }
7892
+ }
7893
+
7894
+ // Check for trailing slash (after wildcard check)
7895
+ const trailingSlash = !wildcard && pathPortion.endsWith("/");
7896
+ if (trailingSlash) {
7897
+ cleanPattern = cleanPattern.slice(0, -1); // Remove trailing /
7898
+ }
7899
+
7900
+ // Split into segments (filter out empty segments)
7901
+ const segmentStrings = cleanPattern
7902
+ ? cleanPattern.split("/").filter((s) => s !== "")
7903
+ : [];
7904
+ const segments = segmentStrings.map((seg, index) => {
7905
+ if (seg.startsWith(":")) {
7906
+ // Parameter segment
7907
+ const paramName = seg.slice(1).replace("?", ""); // Remove : and optional ?
7908
+ const isOptional = seg.endsWith("?") || parameterDefaults.has(paramName);
7909
+
7910
+ return {
7911
+ type: "param",
7912
+ name: paramName,
7913
+ optional: isOptional,
7914
+ index,
7915
+ };
7916
+ }
7917
+ // Literal segment
7918
+ return {
7919
+ type: "literal",
7920
+ value: seg,
7921
+ index,
7922
+ };
7923
+ });
7924
+
7925
+ return {
7926
+ original: pattern,
7927
+ segments,
7928
+ queryParams, // Add query parameters to the parsed pattern
7929
+ trailingSlash,
7930
+ wildcard,
7931
+ };
7932
+ };
7933
+
7934
+ /**
7935
+ * Check if a literal segment can be treated as optional based on parent route signal defaults
7936
+ */
7937
+ const checkIfLiteralCanBeOptional = (literalValue, patternRegistry) => {
7938
+ // Look through all registered patterns for parent patterns that might have this literal as a default
7939
+ for (const [, patternData] of patternRegistry) {
7940
+ // Check if any signal connection has this literal value as default
7941
+ for (const connection of patternData.connections) {
7942
+ if (connection.options.defaultValue === literalValue) {
7943
+ return true; // This literal matches a signal default, so it can be optional
7944
+ }
7945
+ }
7946
+ }
7947
+ return false;
7948
+ };
7949
+
7950
+ /**
7951
+ * Match a URL against a parsed pattern
7952
+ */
7953
+ const matchUrl = (parsedPattern, url, { parameterDefaults, baseUrl }) => {
7954
+ // Parse the URL
7955
+ const urlObj = new URL(url, baseUrl);
7956
+ let pathname = urlObj.pathname;
7957
+ const originalPathname = pathname; // Store original pathname before baseUrl processing
7958
+
7959
+ // If baseUrl is provided, calculate the pathname relative to the baseUrl's directory
7960
+ if (baseUrl) {
7961
+ const baseUrlObj = new URL(baseUrl);
7962
+ // if the base url is a file, we want to be relative to the directory containing that file
7963
+ const baseDir = baseUrlObj.pathname.endsWith("/")
7964
+ ? baseUrlObj.pathname
7965
+ : baseUrlObj.pathname.substring(0, baseUrlObj.pathname.lastIndexOf("/"));
7966
+ if (pathname.startsWith(baseDir)) {
7967
+ pathname = pathname.slice(baseDir.length);
7968
+ }
7969
+ }
7970
+
7971
+ // Handle root route - only matches empty path or just "/"
7972
+ // OR when URL exactly matches baseUrl (treating baseUrl as root)
7973
+ if (parsedPattern.segments.length === 0) {
7974
+ if (pathname === "/" || pathname === "") {
7975
+ return extractSearchParams(urlObj);
7976
+ }
7977
+
7978
+ // Special case: if URL exactly matches baseUrl, treat as root route
7979
+ if (baseUrl) {
7980
+ const baseUrlObj = new URL(baseUrl);
7981
+ if (originalPathname === baseUrlObj.pathname) {
7982
+ return extractSearchParams(urlObj);
7983
+ }
7984
+ }
7985
+
7986
+ return null;
7987
+ }
7988
+
7989
+ // Remove leading slash and split into segments
7990
+ let urlSegments = pathname.startsWith("/")
7991
+ ? pathname
7992
+ .slice(1)
7993
+ .split("/")
7994
+ .filter((s) => s !== "")
7995
+ : pathname.split("/").filter((s) => s !== "");
7996
+
7997
+ // Handle trailing slash flexibility: if pattern has trailing slash but URL doesn't (or vice versa)
7998
+ // and we're at the end of segments, allow the match
7999
+ const urlHasTrailingSlash = pathname.endsWith("/") && pathname !== "/";
8000
+ const patternHasTrailingSlash = parsedPattern.trailingSlash;
8001
+
8002
+ const params = {};
8003
+ let urlSegmentIndex = 0;
8004
+
8005
+ // Process each pattern segment
8006
+ for (let i = 0; i < parsedPattern.segments.length; i++) {
8007
+ const patternSeg = parsedPattern.segments[i];
8008
+
8009
+ if (patternSeg.type === "literal") {
8010
+ // Check if URL has this segment
8011
+ if (urlSegmentIndex >= urlSegments.length) {
8012
+ // URL is too short for this literal segment
8013
+ // Check if this literal segment can be treated as optional based on parent route defaults
8014
+ const canBeOptional = checkIfLiteralCanBeOptional(
8015
+ patternSeg.value,
8016
+ patternRegistry,
8017
+ );
8018
+ if (canBeOptional) {
8019
+ // Skip this literal segment, don't increment urlSegmentIndex
8020
+ continue;
8021
+ }
8022
+ return null; // URL too short and literal is not optional
8023
+ }
8024
+
8025
+ const urlSeg = urlSegments[urlSegmentIndex];
8026
+ if (urlSeg !== patternSeg.value) {
8027
+ // Literal mismatch - this route doesn't match this URL
8028
+ return null;
8029
+ }
8030
+ urlSegmentIndex++;
8031
+ } else if (patternSeg.type === "param") {
8032
+ // Parameter segment
8033
+ if (urlSegmentIndex >= urlSegments.length) {
8034
+ // No URL segment for this parameter
8035
+ if (patternSeg.optional) {
8036
+ // Optional parameter - use default if available
8037
+ const defaultValue = parameterDefaults.get(patternSeg.name);
8038
+ if (defaultValue !== undefined) {
8039
+ params[patternSeg.name] = defaultValue;
8040
+ }
8041
+ continue;
8042
+ }
8043
+ // Required parameter missing - but check if we can use trailing slash logic
8044
+ // If this is the last segment and we have a trailing slash difference, it might still match
8045
+ const isLastSegment = i === parsedPattern.segments.length - 1;
8046
+ if (isLastSegment && patternHasTrailingSlash && !urlHasTrailingSlash) {
8047
+ // Pattern expects trailing slash segment, URL doesn't have it
8048
+ const defaultValue = parameterDefaults.get(patternSeg.name);
8049
+ if (defaultValue !== undefined) {
8050
+ params[patternSeg.name] = defaultValue;
8051
+ continue;
7473
8052
  }
7474
8053
  }
8054
+ return null; // Required parameter missing
7475
8055
  }
7476
8056
 
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
- });
8057
+ // Capture URL segment as parameter value
8058
+ const urlSeg = urlSegments[urlSegmentIndex];
8059
+ params[patternSeg.name] = decodeURIComponent(urlSeg);
8060
+ urlSegmentIndex++;
8061
+ }
8062
+ }
8063
+
8064
+ // Check for remaining URL segments
8065
+ // Patterns with trailing slashes can match additional URL segments (like wildcards)
8066
+ // Patterns without trailing slashes should match exactly (unless they're wildcards)
8067
+ if (
8068
+ !parsedPattern.wildcard &&
8069
+ !parsedPattern.trailingSlash &&
8070
+ urlSegmentIndex < urlSegments.length
8071
+ ) {
8072
+ return null; // Pattern without trailing slash should not match extra segments
8073
+ }
8074
+ // If pattern has trailing slash or wildcard, allow extra segments (no additional check needed)
8075
+
8076
+ // Add search parameters
8077
+ const searchParams = extractSearchParams(urlObj);
8078
+ Object.assign(params, searchParams);
8079
+
8080
+ // Apply remaining parameter defaults for unmatched parameters
8081
+ for (const [paramName, defaultValue] of parameterDefaults) {
8082
+ if (!(paramName in params)) {
8083
+ params[paramName] = defaultValue;
8084
+ }
8085
+ }
8086
+
8087
+ return params;
8088
+ };
8089
+
8090
+ /**
8091
+ * Extract search parameters from URL
8092
+ */
8093
+ const extractSearchParams = (urlObj) => {
8094
+ const params = {};
8095
+ for (const [key, value] of urlObj.searchParams) {
8096
+ params[key] = value;
8097
+ }
8098
+ return params;
8099
+ };
8100
+
8101
+ /**
8102
+ * Build a URL from a pattern and parameters
8103
+ */
8104
+ const buildUrlFromPattern = (parsedPattern, params = {}) => {
8105
+ if (parsedPattern.segments.length === 0) {
8106
+ // Root route
8107
+ const searchParams = new URLSearchParams();
8108
+ for (const [key, value] of Object.entries(params)) {
8109
+ if (value !== undefined) {
8110
+ searchParams.set(key, value);
8111
+ }
8112
+ }
8113
+ const search = searchParams.toString();
8114
+ return `/${search ? `?${search}` : ""}`;
8115
+ }
8116
+
8117
+ const segments = [];
8118
+
8119
+ for (const patternSeg of parsedPattern.segments) {
8120
+ if (patternSeg.type === "literal") {
8121
+ segments.push(patternSeg.value);
8122
+ } else if (patternSeg.type === "param") {
8123
+ const value = params[patternSeg.name];
8124
+
8125
+ // If value is provided, include it
8126
+ if (value !== undefined) {
8127
+ segments.push(encodeURIComponent(value));
8128
+ } else if (!patternSeg.optional) {
8129
+ // For required parameters without values, keep the placeholder
8130
+ segments.push(`:${patternSeg.name}`);
8131
+ }
8132
+ // Optional parameters with undefined values are omitted
8133
+ }
8134
+ }
8135
+
8136
+ let path = `/${segments.join("/")}`;
8137
+
8138
+ // Handle trailing slash - only add if it serves a purpose
8139
+ if (parsedPattern.trailingSlash && !path.endsWith("/") && path !== "/") {
8140
+ // Only add trailing slash if the original pattern suggests there could be more content
8141
+ // For patterns like "/admin/:section/" where the slash is at the very end,
8142
+ // it's not needed in the generated URL if there are no more segments
8143
+ const lastSegment =
8144
+ parsedPattern.segments[parsedPattern.segments.length - 1];
8145
+ const hasMorePotentialContent =
8146
+ parsedPattern.wildcard || (lastSegment && lastSegment.type === "literal"); // Only add slash after literals, not parameters
8147
+
8148
+ if (hasMorePotentialContent) {
8149
+ path += "/";
8150
+ }
8151
+ } else if (
8152
+ !parsedPattern.trailingSlash &&
8153
+ path.endsWith("/") &&
8154
+ path !== "/"
8155
+ ) {
8156
+ // Remove trailing slash for patterns without trailing slash
8157
+ path = path.slice(0, -1);
8158
+ }
8159
+
8160
+ // Add search parameters
8161
+ const pathParamNames = new Set(
8162
+ parsedPattern.segments.filter((s) => s.type === "param").map((s) => s.name),
8163
+ );
8164
+
8165
+ // Add query parameters defined in the pattern first
8166
+ const queryParamNames = new Set();
8167
+ const searchParams = new URLSearchParams();
8168
+
8169
+ // Handle pattern-defined query parameters (from ?tab, &lon, etc.)
8170
+ if (parsedPattern.queryParams) {
8171
+ for (const queryParam of parsedPattern.queryParams) {
8172
+ const paramName = queryParam.name;
8173
+ queryParamNames.add(paramName);
8174
+
8175
+ const value = params[paramName];
8176
+ if (value !== undefined) {
8177
+ searchParams.set(paramName, value);
8178
+ }
8179
+ // If no value provided, don't add the parameter to keep URLs clean
8180
+ }
8181
+ }
8182
+
8183
+ // Add remaining parameters as additional query parameters (excluding path and pattern query params)
8184
+ for (const [key, value] of Object.entries(params)) {
8185
+ if (
8186
+ !pathParamNames.has(key) &&
8187
+ !queryParamNames.has(key) &&
8188
+ value !== undefined
8189
+ ) {
8190
+ searchParams.set(key, value);
8191
+ }
7481
8192
  }
7482
8193
 
7483
- // Clean up any double slashes or trailing slashes that might result
7484
- relativeUrl = relativeUrl.replace(/\/+/g, "/").replace(/\/$/, "");
8194
+ const search = searchParams.toString();
7485
8195
 
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;
7496
- }
7497
- wildcardIndex++;
7498
- return "*";
7499
- });
7500
- }
8196
+ // No longer handle trailing slash inheritance here
8197
+
8198
+ return path + (search ? `?${search}` : "");
8199
+ };
7501
8200
 
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);
8201
+ /**
8202
+ * Check if childPattern is a child route of parentPattern
8203
+ * E.g., "/admin/settings/:tab" is a child of "/admin/:section/"
8204
+ * Also, "/admin/?tab=something" is a child of "/admin/"
8205
+ */
8206
+ const isChildPattern = (childPattern, parentPattern) => {
8207
+ // Split path and query parts
8208
+ const [childPath, childQuery] = childPattern.split("?");
8209
+ const [parentPath, parentQuery] = parentPattern.split("?");
8210
+
8211
+ // Remove trailing slashes for path comparison
8212
+ const cleanChild = childPath.replace(/\/$/, "");
8213
+ const cleanParent = parentPath.replace(/\/$/, "");
8214
+
8215
+ // CASE 1: Same path, child has query params, parent doesn't
8216
+ // E.g., "/admin/?tab=something" is child of "/admin/"
8217
+ if (cleanChild === cleanParent && childQuery && !parentQuery) {
8218
+ return true;
7508
8219
  }
7509
8220
 
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);
8221
+ // CASE 2: Traditional path-based child relationship
8222
+ // Convert patterns to comparable segments for proper comparison
8223
+ const childSegments = cleanChild.split("/").filter((s) => s);
8224
+ const parentSegments = cleanParent.split("/").filter((s) => s);
8225
+
8226
+ // Child must have at least as many segments as parent
8227
+ if (childSegments.length < parentSegments.length) {
8228
+ return false;
7514
8229
  }
7515
8230
 
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;
8231
+ let hasMoreSpecificSegment = false;
8232
+
8233
+ // Check if parent segments match child segments (allowing for parameters)
8234
+ for (let i = 0; i < parentSegments.length; i++) {
8235
+ const parentSeg = parentSegments[i];
8236
+ const childSeg = childSegments[i];
8237
+
8238
+ // If parent has parameter, child can have anything in that position
8239
+ if (parentSeg.startsWith(":")) {
8240
+ // Child is more specific if it has a literal value for a parent parameter
8241
+ // But if child also starts with ":", it's also a parameter (not more specific)
8242
+ if (!childSeg.startsWith(":")) {
8243
+ hasMoreSpecificSegment = true;
7535
8244
  }
7536
- } else if (extraParamEffect === "warn") {
7537
- console.warn(
7538
- `Unknown parameters given to "${urlPatternInput}":`,
7539
- Array.from(extraParamMap.keys()),
7540
- );
8245
+ continue;
7541
8246
  }
7542
- }
7543
8247
 
7544
- // Append string query params if any
7545
- if (stringQueryParams) {
7546
- relativeUrl += (relativeUrl.includes("?") ? "&" : "?") + stringQueryParams;
8248
+ // If parent has literal, child must match exactly
8249
+ if (parentSeg !== childSeg) {
8250
+ return false;
8251
+ }
7547
8252
  }
7548
8253
 
7549
- return {
7550
- relativeUrl,
7551
- hasRawUrlPartWithInvalidChars,
7552
- };
8254
+ // Child must be more specific (more segments OR more specific segments)
8255
+ return childSegments.length > parentSegments.length || hasMoreSpecificSegment;
7553
8256
  };
7554
8257
 
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
- });
8258
+ /**
8259
+ * Register all patterns at once and build their relationships
8260
+ */
8261
+ const setupPatterns = (patternDefinitions) => {
8262
+ // Clear existing patterns
8263
+ patternRegistry.clear();
8264
+ patternRelationships.clear();
7563
8265
 
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
- });
8266
+ // Phase 1: Register all patterns
8267
+ for (const [key, urlPatternRaw] of Object.entries(patternDefinitions)) {
8268
+ const [cleanPattern, connections] = detectSignals(urlPatternRaw);
8269
+ const parsedPattern = parsePattern(cleanPattern);
7579
8270
 
7580
- const applyOn = (url) => {
8271
+ const patternData = {
8272
+ key,
8273
+ urlPatternRaw,
8274
+ cleanPattern,
8275
+ connections,
8276
+ parsedPattern,
8277
+ childPatterns: [],
8278
+ parentPatterns: [],
8279
+ };
7581
8280
 
7582
- // Check if the URL matches the route pattern
7583
- const match = urlPattern.exec(url);
7584
- if (match) {
7585
- return extractParams(match, url);
7586
- }
8281
+ patternRegistry.set(urlPatternRaw, patternData);
8282
+ }
7587
8283
 
7588
- // If no match, try with normalized URLs (trailing slash handling)
7589
- const urlObj = new URL(url, baseUrl);
7590
- const pathname = urlObj.pathname;
8284
+ // Phase 2: Build relationships between all patterns
8285
+ const allPatterns = Array.from(patternRegistry.keys());
7591
8286
 
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
- };
8287
+ for (const currentPattern of allPatterns) {
8288
+ const currentData = patternRegistry.get(currentPattern);
7614
8289
 
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;
8290
+ for (const otherPattern of allPatterns) {
8291
+ if (currentPattern === otherPattern) continue;
8292
+
8293
+ const otherData = patternRegistry.get(otherPattern);
8294
+
8295
+ // Check if current pattern is a child of other pattern using clean patterns
8296
+ if (isChildPattern(currentData.cleanPattern, otherData.cleanPattern)) {
8297
+ currentData.parentPatterns.push(otherPattern);
8298
+ otherData.childPatterns.push(currentPattern);
7653
8299
  }
7654
8300
  }
7655
- return params;
7656
- };
7657
8301
 
7658
- return {
7659
- urlPattern,
7660
- applyOn,
7661
- };
8302
+ // Store relationships for easy access
8303
+ patternRelationships.set(currentPattern, {
8304
+ pattern: currentData.parsedPattern,
8305
+ parsedPattern: currentData.parsedPattern,
8306
+ connections: currentData.connections,
8307
+ childPatterns: currentData.childPatterns, // Store child patterns
8308
+ parentPatterns: currentData.parentPatterns, // Store parent patterns
8309
+ originalPattern: currentPattern,
8310
+ });
8311
+ }
7662
8312
  };
7663
8313
 
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
8314
  /**
7675
- *
7676
- *
8315
+ * Get pattern data for a registered pattern
7677
8316
  */
8317
+ const getPatternData = (urlPatternRaw) => {
8318
+ return patternRegistry.get(urlPatternRaw);
8319
+ };
7678
8320
 
8321
+ /**
8322
+ * Clear all registered patterns
8323
+ */
8324
+ const clearPatterns = () => {
8325
+ patternRegistry.clear();
8326
+ patternRelationships.clear();
8327
+ };
7679
8328
 
7680
- let baseUrl;
7681
- if (typeof window === "undefined") {
7682
- baseUrl = "http://localhost/";
7683
- } else {
7684
- baseUrl = window.location.origin;
7685
- }
8329
+ const resolveRouteUrl = (relativeUrl) => {
8330
+ if (relativeUrl[0] === "/") {
8331
+ // we remove the leading slash because we want to resolve against baseUrl which may
8332
+ // not be the root url
8333
+ relativeUrl = relativeUrl.slice(1);
8334
+ }
7686
8335
 
7687
- const setBaseUrl = (value) => {
7688
- baseUrl = new URL(value, window.location).href;
8336
+ // we don't use URL constructor on PURPOSE (in case the relativeUrl contains invalid url chars)
8337
+ // and we want to support use cases where people WANT to produce invalid urls (for example rawUrlPart with spaces)
8338
+ // because these urls will be handled by non standard clients (like a backend service allowing url like stuff)
8339
+ if (baseUrl.endsWith("/")) {
8340
+ return `${baseUrl}${relativeUrl}`;
8341
+ }
8342
+ return `${baseUrl}/${relativeUrl}`;
7689
8343
  };
8344
+
8345
+ /**
8346
+ * Route management with pattern-first architecture
8347
+ * Routes work with relative URLs, patterns handle base URL resolution
8348
+ */
8349
+
8350
+
7690
8351
  // Controls what happens to actions when their route stops matching:
7691
8352
  // 'abort' - Cancel the action immediately when route stops matching
7692
8353
  // 'keep-loading' - Allow action to continue running after route stops matching
@@ -7695,19 +8356,23 @@ const setBaseUrl = (value) => {
7695
8356
  // However, since route reactivation triggers action reload anyway, the old data won't be used
7696
8357
  // so it's better to abort the action to avoid unnecessary resource usage.
7697
8358
  const ROUTE_DEACTIVATION_STRATEGY = "abort"; // 'abort', 'keep-loading'
8359
+ const ROUTE_NOT_MATCHING_PARAMS = {};
7698
8360
 
7699
8361
  const routeSet = new Set();
7700
8362
  // Store previous route states to detect changes
8363
+ const routePrivatePropertiesMap = new Map();
8364
+
7701
8365
  const routePreviousStateMap = new WeakMap();
7702
8366
  // Store abort controllers per action to control their lifecycle based on route state
7703
8367
  const actionAbortControllerWeakMap = new WeakMap();
8368
+
7704
8369
  const updateRoutes = (
7705
8370
  url,
7706
8371
  {
8372
+ navigationType = "push",
8373
+ isVisited = () => false,
7707
8374
  // state
7708
- replace,
7709
- isVisited,
7710
- },
8375
+ } = {},
7711
8376
  ) => {
7712
8377
  const routeMatchInfoSet = new Set();
7713
8378
  for (const route of routeSet) {
@@ -7717,32 +8382,19 @@ const updateRoutes = (
7717
8382
  // Get previous state
7718
8383
  const previousState = routePreviousStateMap.get(route) || {
7719
8384
  matching: false,
7720
- exactMatching: false,
7721
- params: null,
8385
+ params: ROUTE_NOT_MATCHING_PARAMS,
7722
8386
  };
7723
8387
  const oldMatching = previousState.matching;
7724
- const oldExactMatching = previousState.exactMatching;
7725
8388
  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
- }
8389
+
8390
+ // Use custom pattern matching - much simpler than URLPattern approach
8391
+ let extractedParams = routePattern.applyOn(url);
8392
+ let newMatching = Boolean(extractedParams);
8393
+
7744
8394
  let newParams;
8395
+
7745
8396
  if (extractedParams) {
8397
+ // No need for complex wildcard correction - custom system handles it properly
7746
8398
  if (compareTwoJsValues(oldParams, extractedParams)) {
7747
8399
  // No change in parameters, keep the old params
7748
8400
  newParams = oldParams;
@@ -7750,7 +8402,7 @@ const updateRoutes = (
7750
8402
  newParams = extractedParams;
7751
8403
  }
7752
8404
  } else {
7753
- newParams = null;
8405
+ newParams = ROUTE_NOT_MATCHING_PARAMS;
7754
8406
  }
7755
8407
 
7756
8408
  const routeMatchInfo = {
@@ -7758,8 +8410,6 @@ const updateRoutes = (
7758
8410
  routePrivateProperties,
7759
8411
  oldMatching,
7760
8412
  newMatching,
7761
- oldExactMatching,
7762
- newExactMatching,
7763
8413
  oldParams,
7764
8414
  newParams,
7765
8415
  };
@@ -7767,7 +8417,6 @@ const updateRoutes = (
7767
8417
  // Store current state for next comparison
7768
8418
  routePreviousStateMap.set(route, {
7769
8419
  matching: newMatching,
7770
- exactMatching: newExactMatching,
7771
8420
  params: newParams,
7772
8421
  });
7773
8422
  }
@@ -7779,14 +8428,12 @@ const updateRoutes = (
7779
8428
  route,
7780
8429
  routePrivateProperties,
7781
8430
  newMatching,
7782
- newExactMatching,
7783
8431
  newParams,
7784
8432
  } of routeMatchInfoSet) {
7785
8433
  const { updateStatus } = routePrivateProperties;
7786
8434
  const visited = isVisited(route.url);
7787
8435
  updateStatus({
7788
8436
  matching: newMatching,
7789
- exactMatching: newExactMatching,
7790
8437
  params: newParams,
7791
8438
  visited,
7792
8439
  });
@@ -7807,7 +8454,11 @@ const updateRoutes = (
7807
8454
  const routeAction = route.action;
7808
8455
  const currentAction = routeAction.getCurrentAction();
7809
8456
  if (shouldLoad) {
7810
- if (replace || currentAction.aborted || currentAction.error) {
8457
+ if (
8458
+ navigationType === "replace" ||
8459
+ currentAction.aborted ||
8460
+ currentAction.error
8461
+ ) {
7811
8462
  shouldLoad = false;
7812
8463
  }
7813
8464
  }
@@ -7885,12 +8536,15 @@ const updateRoutes = (
7885
8536
  };
7886
8537
  };
7887
8538
 
7888
- const routePrivatePropertiesMap = new Map();
7889
8539
  const getRoutePrivateProperties = (route) => {
7890
8540
  return routePrivatePropertiesMap.get(route);
7891
8541
  };
7892
8542
 
7893
- const createRoute = (urlPatternInput) => {
8543
+ const registerRoute = (routePattern) => {
8544
+ const urlPatternRaw = routePattern.originalPattern;
8545
+ const patternData = getPatternData(urlPatternRaw);
8546
+ const { cleanPattern, connections } = patternData;
8547
+
7894
8548
  const cleanupCallbackSet = new Set();
7895
8549
  const cleanup = () => {
7896
8550
  for (const cleanupCallback of cleanupCallbackSet) {
@@ -7898,14 +8552,14 @@ const createRoute = (urlPatternInput) => {
7898
8552
  }
7899
8553
  cleanupCallbackSet.clear();
7900
8554
  };
7901
-
7902
8555
  const [publishStatus, subscribeStatus] = createPubSub();
8556
+
7903
8557
  const route = {
7904
- urlPattern: urlPatternInput,
8558
+ urlPattern: cleanPattern,
8559
+ pattern: cleanPattern,
7905
8560
  isRoute: true,
7906
8561
  matching: false,
7907
- exactMatching: false,
7908
- params: null,
8562
+ params: ROUTE_NOT_MATCHING_PARAMS,
7909
8563
  buildUrl: null,
7910
8564
  bindAction: null,
7911
8565
  relativeUrl: null,
@@ -7913,167 +8567,171 @@ const createRoute = (urlPatternInput) => {
7913
8567
  action: null,
7914
8568
  cleanup,
7915
8569
  toString: () => {
7916
- return `route "${urlPatternInput}"`;
8570
+ return `route "${cleanPattern}"`;
7917
8571
  },
7918
8572
  replaceParams: undefined,
7919
8573
  subscribeStatus,
7920
8574
  };
7921
8575
  routeSet.add(route);
7922
-
7923
8576
  const routePrivateProperties = {
7924
- routePattern: null,
8577
+ routePattern,
8578
+ originalPattern: urlPatternRaw,
8579
+ pattern: cleanPattern,
7925
8580
  matchingSignal: null,
7926
- exactMatchingSignal: null,
7927
8581
  paramsSignal: null,
8582
+ rawParamsSignal: null,
7928
8583
  visitedSignal: null,
7929
8584
  relativeUrlSignal: null,
7930
8585
  urlSignal: null,
7931
- updateStatus: ({ matching, exactMatching, params, visited }) => {
8586
+ updateStatus: ({ matching, params, visited }) => {
7932
8587
  let someChange = false;
7933
8588
  matchingSignal.value = matching;
7934
- exactMatchingSignal.value = exactMatching;
7935
- paramsSignal.value = params;
7936
- visitedSignal.value = visited;
8589
+
7937
8590
  if (route.matching !== matching) {
7938
8591
  route.matching = matching;
7939
8592
  someChange = true;
7940
8593
  }
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
- }
8594
+ visitedSignal.value = visited;
7949
8595
  if (route.visited !== visited) {
7950
8596
  route.visited = visited;
7951
8597
  someChange = true;
7952
8598
  }
8599
+ // Store raw params (from URL) - paramsSignal will reactively compute merged params
8600
+ rawParamsSignal.value = params;
8601
+ // Get merged params for comparison (computed signal will handle the merging)
8602
+ const mergedParams = paramsSignal.value;
8603
+ if (route.params !== mergedParams) {
8604
+ route.params = mergedParams;
8605
+ someChange = true;
8606
+ }
7953
8607
  if (someChange) {
7954
- publishStatus({ matching, exactMatching, params, visited });
8608
+ publishStatus({
8609
+ matching,
8610
+ params: mergedParams,
8611
+ visited,
8612
+ });
7955
8613
  }
7956
8614
  },
7957
8615
  };
7958
8616
  routePrivatePropertiesMap.set(route, routePrivateProperties);
7959
8617
 
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
- };
8618
+ const matchingSignal = signal(false);
8619
+ const rawParamsSignal = signal(ROUTE_NOT_MATCHING_PARAMS);
8620
+ const paramsSignal = computed(() => {
8621
+ const rawParams = rawParamsSignal.value;
8622
+ // Pattern system handles parameter defaults, routes just work with raw params
8623
+ return rawParams || {};
8624
+ });
8625
+ const visitedSignal = signal(false);
8626
+ for (const { signal: stateSignal, paramName, options = {} } of connections) {
8627
+ const { debug } = options;
7966
8628
 
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
- }
8629
+ // URL -> Signal synchronization
8630
+ effect(() => {
8631
+ const matching = matchingSignal.value;
8632
+ const params = rawParamsSignal.value;
8633
+ const urlParamValue = params[paramName];
8634
+
8635
+ if (!matching) {
8636
+ return;
8637
+ }
8638
+ if (debug) {
8639
+ console.debug(
8640
+ `[stateSignal] URL -> Signal: ${paramName}=${urlParamValue}`,
8641
+ );
8642
+ }
8643
+ stateSignal.value = urlParamValue;
8644
+ });
8645
+
8646
+ // Signal -> URL synchronization
8647
+ effect(() => {
8648
+ const value = stateSignal.value;
8649
+ const params = rawParamsSignal.value;
8650
+ const urlParamValue = params[paramName];
8651
+ const matching = matchingSignal.value;
8652
+
8653
+ if (!matching || value === urlParamValue) {
8654
+ return;
8655
+ }
8656
+
8657
+ if (debug) {
8658
+ console.debug(`[stateSignal] Signal -> URL: ${paramName}=${value}`);
7975
8659
  }
7976
- params = paramsWithoutWildcards;
8660
+
8661
+ route.replaceParams({ [paramName]: value });
8662
+ });
8663
+ }
8664
+
8665
+ route.navTo = (params) => {
8666
+ if (!browserIntegration$1) {
8667
+ return Promise.resolve();
7977
8668
  }
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;
8669
+ return browserIntegration$1.navTo(route.buildUrl(params));
8670
+ };
8671
+ route.redirectTo = (params) => {
8672
+ if (!browserIntegration$1) {
8673
+ return Promise.resolve();
7983
8674
  }
7984
- if (otherParamsFalsyOrEmpty) {
7985
- return false;
8675
+ return browserIntegration$1.navTo(route.buildUrl(params), {
8676
+ replace: true,
8677
+ });
8678
+ };
8679
+ route.replaceParams = (newParams) => {
8680
+ const matching = matchingSignal.peek();
8681
+ if (!matching) {
8682
+ console.warn(
8683
+ `Cannot replace params on route ${route} because it is not matching the current URL.`,
8684
+ );
8685
+ return null;
7986
8686
  }
7987
- return compareTwoJsValues(params, otherParams);
8687
+ if (route.action) {
8688
+ // For action: merge with resolved params (includes defaults) so action gets complete params
8689
+ const currentResolvedParams = routePattern.resolveParams();
8690
+ const updatedActionParams = { ...currentResolvedParams, ...newParams };
8691
+ route.action.replaceParams(updatedActionParams);
8692
+ }
8693
+ return route.redirectTo(newParams);
7988
8694
  };
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;
8695
+ route.buildRelativeUrl = (params) => {
8696
+ // buildMostPreciseUrl now handles parameter resolution internally
8697
+ return routePattern.buildMostPreciseUrl(params);
8698
+ };
8699
+ route.buildUrl = (params) => {
8700
+ const routeRelativeUrl = route.buildRelativeUrl(params);
8701
+ const routeUrl = resolveRouteUrl(routeRelativeUrl);
8702
+ return routeUrl;
8703
+ };
8704
+ route.matchesParams = (providedParams) => {
8705
+ const currentParams = route.params;
8706
+ const resolvedParams = routePattern.resolveParams({
8707
+ ...currentParams,
8708
+ ...providedParams,
8709
+ });
8710
+ const same = compareTwoJsValues(currentParams, resolvedParams);
8711
+ return same;
8025
8712
  };
8026
- route.buildUrl = buildUrl;
8027
8713
 
8028
- const matchingSignal = signal(false);
8029
- const exactMatchingSignal = signal(false);
8030
- const paramsSignal = signal(null);
8031
- const visitedSignal = signal(false);
8714
+ // relativeUrl/url
8032
8715
  const relativeUrlSignal = computed(() => {
8033
- const params = paramsSignal.value;
8034
- const { relativeUrl } = buildRelativeUrl(params);
8716
+ const rawParams = rawParamsSignal.value;
8717
+ const relativeUrl = route.buildRelativeUrl(rawParams);
8035
8718
  return relativeUrl;
8036
8719
  });
8720
+ const urlSignal = computed(() => {
8721
+ const routeUrl = route.buildUrl();
8722
+ return routeUrl;
8723
+ });
8037
8724
  const disposeRelativeUrlEffect = effect(() => {
8038
8725
  route.relativeUrl = relativeUrlSignal.value;
8039
8726
  });
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
8727
  const disposeUrlEffect = effect(() => {
8048
8728
  route.url = urlSignal.value;
8049
8729
  });
8730
+ cleanupCallbackSet.add(disposeRelativeUrlEffect);
8050
8731
  cleanupCallbackSet.add(disposeUrlEffect);
8051
8732
 
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
-
8733
+ // action stuff (for later)
8734
+ route.bindAction = (action) => {
8077
8735
  const { store } = action.meta;
8078
8736
  if (store) {
8079
8737
  const { mutableIdKeys } = store;
@@ -8100,75 +8758,40 @@ const createRoute = (urlPatternInput) => {
8100
8758
  }
8101
8759
  }
8102
8760
 
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
8761
  const actionBoundToThisRoute = action.bindParams(paramsSignal);
8129
8762
  route.action = actionBoundToThisRoute;
8130
8763
  return actionBoundToThisRoute;
8131
8764
  };
8132
- route.bindAction = bindAction;
8133
8765
 
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
- }
8766
+ // Store private properties for internal access
8767
+ routePrivateProperties.matchingSignal = matchingSignal;
8768
+ routePrivateProperties.paramsSignal = paramsSignal;
8769
+ routePrivateProperties.rawParamsSignal = rawParamsSignal;
8770
+ routePrivateProperties.visitedSignal = visitedSignal;
8771
+ routePrivateProperties.relativeUrlSignal = relativeUrlSignal;
8772
+ routePrivateProperties.urlSignal = urlSignal;
8773
+ routePrivateProperties.cleanupCallbackSet = cleanupCallbackSet;
8145
8774
 
8146
8775
  return route;
8147
8776
  };
8777
+
8148
8778
  const useRouteStatus = (route) => {
8149
8779
  const routePrivateProperties = getRoutePrivateProperties(route);
8150
8780
  if (!routePrivateProperties) {
8151
8781
  throw new Error(`Cannot find route private properties for ${route}`);
8152
8782
  }
8153
8783
 
8154
- const {
8155
- urlSignal,
8156
- matchingSignal,
8157
- exactMatchingSignal,
8158
- paramsSignal,
8159
- visitedSignal,
8160
- } = routePrivateProperties;
8784
+ const { urlSignal, matchingSignal, paramsSignal, visitedSignal } =
8785
+ routePrivateProperties;
8161
8786
 
8162
8787
  const url = urlSignal.value;
8163
8788
  const matching = matchingSignal.value;
8164
- const exactMatching = exactMatchingSignal.value;
8165
8789
  const params = paramsSignal.value;
8166
8790
  const visited = visitedSignal.value;
8167
8791
 
8168
8792
  return {
8169
8793
  url,
8170
8794
  matching,
8171
- exactMatching,
8172
8795
  params,
8173
8796
  visited,
8174
8797
  };
@@ -8178,7 +8801,6 @@ let browserIntegration$1;
8178
8801
  const setBrowserIntegration = (integration) => {
8179
8802
  browserIntegration$1 = integration;
8180
8803
  };
8181
-
8182
8804
  let onRouteDefined = () => {};
8183
8805
  const setOnRouteDefined = (v) => {
8184
8806
  onRouteDefined = v;
@@ -8199,23 +8821,47 @@ const setOnRouteDefined = (v) => {
8199
8821
  // at any given time (url can be shared, reloaded, etc..)
8200
8822
  // Later I'll consider adding ability to have dynamic import into the mix
8201
8823
  // (An async function returning an action)
8824
+
8202
8825
  const setupRoutes = (routeDefinition) => {
8203
- // Clean up existing routes
8204
- for (const route of routeSet) {
8205
- route.cleanup();
8826
+ // Prevent calling setupRoutes when routes already exist - enforce clean setup
8827
+ if (routeSet.size > 0) {
8828
+ throw new Error(
8829
+ "Routes already exist. Call clearAllRoutes() first to clean up existing routes before creating new ones. This prevents cross-test pollution and ensures clean state.",
8830
+ );
8206
8831
  }
8207
- routeSet.clear();
8208
-
8832
+ // PHASE 1: Register all patterns and build their relationships
8833
+ setupPatterns(routeDefinition);
8834
+ // PHASE 2: Create route patterns with signal connections and parameter defaults
8835
+ const routePatterns = {};
8836
+ for (const key of Object.keys(routeDefinition)) {
8837
+ const urlPatternRaw = routeDefinition[key];
8838
+ routePatterns[key] = createRoutePattern(urlPatternRaw);
8839
+ }
8840
+ // PHASE 3: Create routes using pre-created patterns
8209
8841
  const routes = {};
8210
8842
  for (const key of Object.keys(routeDefinition)) {
8211
- const value = routeDefinition[key];
8212
- const route = createRoute(value);
8843
+ const routePattern = routePatterns[key];
8844
+ const route = registerRoute(routePattern);
8213
8845
  routes[key] = route;
8214
8846
  }
8215
8847
  onRouteDefined();
8848
+
8216
8849
  return routes;
8217
8850
  };
8218
8851
 
8852
+ // for unit tests
8853
+ const clearAllRoutes = () => {
8854
+ for (const route of routeSet) {
8855
+ route.cleanup();
8856
+ }
8857
+ routeSet.clear();
8858
+ routePrivatePropertiesMap.clear();
8859
+ // Clear patterns as well
8860
+ clearPatterns();
8861
+ // Don't clear signal registry here - let tests manage it explicitly
8862
+ // This prevents clearing signals that are still being used across multiple route registrations
8863
+ };
8864
+
8219
8865
  const arraySignal = (initialValue = []) => {
8220
8866
  const theSignal = signal(initialValue);
8221
8867
 
@@ -8340,7 +8986,9 @@ computed(() => {
8340
8986
  return reasonArray;
8341
8987
  });
8342
8988
 
8343
- const documentUrlSignal = signal(window.location.href);
8989
+ const documentUrlSignal = signal(
8990
+ typeof window === "undefined" ? "http://localhost" : window.location.href,
8991
+ );
8344
8992
  const useDocumentUrl = () => {
8345
8993
  return documentUrlSignal.value;
8346
8994
  };
@@ -8477,11 +9125,10 @@ const setupBrowserIntegrationViaHistory = ({
8477
9125
  { reason = "replaceDocumentState called" } = {},
8478
9126
  ) => {
8479
9127
  const url = window.location.href;
8480
- window.history.replaceState(newState, null, url);
8481
9128
  handleRoutingTask(url, {
8482
- replace: true,
8483
- state: newState,
8484
9129
  reason,
9130
+ navigationType: "replace",
9131
+ state: newState,
8485
9132
  });
8486
9133
  };
8487
9134
 
@@ -8504,42 +9151,52 @@ const setupBrowserIntegrationViaHistory = ({
8504
9151
  return;
8505
9152
  }
8506
9153
  visitedUrlSet.add(url);
8507
-
8508
- // Increment signal to notify subscribers that visited URLs changed
8509
- visitedUrlsSignal.value++;
9154
+ visitedUrlsSignal.value++; // Increment signal to notify subscribers that visited URLs changed
8510
9155
 
8511
9156
  const historyState = getDocumentState() || {};
8512
- const hsitoryStateWithVisitedUrls = {
9157
+ const historyStateWithVisitedUrls = {
8513
9158
  ...historyState,
8514
9159
  jsenv_visited_urls: Array.from(visitedUrlSet),
8515
9160
  };
8516
9161
  window.history.replaceState(
8517
- hsitoryStateWithVisitedUrls,
9162
+ historyStateWithVisitedUrls,
8518
9163
  null,
8519
9164
  window.location.href,
8520
9165
  );
8521
- updateDocumentState(hsitoryStateWithVisitedUrls);
9166
+ updateDocumentState(historyStateWithVisitedUrls);
8522
9167
  };
8523
9168
 
8524
9169
  let abortController = null;
8525
- const handleRoutingTask = (url, { state, replace, reason }) => {
8526
- markUrlAsVisited(url);
9170
+ const handleRoutingTask = (
9171
+ url,
9172
+ {
9173
+ reason,
9174
+ navigationType, // "push", "reload", "replace", "traverse"
9175
+ state,
9176
+ },
9177
+ ) => {
9178
+ if (navigationType === "push") {
9179
+ window.history.pushState(state, null, url);
9180
+ } else if (navigationType === "replace") {
9181
+ window.history.replaceState(state, null, url);
9182
+ }
9183
+
8527
9184
  updateDocumentUrl(url);
8528
9185
  updateDocumentState(state);
9186
+ markUrlAsVisited(url);
8529
9187
  if (abortController) {
8530
9188
  abortController.abort(`navigating to ${url}`);
8531
9189
  }
8532
9190
  abortController = new AbortController();
8533
-
9191
+ const abortSignal = abortController.signal;
8534
9192
  const { allResult, requestedResult } = applyRouting(url, {
8535
9193
  globalAbortSignal: globalAbortController.signal,
8536
- abortSignal: abortController.signal,
8537
- state,
8538
- replace,
8539
- isVisited,
9194
+ abortSignal,
8540
9195
  reason,
9196
+ navigationType,
9197
+ isVisited,
9198
+ state,
8541
9199
  });
8542
-
8543
9200
  executeWithCleanup(
8544
9201
  () => allResult,
8545
9202
  () => {
@@ -8583,11 +9240,10 @@ const setupBrowserIntegrationViaHistory = ({
8583
9240
  return;
8584
9241
  }
8585
9242
  e.preventDefault();
8586
- const state = null;
8587
- history.pushState(state, null, href);
8588
9243
  handleRoutingTask(href, {
8589
- state,
8590
9244
  reason: `"click" on a[href="${href}"]`,
9245
+ navigationType: "push",
9246
+ state: null,
8591
9247
  });
8592
9248
  },
8593
9249
  { capture: true },
@@ -8596,7 +9252,8 @@ const setupBrowserIntegrationViaHistory = ({
8596
9252
  window.addEventListener(
8597
9253
  "submit",
8598
9254
  () => {
8599
- // TODO: Handle form submissions
9255
+ // Handle form submissions?
9256
+ // Not needed yet
8600
9257
  },
8601
9258
  { capture: true },
8602
9259
  );
@@ -8605,21 +9262,17 @@ const setupBrowserIntegrationViaHistory = ({
8605
9262
  const url = window.location.href;
8606
9263
  const state = popstateEvent.state;
8607
9264
  handleRoutingTask(url, {
8608
- state,
8609
9265
  reason: `"popstate" event for ${url}`,
9266
+ navigationType: "traverse",
9267
+ state,
8610
9268
  });
8611
9269
  });
8612
9270
 
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
- }
9271
+ const navTo = async (url, { replace, state = null } = {}) => {
8619
9272
  handleRoutingTask(url, {
8620
- state,
8621
- replace,
8622
9273
  reason: `navTo called with "${url}"`,
9274
+ navigationType: replace ? "replace" : "push",
9275
+ state,
8623
9276
  });
8624
9277
  };
8625
9278
 
@@ -8631,6 +9284,8 @@ const setupBrowserIntegrationViaHistory = ({
8631
9284
  const url = window.location.href;
8632
9285
  const state = history.state;
8633
9286
  handleRoutingTask(url, {
9287
+ reason: "reload called",
9288
+ navigationType: "reload",
8634
9289
  state,
8635
9290
  });
8636
9291
  };
@@ -8646,11 +9301,10 @@ const setupBrowserIntegrationViaHistory = ({
8646
9301
  const init = () => {
8647
9302
  const url = window.location.href;
8648
9303
  const state = history.state;
8649
- history.replaceState(state, null, url);
8650
9304
  handleRoutingTask(url, {
8651
- state,
8652
- replace: true,
8653
9305
  reason: "routing initialization",
9306
+ navigationType: "replace",
9307
+ state,
8654
9308
  });
8655
9309
  };
8656
9310
 
@@ -8686,7 +9340,7 @@ const applyRouting = (
8686
9340
  globalAbortSignal,
8687
9341
  abortSignal,
8688
9342
  // state
8689
- replace,
9343
+ navigationType,
8690
9344
  isVisited,
8691
9345
  reason,
8692
9346
  },
@@ -8698,9 +9352,9 @@ const applyRouting = (
8698
9352
  routeLoadRequestedMap,
8699
9353
  activeRouteSet,
8700
9354
  } = updateRoutes(url, {
8701
- replace,
8702
- // state,
9355
+ navigationType,
8703
9356
  isVisited,
9357
+ // state,
8704
9358
  });
8705
9359
  if (loadSet.size === 0 && reloadSet.size === 0) {
8706
9360
  return {
@@ -8937,7 +9591,8 @@ const Route = ({
8937
9591
  index,
8938
9592
  fallback,
8939
9593
  meta,
8940
- children
9594
+ children,
9595
+ routeParams
8941
9596
  }) => {
8942
9597
  const forceRender = useForceRender();
8943
9598
  const hasDiscoveredRef = useRef(false);
@@ -8949,6 +9604,7 @@ const Route = ({
8949
9604
  index: index,
8950
9605
  fallback: fallback,
8951
9606
  meta: meta,
9607
+ routeParams: routeParams,
8952
9608
  onMatchingInfoChange: matchingInfo => {
8953
9609
  hasDiscoveredRef.current = true;
8954
9610
  matchingInfoRef.current = matchingInfo;
@@ -8977,6 +9633,7 @@ const MatchingRouteManager = ({
8977
9633
  index,
8978
9634
  fallback,
8979
9635
  meta,
9636
+ routeParams,
8980
9637
  onMatchingInfoChange,
8981
9638
  children
8982
9639
  }) => {
@@ -9018,6 +9675,7 @@ const MatchingRouteManager = ({
9018
9675
  index,
9019
9676
  fallback,
9020
9677
  meta,
9678
+ routeParams,
9021
9679
  indexCandidate,
9022
9680
  fallbackCandidate,
9023
9681
  candidateSet,
@@ -9036,6 +9694,7 @@ const initRouteObserver = ({
9036
9694
  index,
9037
9695
  fallback,
9038
9696
  meta,
9697
+ routeParams,
9039
9698
  indexCandidate,
9040
9699
  fallbackCandidate,
9041
9700
  candidateSet,
@@ -9092,6 +9751,12 @@ const initRouteObserver = ({
9092
9751
  // we have a route and it does not match no need to go further
9093
9752
  return null;
9094
9753
  }
9754
+
9755
+ // Check if routeParams match current route parameters
9756
+ if (routeParams && !route.matchesParams(routeParams)) {
9757
+ return null; // routeParams don't match, don't render
9758
+ }
9759
+
9095
9760
  // we have a route and it is matching
9096
9761
  // we search the first matching child to put it in the slot
9097
9762
  const matchingChildInfo = findMatchingChildInfo();
@@ -17322,26 +17987,23 @@ const RouteLink = ({
17322
17987
  if (!route) {
17323
17988
  throw new Error("route prop is required");
17324
17989
  }
17325
- const routeStatus = useRouteStatus(route);
17990
+ useRouteStatus(route);
17326
17991
  const url = route.buildUrl(routeParams);
17327
- let isCurrent;
17328
- if (routeStatus.exactMatching) {
17329
- isCurrent = true;
17330
- } else if (routeStatus.matching) {
17331
- isCurrent = route.matchesParams(routeParams);
17332
- } else {
17333
- isCurrent = false;
17334
- }
17335
17992
  return jsx(Link, {
17336
17993
  ...rest,
17337
17994
  href: url,
17338
- pseudoState: {
17339
- ":-navi-href-current": isCurrent
17340
- },
17341
17995
  children: children || route.buildRelativeUrl(routeParams)
17342
17996
  });
17343
17997
  };
17344
17998
 
17999
+ const rawUrlPartSymbol = Symbol("raw_url_part");
18000
+ const rawUrlPart = (value) => {
18001
+ return {
18002
+ [rawUrlPartSymbol]: true,
18003
+ value,
18004
+ };
18005
+ };
18006
+
17345
18007
  installImportMetaCss(import.meta);Object.assign(PSEUDO_CLASSES, {
17346
18008
  ":-navi-tab-selected": {
17347
18009
  attribute: "data-tab-selected"
@@ -17649,6 +18311,10 @@ const TabRoute = ({
17649
18311
  padding = 2,
17650
18312
  paddingX,
17651
18313
  paddingY,
18314
+ paddingLeft,
18315
+ paddingRight,
18316
+ paddingTop,
18317
+ paddingBottom,
17652
18318
  alignX,
17653
18319
  alignY,
17654
18320
  ...props
@@ -17675,6 +18341,10 @@ const TabRoute = ({
17675
18341
  padding: padding,
17676
18342
  paddingX: paddingX,
17677
18343
  paddingY: paddingY,
18344
+ paddingLeft: paddingLeft,
18345
+ paddingRight: paddingRight,
18346
+ paddingTop: paddingTop,
18347
+ paddingBottom: paddingBottom,
17678
18348
  alignX: alignX,
17679
18349
  alignY: alignY,
17680
18350
  children: children
@@ -25548,5 +26218,5 @@ const UserSvg = () => jsx("svg", {
25548
26218
  })
25549
26219
  });
25550
26220
 
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 };
26221
+ 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
26222
  //# sourceMappingURL=jsenv_navi.js.map