@jsenv/navi 0.29.86 → 0.29.88

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.
@@ -2777,6 +2777,11 @@ const stateSignal = (defaultValue, options = {}) => {
2777
2777
 
2778
2778
  const DEBUG$2 =
2779
2779
  typeof process === "object" ? process.env.DEBUG === "true" : false;
2780
+ const debug$3 = (...args) => {
2781
+ if (DEBUG$2) {
2782
+ console.debug(...args);
2783
+ }
2784
+ };
2780
2785
 
2781
2786
  // Base URL management
2782
2787
  let baseFileUrl;
@@ -2820,10 +2825,8 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
2820
2825
 
2821
2826
  // Build pathConnectionMap from path signals
2822
2827
  const pathConnectionMap = new Map();
2823
- const signalSet = new Set();
2824
2828
  for (const connection of pathConnections) {
2825
2829
  pathConnectionMap.set(connection.paramName, connection);
2826
- signalSet.add(connection.signal);
2827
2830
  }
2828
2831
 
2829
2832
  // Build queryConnectionMap directly from searchParams
@@ -2835,7 +2838,6 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
2835
2838
  const { signal, options } = registryEntry;
2836
2839
  const connection = { paramName, signal, paramType: "query", ...options };
2837
2840
  queryConnectionMap.set(paramName, connection);
2838
- signalSet.add(signal);
2839
2841
  }
2840
2842
  }
2841
2843
 
@@ -2847,16 +2849,10 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
2847
2849
  queryConnectionMap,
2848
2850
  });
2849
2851
 
2850
- if (DEBUG$2) {
2851
- console.debug(`[CustomPattern] Created pattern:`, parsedPattern);
2852
- console.debug(`[CustomPattern] Signal connections:`, connections);
2853
- console.debug(`[CustomPattern] Path connections:`, pathConnectionMap.size);
2854
- console.debug(
2855
- `[CustomPattern] Query connections:`,
2856
- queryConnectionMap.size,
2857
- );
2858
- console.debug(`[CustomPattern] SignalSet size:`, signalSet.size);
2859
- }
2852
+ debug$3(`[CustomPattern] Created pattern:`, parsedPattern);
2853
+ debug$3(`[CustomPattern] Signal connections:`, connections);
2854
+ debug$3(`[CustomPattern] Path connections:`, pathConnectionMap.size);
2855
+ debug$3(`[CustomPattern] Query connections:`, queryConnectionMap.size);
2860
2856
 
2861
2857
  const applyOn = (url) => {
2862
2858
  const result = matchUrl(parsedPattern, url, {
@@ -2866,58 +2862,33 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
2866
2862
  patternObj: patternObject,
2867
2863
  });
2868
2864
 
2869
- if (DEBUG$2) {
2870
- console.debug(
2871
- `[CustomPattern] Matching "${url}" against "${cleanPattern}":`,
2872
- result,
2873
- );
2874
- }
2865
+ debug$3(
2866
+ `[CustomPattern] Matching "${url}" against "${cleanPattern}":`,
2867
+ result,
2868
+ );
2875
2869
 
2876
2870
  return result;
2877
2871
  };
2878
2872
 
2879
2873
  const resolveParams = (providedParams = {}) => {
2880
- let resolvedParams = { ...providedParams };
2881
-
2882
- // Process path connections for parameter resolution
2883
- for (const [paramName, connection] of pathConnectionMap) {
2884
- if (paramName in providedParams) {
2885
- // Parameter was explicitly provided - always respect explicit parameters
2886
- continue;
2887
- }
2888
- const signalValue = readSignalForUrlBuild(connection);
2889
- if (signalValue !== undefined) {
2890
- // Parameter was not provided, check signal value
2891
- resolvedParams[paramName] = signalValue;
2892
- }
2893
- }
2874
+ const resolvedParams = { ...providedParams };
2894
2875
 
2895
- // Process query connections for parameter resolution
2896
- for (const [paramName, connection] of queryConnectionMap) {
2876
+ // Signal values for parameters that were not explicitly provided
2877
+ for (const connection of connections) {
2878
+ const { paramName } = connection;
2897
2879
  if (paramName in providedParams) {
2898
2880
  // Parameter was explicitly provided - always respect explicit parameters
2899
2881
  continue;
2900
2882
  }
2901
2883
  const signalValue = readSignalForUrlBuild(connection);
2902
2884
  if (signalValue !== undefined) {
2903
- // Parameter was not provided, check signal value
2904
2885
  resolvedParams[paramName] = signalValue;
2905
2886
  }
2906
2887
  }
2907
2888
 
2908
- // Add defaults for path parameters that are still missing
2909
- for (const [paramName, connection] of pathConnectionMap) {
2910
- if (paramName in resolvedParams) {
2911
- continue;
2912
- }
2913
- const currentDefault = connection.getDefaultValue();
2914
- if (currentDefault !== undefined) {
2915
- resolvedParams[paramName] = currentDefault;
2916
- }
2917
- }
2918
-
2919
- // Add defaults for query parameters that are still missing
2920
- for (const [paramName, connection] of queryConnectionMap) {
2889
+ // Defaults for parameters that are still missing
2890
+ for (const connection of connections) {
2891
+ const { paramName } = connection;
2921
2892
  if (paramName in resolvedParams) {
2922
2893
  continue;
2923
2894
  }
@@ -3035,1754 +3006,623 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
3035
3006
  };
3036
3007
 
3037
3008
  /**
3038
- * Build the most precise URL by using route relationships from pattern registry.
3039
- * Each route is responsible for its own URL generation using its own signals.
3009
+ * Generate-and-verify url building.
3010
+ *
3011
+ * One hard rule: a built url must ROUND-TRIP. Matched back against the
3012
+ * route family and re-resolved (defaults included), it must reproduce
3013
+ * exactly the state it was built from — and it must still match this
3014
+ * route. The verifier makes a wrong url impossible; the candidate order
3015
+ * then decides which of several faithful urls is canonical:
3016
+ *
3017
+ * - highest ancestor first: when everything this route adds over an
3018
+ * ancestor is default values, the ancestor's shorter url is the same
3019
+ * place ("/admin/settings" with default tab IS "/admin")
3020
+ * - the route itself
3021
+ * - descendants, deepest first: a descendant url is used when it carries
3022
+ * custom state that this route's own url cannot encode (a tab the user
3023
+ * selected must survive "/admin" being rebuilt)
3040
3024
  */
3041
3025
 
3042
3026
  /**
3043
- * Helper: Filter out default values from parameters for cleaner URLs
3027
+ * The state a url built by this route must encode, as a Map of
3028
+ * paramName -> { value, connection, segmentIndex, owner, explicit }.
3029
+ * owner is relative to this route: "own" | "ancestor" | "descendant" | "extra".
3044
3030
  *
3045
- * This function removes parameters that match their default values (static or dynamic)
3046
- * while preserving custom values and inherited parameters from ancestor routes.
3047
- * Parameter inheritance from parent routes is intentional - only default values
3048
- * for the current route's own parameters are filtered out.
3031
+ * Sources, in priority order (first write wins):
3032
+ * - explicit params (an explicit undefined pins the param to its default)
3033
+ * - own connection signals (path + query, inherited query included)
3034
+ * - ancestor path params pinned by this pattern's own literals: being on
3035
+ * "/admin/settings" MEANS section=settings, whatever the signal says
3036
+ * - ancestor query signals holding custom values
3037
+ * - reachable descendants' connection signals. A descendant is reachable
3038
+ * when its literals agree with the state collected so far (a tab value
3039
+ * is unreachable while section conflicts with the tab route's literals)
3040
+ * and its extra literals are justified by a param value (building "/"
3041
+ * must not teleport to "/admin").
3042
+ *
3043
+ * Every family signal is read (not only the stored ones) so that a url
3044
+ * computed inside a preact computed() subscribes to all of them.
3049
3045
  */
3050
- const removeDefaultValues = (params) => {
3051
- const filtered = { ...params };
3052
-
3053
- // Process path parameters
3054
- for (const [paramName, connection] of pathConnectionMap) {
3055
- if (paramName in filtered) {
3056
- // Parameter is explicitly provided - check if we should remove it
3057
- const paramValue = filtered[paramName];
3058
-
3059
- if (!connection.isCustomValue(paramValue)) {
3060
- delete filtered[paramName];
3061
- }
3062
- } else {
3063
- // Parameter not provided but signal has a value
3064
- const signalValue = readSignalForUrlBuild(connection);
3065
- if (connection.isCustomValue(signalValue)) {
3066
- // Only include custom values
3067
- filtered[paramName] = signalValue;
3068
- }
3046
+ const buildIntendedState = (explicitParams) => {
3047
+ const intended = new Map();
3048
+ const reachableDescendants = new Set();
3049
+ const setEntry = (
3050
+ name,
3051
+ value,
3052
+ connection,
3053
+ segmentIndex,
3054
+ owner,
3055
+ explicit,
3056
+ pageNaming = false,
3057
+ ) => {
3058
+ if (intended.has(name)) {
3059
+ return;
3069
3060
  }
3070
- }
3071
-
3072
- // Process query parameters
3073
- for (const [paramName, connection] of queryConnectionMap) {
3074
- if (paramName in filtered) {
3075
- // Parameter is explicitly provided - check if we should remove it
3076
- const paramValue = filtered[paramName];
3077
-
3078
- if (!connection.isCustomValue(paramValue)) {
3079
- delete filtered[paramName];
3080
- }
3081
- } else {
3082
- // Parameter not provided but signal has a value
3083
- const signalValue = readSignalForUrlBuild(connection);
3084
- if (connection.isCustomValue(signalValue)) {
3085
- // Only include custom values
3086
- filtered[paramName] = signalValue;
3061
+ intended.set(name, {
3062
+ value,
3063
+ connection,
3064
+ segmentIndex,
3065
+ owner,
3066
+ explicit,
3067
+ pageNaming,
3068
+ });
3069
+ };
3070
+ const paramSegmentIndex = (patternObj, name) => {
3071
+ const seg = patternObj.pattern.segments.find(
3072
+ (s) => s.type === "param" && s.name === name,
3073
+ );
3074
+ return seg ? seg.index : undefined;
3075
+ };
3076
+ const findFamilyConnection = (name) => {
3077
+ const own = pathConnectionMap.get(name) || queryConnectionMap.get(name);
3078
+ if (own) {
3079
+ return {
3080
+ connection: own,
3081
+ segmentIndex: paramSegmentIndex(patternObject, name),
3082
+ owner: "own",
3083
+ };
3084
+ }
3085
+ let ancestor = patternObject.parent;
3086
+ while (ancestor) {
3087
+ const conn =
3088
+ ancestor.pathConnectionMap.get(name) ||
3089
+ ancestor.queryConnectionMap.get(name);
3090
+ if (conn) {
3091
+ return {
3092
+ connection: conn,
3093
+ segmentIndex: paramSegmentIndex(ancestor, name),
3094
+ owner: "ancestor",
3095
+ };
3087
3096
  }
3097
+ ancestor = ancestor.parent;
3088
3098
  }
3089
- }
3090
-
3091
- return filtered;
3092
- };
3093
-
3094
- /**
3095
- * Helper: Check if a literal value can be reached through available parameters
3096
- */
3097
- const canReachLiteralValue = (literalValue, params, literalPosition) => {
3098
- // Check parent's own parameters (signals and user params)
3099
- const parentCanProvide = connections.some((conn) => {
3100
- const signalValue = readSignalForUrlBuild(conn);
3101
- const userValue = params[conn.paramName];
3102
- const effectiveValue = userValue !== undefined ? userValue : signalValue;
3099
+ let found = null;
3100
+ const visit = (patternObj) => {
3101
+ for (const child of patternObj.children) {
3102
+ if (found) {
3103
+ return;
3104
+ }
3105
+ const conn =
3106
+ child.pathConnectionMap.get(name) ||
3107
+ child.queryConnectionMap.get(name);
3108
+ if (conn) {
3109
+ found = {
3110
+ connection: conn,
3111
+ segmentIndex: paramSegmentIndex(child, name),
3112
+ owner: "descendant",
3113
+ };
3114
+ return;
3115
+ }
3116
+ visit(child);
3117
+ }
3118
+ };
3119
+ visit(patternObject);
3103
3120
  return (
3104
- effectiveValue === literalValue && conn.isCustomValue(effectiveValue)
3121
+ found || {
3122
+ connection: undefined,
3123
+ segmentIndex: undefined,
3124
+ owner: "extra",
3125
+ }
3105
3126
  );
3106
- });
3107
- if (parentCanProvide) {
3108
- return true;
3109
- }
3127
+ };
3110
3128
 
3111
- // Check user-provided parameters
3112
- const userCanProvide = Object.entries(params).some(
3113
- ([, value]) => value === literalValue,
3114
- );
3115
- if (userCanProvide) {
3116
- return true;
3129
+ for (const [name, value] of Object.entries(explicitParams)) {
3130
+ const { connection, segmentIndex, owner } = findFamilyConnection(name);
3131
+ setEntry(name, value, connection, segmentIndex, owner, true);
3117
3132
  }
3118
-
3119
- // Check if any descendant path signal provides this literal value AT THE SAME position.
3120
- // A signal from /map/isochrone/:tab can provide a literal at position 2 (tab position),
3121
- // but NOT a literal at position 1 (panel position) — even if the signal value matches.
3122
- // descendantPathSignals is a Map<segmentIndex, conn[]> precomputed during setupPatterns.
3123
- const connsAtPosition =
3124
- patternObject.descendantPathSignals.get(literalPosition);
3125
- if (!connsAtPosition) {
3126
- return false;
3133
+ for (const connection of connections) {
3134
+ const { paramName } = connection;
3135
+ const value = readSignalForUrlBuild(connection);
3136
+ if (value !== undefined) {
3137
+ setEntry(
3138
+ paramName,
3139
+ value,
3140
+ connection,
3141
+ paramSegmentIndex(patternObject, paramName),
3142
+ "own",
3143
+ false,
3144
+ );
3145
+ }
3127
3146
  }
3128
- return connsAtPosition.some((conn) => {
3129
- const signalValue = readSignalForUrlBuild(conn);
3130
- return signalValue === literalValue && conn.isCustomValue(signalValue);
3131
- });
3132
- };
3133
- const checkChildRouteCompatibility = (childPatternObj, params) => {
3134
- const childParams = {};
3135
- let isCompatible = true;
3136
-
3137
- // CRITICAL: Check if parent route can reach all child route's literal segments
3138
- // A route can only optimize to a descendant if there's a viable path through parameters
3139
- // to reach all the descendant's literal segments (e.g., "/" cannot reach "/admin"
3140
- // without a parameter that produces "admin")
3141
- const childLiterals = childPatternObj.pattern.segments.filter(
3142
- (segment) => segment.type === "literal",
3143
- );
3144
- // Check each child literal segment
3145
- for (let i = 0; i < childLiterals.length; i++) {
3146
- const childLiteral = childLiterals[i];
3147
- const childPosition = childLiteral.index;
3148
- const literalValue = childLiteral.value;
3149
-
3150
- // Check what the parent has at this position
3151
- const parentSegmentAtPosition = parsedPattern.segments.find(
3152
- (segment) => segment.index === childPosition,
3153
- );
3154
-
3155
- if (parentSegmentAtPosition) {
3156
- if (parentSegmentAtPosition.type === "literal") {
3157
- // Parent has a literal at this position
3158
- if (parentSegmentAtPosition.value === literalValue) {
3159
- // Same literal - no problem
3160
- continue;
3161
- }
3162
- // Different literal - incompatible
3163
- if (DEBUG$2) {
3164
- console.debug(
3165
- `[${pattern}] INCOMPATIBLE with ${childPatternObj.originalPattern}: conflicting literal "${parentSegmentAtPosition.value}" vs "${literalValue}" at position ${childPosition}`,
3166
- );
3167
- }
3168
- return { isCompatible: false, childParams: {} };
3169
- }
3170
- if (parentSegmentAtPosition.type === "param") {
3171
- // Parent has a parameter at this position - child literal can satisfy this parameter
3172
- // BUT we need to check if the parent's parameter value matches the child's literal
3173
-
3174
- // Find the parent's parameter value from signals or params
3175
- const paramName = parentSegmentAtPosition.name;
3176
- let parentParamValue = params[paramName];
3177
-
3178
- // If not in params, check signals
3179
- if (parentParamValue === undefined) {
3180
- const parentConnection =
3181
- pathConnectionMap.get(paramName) ||
3182
- queryConnectionMap.get(paramName);
3183
- if (parentConnection) {
3184
- parentParamValue = readSignalForUrlBuild(parentConnection);
3185
- }
3186
- }
3187
-
3188
- // If parent has a specific value for this parameter, it must match the child literal
3189
- if (
3190
- parentParamValue !== undefined &&
3191
- parentParamValue !== literalValue
3192
- ) {
3193
- return { isCompatible: false, childParams: {} };
3194
- }
3195
-
3147
+ let ancestor = patternObject.parent;
3148
+ while (ancestor) {
3149
+ for (const seg of ancestor.pattern.segments) {
3150
+ if (seg.type !== "param" || intended.has(seg.name)) {
3151
+ continue;
3152
+ }
3153
+ const conn = ancestor.pathConnectionMap.get(seg.name);
3154
+ if (!conn) {
3196
3155
  continue;
3197
3156
  }
3157
+ const selfSeg = parsedPattern.segments[seg.index];
3158
+ if (selfSeg && selfSeg.type === "literal") {
3159
+ setEntry(seg.name, selfSeg.value, conn, seg.index, "ancestor", false);
3160
+ }
3198
3161
  }
3199
- // Parent doesn't have a segment at this position - child extends beyond parent
3200
- // Check if any available parameter can produce this literal value
3201
- else if (!canReachLiteralValue(literalValue, params, childPosition)) {
3202
- if (DEBUG$2) {
3203
- console.debug(
3204
- `[${pattern}] INCOMPATIBLE with ${childPatternObj.originalPattern}: cannot reach literal segment "${literalValue}" at position ${childPosition} - no viable parameter path`,
3205
- );
3162
+ for (const [name, conn] of ancestor.queryConnectionMap) {
3163
+ if (intended.has(name)) {
3164
+ continue;
3165
+ }
3166
+ const value = readSignalForUrlBuild(conn);
3167
+ if (value !== undefined && conn.isCustomValue(value)) {
3168
+ setEntry(name, value, conn, undefined, "ancestor", false);
3206
3169
  }
3207
- return { isCompatible: false, childParams: {} };
3208
3170
  }
3171
+ ancestor = ancestor.parent;
3209
3172
  }
3210
3173
 
3211
- // Check both parent signals AND user-provided params for child route matching
3212
- const paramsToCheck = [
3213
- ...connections,
3214
- ...Object.entries(params).map(([key, value]) => ({
3215
- paramName: key,
3216
- userValue: value,
3217
- isUserProvided: true,
3218
- })),
3219
- ];
3220
-
3221
- for (const item of paramsToCheck) {
3222
- const result = processParameterForChildRoute(
3223
- item,
3224
- childPatternObj.pattern,
3225
- );
3226
-
3227
- if (DEBUG$2) {
3228
- console.debug(
3229
- `[${pattern}] Processing param '${item.paramName}' (userProvided: ${item.isUserProvided}, value: ${item.isUserProvided ? item.userValue : item.signal?.value}) for child ${childPatternObj.originalPattern}: compatible=${result.isCompatible}, shouldInclude=${result.shouldInclude}`,
3230
- );
3231
- }
3232
-
3233
- if (!result.isCompatible) {
3234
- isCompatible = false;
3235
- if (DEBUG$2) {
3236
- console.debug(
3237
- `[${pattern}] Child ${childPatternObj.originalPattern} INCOMPATIBLE due to param '${item.paramName}'`,
3174
+ const intendedValueAt = (name) => {
3175
+ const entry = intended.get(name);
3176
+ return entry ? entry.value : undefined;
3177
+ };
3178
+ const isDescendantReachable = (descendant) => {
3179
+ const selfSegments = parsedPattern.segments;
3180
+ for (const dSeg of descendant.pattern.segments) {
3181
+ if (dSeg.type !== "literal") {
3182
+ continue;
3183
+ }
3184
+ const selfSeg = selfSegments[dSeg.index];
3185
+ if (selfSeg) {
3186
+ if (selfSeg.type === "literal") {
3187
+ if (selfSeg.value !== dSeg.value) {
3188
+ return false;
3189
+ }
3190
+ continue;
3191
+ }
3192
+ const value = intendedValueAt(selfSeg.name);
3193
+ if (value === undefined || String(value) !== dSeg.value) {
3194
+ return false;
3195
+ }
3196
+ continue;
3197
+ }
3198
+ // beyond this route's own segments: the literal must be justified by
3199
+ // a custom param value at the same position, or by an explicit value
3200
+ const connsAtPosition =
3201
+ patternObject.descendantPathSignals.get(dSeg.index) || [];
3202
+ const justifiedBySignal = connsAtPosition.some((conn) => {
3203
+ const entry = intended.get(conn.paramName);
3204
+ return (
3205
+ entry &&
3206
+ entry.value !== undefined &&
3207
+ conn.isCustomValue(entry.value) &&
3208
+ String(entry.value) === dSeg.value
3238
3209
  );
3210
+ });
3211
+ if (justifiedBySignal) {
3212
+ continue;
3239
3213
  }
3240
- break;
3241
- }
3242
-
3243
- if (result.shouldInclude) {
3244
- childParams[result.paramName] = result.paramValue;
3245
- }
3246
- }
3247
-
3248
- if (DEBUG$2) {
3249
- console.debug(
3250
- `[${pattern}] Final compatibility result for ${childPatternObj.originalPattern}: ${isCompatible}`,
3251
- );
3252
- }
3253
-
3254
- return { isCompatible, childParams };
3255
- };
3256
-
3257
- /**
3258
- * Helper: Process a single parameter for child route compatibility
3259
- */
3260
- const processParameterForChildRoute = (item, childParsedPattern) => {
3261
- let paramName;
3262
- let paramValue;
3263
-
3264
- if (item.isUserProvided) {
3265
- paramName = item.paramName;
3266
- paramValue = item.userValue;
3267
- } else {
3268
- paramName = item.paramName;
3269
- paramValue = readSignalForUrlBuild(item);
3270
- // Only include custom parent signal values (not using defaults)
3271
- if (paramValue === undefined || !item.isCustomValue(paramValue)) {
3272
- return { isCompatible: true, shouldInclude: false };
3273
- }
3274
- }
3275
-
3276
- // Check if parameter value matches a literal segment in child pattern
3277
- const matchesChildLiteral = paramMatchesChildLiteral(
3278
- paramValue,
3279
- childParsedPattern,
3280
- );
3281
- if (matchesChildLiteral) {
3282
- // Compatible - parameter value matches child literal
3283
- return {
3284
- isCompatible: true,
3285
- shouldInclude: !item.isUserProvided,
3286
- paramName,
3287
- paramValue,
3288
- };
3289
- }
3290
-
3291
- // ROBUST FIX: For path parameters, check semantic compatibility by verifying
3292
- // that parent parameter values can actually produce the child route structure
3293
- const isParentPathParam = pathConnectionMap.has(paramName);
3294
- if (isParentPathParam) {
3295
- // Check if parent parameter value matches any child literal where it should
3296
- // The key insight: if parent has a specific parameter value, child route must
3297
- // be reachable with that value or they're incompatible
3298
- const parameterCanReachChild = canParameterReachChildRoute(
3299
- paramName,
3300
- paramValue,
3301
- parsedPattern,
3302
- childParsedPattern,
3303
- );
3304
-
3305
- if (!parameterCanReachChild) {
3306
- return { isCompatible: false };
3307
- }
3308
- }
3309
-
3310
- // Check if this is a query parameter in the parent pattern
3311
- const isParentQueryParam = queryConnectionMap.has(paramName);
3312
- if (isParentQueryParam) {
3313
- // Query parameters are always compatible and can be inherited by child routes
3314
- return {
3315
- isCompatible: true,
3316
- shouldInclude: !item.isUserProvided && !matchesChildLiteral,
3317
- paramName,
3318
- paramValue,
3319
- };
3320
- }
3321
-
3322
- // Check for generic parameter-literal conflicts (only for path parameters)
3323
- if (!matchesChildLiteral) {
3324
- // Check if this is a path parameter from parent pattern
3325
- const isParentPathParam = pathConnectionMap.has(paramName);
3326
- if (isParentPathParam) {
3327
- // Parameter value (from user or signal) doesn't match this child's literals
3328
- // Check if child has any literal segments that would conflict with this parameter
3329
- const hasConflictingLiteral = childParsedPattern.segments.some(
3330
- (segment) =>
3331
- segment.type === "literal" && segment.value !== paramValue,
3214
+ const justifiedByExplicit = [...intended.values()].some(
3215
+ (entry) => entry.explicit && String(entry.value) === dSeg.value,
3332
3216
  );
3333
- if (hasConflictingLiteral) {
3334
- return { isCompatible: false };
3217
+ if (!justifiedByExplicit) {
3218
+ return false;
3335
3219
  }
3336
3220
  }
3337
- }
3338
-
3339
- // Compatible but should only include if from signal (not user-provided)
3340
- return {
3341
- isCompatible: true,
3342
- shouldInclude: !item.isUserProvided && !matchesChildLiteral,
3343
- paramName,
3344
- paramValue,
3221
+ return true;
3345
3222
  };
3346
- };
3347
-
3348
- /**
3349
- * Helper: Determine if child route should be used based on active parameters
3350
- */
3351
- const shouldUseChildRoute = (
3352
- childPatternObj,
3353
- params,
3354
- compatibility,
3355
- resolvedParams,
3356
- ) => {
3357
- // CRITICAL: Check if user explicitly passed undefined for parameters that would
3358
- // normally be used to select this child route via sibling route relationships
3359
- for (const [paramName, paramValue] of Object.entries(params)) {
3360
- if (paramValue !== undefined) {
3361
- continue;
3362
- }
3363
-
3364
- // Look for sibling routes (other children of the same parent) that use this parameter
3365
- const siblingPatternObjs = patternObject.children;
3366
- for (const siblingPatternObj of siblingPatternObjs) {
3367
- if (siblingPatternObj === childPatternObj) continue; // Skip self
3368
-
3369
- // Check if sibling route uses this parameter and get the connection
3370
- const siblingConnection =
3371
- siblingPatternObj.pathConnectionMap.get(paramName) ||
3372
- siblingPatternObj.queryConnectionMap.get(paramName);
3373
- if (!siblingConnection) {
3223
+ // Query values are inherited under a LOOSER rule than path descent: the
3224
+ // descendant's extra literals only need a param position to exist there,
3225
+ // not a param value naming them. Leaving a sub screen keeps its query
3226
+ // prefs in the url; entering it by literal requires a value that says so.
3227
+ const isDescendantQueryReachable = (descendant) => {
3228
+ const selfSegments = parsedPattern.segments;
3229
+ for (const dSeg of descendant.pattern.segments) {
3230
+ if (dSeg.type !== "literal") {
3374
3231
  continue;
3375
3232
  }
3376
- const siblingSignalValue = readSignalForUrlBuild(siblingConnection);
3377
- if (siblingSignalValue === undefined) {
3233
+ const selfSeg = selfSegments[dSeg.index];
3234
+ if (selfSeg) {
3235
+ if (selfSeg.type === "literal") {
3236
+ if (selfSeg.value !== dSeg.value) {
3237
+ return false;
3238
+ }
3239
+ continue;
3240
+ }
3241
+ const entry = intended.get(selfSeg.name);
3242
+ const conn = pathConnectionMap.get(selfSeg.name);
3243
+ const value =
3244
+ entry && entry.value !== undefined
3245
+ ? entry.value
3246
+ : conn
3247
+ ? conn.getDefaultValue()
3248
+ : undefined;
3249
+ if (String(value) !== dSeg.value) {
3250
+ return false;
3251
+ }
3378
3252
  continue;
3379
3253
  }
3380
- // Check if this child route has a literal that matches the signal value
3381
- const signalMatchesThisChildLiteral =
3382
- childPatternObj.pattern.segments.some(
3383
- (segment) =>
3384
- segment.type === "literal" &&
3385
- segment.value === siblingSignalValue,
3386
- );
3387
- if (signalMatchesThisChildLiteral) {
3388
- // This child route's literal matches the sibling's signal value
3389
- // User passed undefined to override that signal - don't use this child route
3390
- if (DEBUG$2) {
3391
- console.debug(
3392
- `[${pattern}] Blocking child route ${childPatternObj.originalPattern} because ${paramName}:undefined overrides sibling signal value "${siblingSignalValue}"`,
3393
- );
3394
- }
3254
+ const connsAtPosition = patternObject.descendantPathSignals.get(
3255
+ dSeg.index,
3256
+ );
3257
+ if (!connsAtPosition || connsAtPosition.length === 0) {
3395
3258
  return false;
3396
3259
  }
3397
3260
  }
3398
- }
3399
-
3400
- // CRITICAL: Block child routes that have literal segments requiring specific parameter values
3401
- // that aren't available. Only check literal segments that replace parameter positions.
3402
- // Example: /map/flow/ replaces /:panel/ with "flow", so panel must equal "flow"
3403
- let hasIncompatibleLiterals = false;
3404
- let hasMatchingNonDefaultLiterals = false;
3405
-
3406
- for (let i = 0; i < childPatternObj.pattern.segments.length; i++) {
3407
- const childSegment = childPatternObj.pattern.segments[i];
3408
- const parentSegment = parsedPattern.segments[i];
3409
-
3410
- if (
3411
- childSegment.type === "literal" &&
3412
- parentSegment &&
3413
- parentSegment.type === "param"
3414
- ) {
3415
- // This literal segment replaces a parameter in the parent
3416
- const paramName = parentSegment.name;
3417
- const explicitValue = params[paramName];
3418
- const connection =
3419
- pathConnectionMap.get(paramName) || queryConnectionMap.get(paramName);
3420
- const signalValue = readSignalForUrlBuild(connection);
3421
-
3422
- // Check if the parameter has the required value
3423
- if (
3424
- explicitValue !== childSegment.value &&
3425
- signalValue !== childSegment.value
3426
- ) {
3427
- hasIncompatibleLiterals = true;
3428
- if (DEBUG$2) {
3429
- console.debug(
3430
- `[${pattern}] Blocking child route ${childPatternObj.originalPattern} because parameter "${paramName}" must be "${childSegment.value}" but current values are explicit="${explicitValue}" signal="${signalValue}"`,
3261
+ return true;
3262
+ };
3263
+ const visitDescendants = (patternObj) => {
3264
+ for (const child of patternObj.children) {
3265
+ const reachable = isDescendantReachable(child);
3266
+ if (reachable) {
3267
+ reachableDescendants.add(child);
3268
+ }
3269
+ const queryReachable = reachable || isDescendantQueryReachable(child);
3270
+ for (const conn of child.connections) {
3271
+ if (conn.inherited) {
3272
+ continue;
3273
+ }
3274
+ const { paramName } = conn;
3275
+ // read even when the value is not kept: subscribes the caller
3276
+ const value = readSignalForUrlBuild(conn);
3277
+ if (intended.has(paramName)) {
3278
+ continue;
3279
+ }
3280
+ if (conn.paramType === "path") {
3281
+ if (!reachable || value === undefined) {
3282
+ continue;
3283
+ }
3284
+ // A param whose other values are declared as literal routes and
3285
+ // which has a default NAMES pages: this route's url stays the
3286
+ // url of the default value, it does not follow the signal. The
3287
+ // value still travels along when something else picks the page
3288
+ // (pageNaming entries build urls but neither trigger descent nor
3289
+ // fail verification).
3290
+ const pageNaming =
3291
+ conn.namedByLiteralRoutes &&
3292
+ conn.getDefaultValue() !== undefined &&
3293
+ !pathConnectionMap.has(paramName);
3294
+ setEntry(
3295
+ paramName,
3296
+ value,
3297
+ conn,
3298
+ paramSegmentIndex(child, paramName),
3299
+ "descendant",
3300
+ false,
3301
+ pageNaming,
3431
3302
  );
3303
+ continue;
3432
3304
  }
3433
- break;
3434
- }
3435
-
3436
- // Check if this matching literal represents a non-default parameter value
3437
- // (for forcing child route selection later)
3438
- if (explicitValue === childSegment.value && connection) {
3439
- const defaultValue = connection.getDefaultValue();
3440
- if (explicitValue !== defaultValue) {
3441
- hasMatchingNonDefaultLiterals = true;
3305
+ if (!queryReachable || value === undefined) {
3306
+ continue;
3307
+ }
3308
+ if (conn.isCustomValue(value)) {
3309
+ setEntry(paramName, value, conn, undefined, "descendant", false);
3442
3310
  }
3443
3311
  }
3312
+ visitDescendants(child);
3444
3313
  }
3445
- }
3314
+ };
3315
+ visitDescendants(patternObject);
3446
3316
 
3447
- // Block incompatible child routes immediately
3448
- if (hasIncompatibleLiterals) {
3449
- return false;
3450
- }
3317
+ return { intended, reachableDescendants };
3318
+ };
3451
3319
 
3452
- // Descending into a child path param means "this URL keeps the value you
3453
- // are on" right for a param that QUALIFIES a position (a tab, a mode),
3454
- // wrong for one that NAMES a page. Two things must both hold for it to be
3455
- // a name:
3456
- //
3457
- // - literal routes are declared for its other values ("/games/me/done").
3458
- // Declaring them is the developer saying these values are places; a tab
3459
- // nobody named a route after stays a qualifier;
3460
- // - it has a default value, which makes THIS url the url of that default:
3461
- // "/games/me" IS section=a-venir. Descending would leave the default
3462
- // unaddressable — two states, one url. Without a default ("/map" is not
3463
- // a panel, it is the absence of one) this url means nothing yet and
3464
- // stays free to remember where you were.
3465
- const thisUrlAlreadyMeansAParamValue = (connection) => {
3466
- if (connection.paramType !== "path") {
3467
- return false;
3468
- }
3469
- if (pathConnectionMap.has(connection.paramName)) {
3470
- return false; // we carry that param ourselves, we are not its default
3320
+ /**
3321
+ * The natural url of one candidate route for the intended state.
3322
+ * Values are placed where they belong: path params in the path, query
3323
+ * connections and explicit extras in the query string. A meaningful path
3324
+ * value the candidate can express neither as a param nor as a matching
3325
+ * literal disqualifies it except an explicit value for an ancestor path
3326
+ * param, which travels as a search param ("/admin/settings?section=toto").
3327
+ */
3328
+ const buildCandidateUrl = (
3329
+ candidate,
3330
+ intended,
3331
+ { dropMissing, lenient } = {},
3332
+ ) => {
3333
+ const urlParams = {};
3334
+ const candidateSegments = candidate.pattern.segments;
3335
+ for (const seg of candidateSegments) {
3336
+ if (seg.type !== "param") {
3337
+ continue;
3471
3338
  }
3472
- if (!connection.namedByLiteralRoutes) {
3473
- return false;
3339
+ const entry = intended.get(seg.name);
3340
+ if (!entry || entry.value === undefined) {
3341
+ continue;
3474
3342
  }
3475
- return connection.getDefaultValue() !== undefined;
3476
- };
3477
-
3478
- // Check if child has active non-default signal values
3479
- let hasActiveParams = false;
3480
- const childParams = { ...compatibility.childParams };
3481
-
3482
- for (const [paramName, connection] of new Map([
3483
- ...childPatternObj.pathConnectionMap,
3484
- ...childPatternObj.queryConnectionMap,
3485
- ])) {
3486
- // Check if parameter was explicitly provided by user
3487
- const hasExplicitParam = paramName in params;
3488
- const explicitValue = params[paramName];
3489
-
3490
- if (hasExplicitParam) {
3491
- // User explicitly provided this parameter - use their value
3492
- childParams[paramName] = explicitValue;
3493
- if (
3494
- explicitValue !== undefined &&
3495
- connection.isCustomValue(explicitValue)
3496
- ) {
3497
- hasActiveParams = true;
3498
- }
3499
- } else {
3500
- const signalValue = readSignalForUrlBuild(connection);
3501
- if (signalValue !== undefined) {
3502
- // No explicit override - use signal value
3503
- childParams[paramName] = signalValue;
3504
- if (
3505
- connection.isCustomValue(signalValue) &&
3506
- !thisUrlAlreadyMeansAParamValue(connection)
3507
- ) {
3508
- hasActiveParams = true;
3509
- }
3510
- }
3343
+ const meaningful = entry.connection
3344
+ ? entry.connection.isCustomValue(entry.value)
3345
+ : true;
3346
+ if (meaningful) {
3347
+ urlParams[seg.name] = entry.value;
3511
3348
  }
3512
3349
  }
3513
-
3514
- // Check if child pattern can be fully satisfied
3515
- const initialMergedParams = { ...childParams, ...params };
3516
- const canBuildChildCompletely = childPatternObj.pattern.segments.every(
3517
- (segment) => {
3518
- if (segment.type === "literal") return true;
3519
- if (segment.type === "param") {
3520
- return (
3521
- segment.optional || initialMergedParams[segment.name] !== undefined
3522
- );
3350
+ for (const [name, entry] of intended) {
3351
+ if (name in urlParams) {
3352
+ continue;
3353
+ }
3354
+ if (entry.value === undefined) {
3355
+ continue;
3356
+ }
3357
+ const conn = entry.connection;
3358
+ if (!conn) {
3359
+ if (entry.explicit) {
3360
+ urlParams[name] = entry.value;
3523
3361
  }
3524
- return true;
3525
- },
3526
- );
3527
-
3528
- // Count only non-undefined provided parameters that are NOT default values
3529
- const nonDefaultParams = Object.entries(params).filter(
3530
- ([paramName, value]) => {
3531
- if (value === undefined) return false;
3532
-
3533
- // Check if this parameter has a default value in child's connections
3534
- const childConnection =
3535
- childPatternObj.pathConnectionMap.get(paramName) ||
3536
- childPatternObj.queryConnectionMap.get(paramName);
3537
- if (childConnection) {
3538
- const childDefault = childConnection.getDefaultValue();
3539
- return value !== childDefault;
3540
- }
3541
-
3542
- // Check if this parameter has a default value in parent's connections (current pattern)
3543
- const parentConnection =
3544
- pathConnectionMap.get(paramName) || queryConnectionMap.get(paramName);
3545
- if (parentConnection) {
3546
- const parentDefault = parentConnection.getDefaultValue();
3547
- return value !== parentDefault;
3548
- }
3549
-
3550
- return true; // Non-connection parameters are considered non-default
3551
- },
3552
- );
3553
-
3554
- const hasNonDefaultProvidedParams = nonDefaultParams.length > 0;
3555
-
3556
- // Use child route if:
3557
- // 1. Child has active non-default parameters, OR
3558
- // 2. User provided non-default params AND child can be built completely, OR
3559
- // 3. User provided params that match child literal segments AND are non-default values
3560
- // EXCEPT: Don't use child if parent can produce cleaner URL by omitting defaults
3561
- let shouldUse =
3562
- hasActiveParams ||
3563
- (hasNonDefaultProvidedParams && canBuildChildCompletely) ||
3564
- (hasMatchingNonDefaultLiterals && canBuildChildCompletely);
3565
-
3566
- if (DEBUG$2) {
3567
- console.debug(
3568
- `[${pattern}] shouldUseChildRoute decision for ${childPatternObj.originalPattern}:`,
3569
- {
3570
- hasActiveParams,
3571
- hasNonDefaultProvidedParams,
3572
- canBuildChildCompletely,
3573
- shouldUse,
3574
- },
3575
- );
3576
- }
3577
-
3578
- // Optimization: Check if child would include literal segments that represent default values
3579
- if (shouldUse) {
3580
- // Check if child pattern has literal segments that correspond to default parameter values
3581
- const childLiterals = childPatternObj.pattern.segments
3582
- .filter((seg) => seg.type === "literal")
3583
- .map((seg) => seg.value);
3584
-
3585
- const parentLiterals = parsedPattern.segments
3586
- .filter((seg) => seg.type === "literal")
3587
- .map((seg) => seg.value);
3588
-
3589
- // If child has more literal segments than parent, check if the extra ones are defaults
3590
- if (childLiterals.length > parentLiterals.length) {
3591
- const extraLiterals = childLiterals.slice(parentLiterals.length);
3592
-
3593
- // Check if any extra literal matches a default parameter value
3594
- // BUT only skip if user didn't explicitly provide that parameter AND
3595
- // both conditions are true:
3596
- // 1. The parameters that would cause us to use this child route are defaults
3597
- // 2. The child route doesn't have non-default parameters that would be lost
3598
- let childSpecificParamsAreDefaults = true;
3599
-
3600
- // Check if parameters that determine child selection are non-default
3601
- // OR if any descendant parameters indicate explicit navigation
3602
- for (const [paramName, connection] of new Map([
3603
- ...pathConnectionMap,
3604
- ...queryConnectionMap,
3605
- ])) {
3606
- const currentDefault = connection.getDefaultValue(); // Use current dynamic default
3607
- const resolvedValue = resolvedParams[paramName];
3608
- const userProvidedParam = paramName in params;
3609
-
3610
- if (extraLiterals.includes(currentDefault)) {
3611
- // This literal corresponds to a parameter in the parent
3612
- if (
3613
- userProvidedParam ||
3614
- (resolvedValue !== undefined &&
3615
- connection.isCustomValue(resolvedValue))
3616
- ) {
3617
- // Parameter was explicitly provided or has custom value - child is needed
3618
- childSpecificParamsAreDefaults = false;
3619
- break;
3620
- }
3621
- }
3622
- }
3623
-
3624
- // Additional check: if child route has path parameters that are non-default,
3625
- // this indicates explicit navigation even if structural parameters happen to be default
3626
- // (Query parameters don't count as they don't indicate structural navigation)
3627
- if (childSpecificParamsAreDefaults) {
3628
- for (const childConnection of childPatternObj.connections) {
3629
- const childParamName = childConnection.paramName;
3630
- const childDefaultValue = childConnection.getDefaultValue();
3631
- const childResolvedValue = resolvedParams[childParamName];
3632
-
3633
- // Only consider path parameters, not query parameters
3634
- const isPathParam = childPatternObj.pattern.segments.some(
3635
- (seg) => seg.type === "param" && seg.name === childParamName,
3636
- );
3637
-
3638
- if (
3639
- isPathParam &&
3640
- childResolvedValue !== undefined &&
3641
- childResolvedValue !== childDefaultValue
3642
- ) {
3643
- // Child has non-default path parameters, indicating explicit navigation
3644
- childSpecificParamsAreDefaults = false;
3645
- if (DEBUG$2) {
3646
- console.debug(
3647
- `[${pattern}] Child has non-default path parameter '${childParamName}=${childResolvedValue}' (default: ${childDefaultValue}) - indicates explicit navigation`,
3648
- );
3649
- }
3650
- break;
3651
- }
3652
- }
3653
- }
3654
-
3655
- // When structural parameters (those that determine child selection) are defaults,
3656
- // prefer parent route ONLY if child doesn't have any non-default parameters
3657
- if (childSpecificParamsAreDefaults && !hasActiveParams) {
3658
- for (const [paramName, connection] of new Map([
3659
- ...pathConnectionMap,
3660
- ...queryConnectionMap,
3661
- ])) {
3662
- const currentDefault = connection.getDefaultValue(); // Use current dynamic default
3663
- const userProvidedParam = paramName in params;
3664
-
3665
- if (extraLiterals.includes(currentDefault) && !userProvidedParam) {
3666
- // This child includes a literal that represents a default value
3667
- // AND user didn't explicitly provide this parameter
3668
- // When structural parameters are defaults, prefer parent for cleaner URL
3669
- shouldUse = false;
3670
- if (DEBUG$2) {
3671
- console.debug(
3672
- `[${pattern}] Preferring parent over child - child includes default literal '${currentDefault}' for param '${paramName}' (structural parameter is default and no active params)`,
3673
- );
3674
- }
3675
- break;
3676
- }
3677
- }
3678
- } else if (DEBUG$2) {
3679
- console.debug(
3680
- `[${pattern}] Using child route - parameters that determine child selection are non-default or child has active params`,
3681
- );
3682
- }
3683
- }
3684
- }
3685
-
3686
- if (DEBUG$2 && shouldUse) {
3687
- console.debug(
3688
- `[${pattern}] Will use child route ${childPatternObj.originalPattern}`,
3689
- );
3690
- }
3691
-
3692
- return shouldUse;
3693
- };
3694
-
3695
- /**
3696
- * Helper: Build URL for selected child route with proper parameter filtering
3697
- */
3698
- const buildChildRouteUrl = (
3699
- childPatternObj,
3700
- params,
3701
- parentResolvedParams = {},
3702
- ) => {
3703
- // Start with child signal values
3704
- const baseParams = {};
3705
- for (const [paramName, connection] of new Map([
3706
- ...childPatternObj.pathConnectionMap,
3707
- ...childPatternObj.queryConnectionMap,
3708
- ])) {
3709
- // Check if parameter was explicitly provided by user
3710
- const hasExplicitParam = paramName in params;
3711
- const explicitValue = params[paramName];
3712
-
3713
- if (hasExplicitParam) {
3714
- // User explicitly provided this parameter - use their value (even if undefined)
3715
- if (explicitValue !== undefined) {
3716
- baseParams[paramName] = explicitValue;
3717
- }
3718
- // If explicitly undefined, don't include it (which means don't use child route)
3719
- } else {
3720
- const signalValue = readSignalForUrlBuild(connection);
3721
- if (
3722
- signalValue !== undefined &&
3723
- connection.isCustomValue(signalValue)
3724
- ) {
3725
- // No explicit override - use signal value if non-default
3726
- baseParams[paramName] = signalValue;
3727
- }
3728
- }
3729
- }
3730
-
3731
- // Collect parameters from ALL ancestor routes in the hierarchy (not just immediate parent)
3732
- const collectAncestorParameters = (currentPatternObj) => {
3733
- if (!currentPatternObj?.parent) {
3734
- return; // No more ancestors
3735
- }
3736
-
3737
- const parentPatternObj = currentPatternObj.parent;
3738
-
3739
- // Add parent's signal parameters (query params only, not path params)
3740
- // Path params from ancestors are structural path segments, not inheritable
3741
- for (const connection of parentPatternObj.connections) {
3742
- if (connection.paramType === "path") {
3743
- continue;
3744
- }
3745
- const { paramName } = connection;
3746
-
3747
- // Skip if child route already handles this parameter
3748
- if (
3749
- childPatternObj.pathConnectionMap.has(paramName) ||
3750
- childPatternObj.queryConnectionMap.has(paramName)
3751
- ) {
3752
- continue; // Child route handles this parameter directly
3753
- }
3754
-
3755
- // Skip if parameter is already collected
3756
- if (paramName in baseParams) {
3757
- continue; // Already have this parameter
3758
- }
3759
-
3760
- const signalValue = readSignalForUrlBuild(connection);
3761
- // Only include custom signal values (not using defaults)
3762
- if (
3763
- signalValue !== undefined &&
3764
- connection.isCustomValue(signalValue)
3765
- ) {
3766
- // Skip if parameter is consumed by child's literal path segments
3767
- const isConsumedByChildPath = childPatternObj.pattern.segments.some(
3768
- (segment) =>
3769
- segment.type === "literal" && segment.value === signalValue,
3770
- );
3771
- if (!isConsumedByChildPath) {
3772
- baseParams[paramName] = signalValue;
3773
- }
3774
- }
3775
- }
3776
-
3777
- // Recursively collect from higher ancestors
3778
- collectAncestorParameters(parentPatternObj);
3779
- };
3780
-
3781
- // Start collecting from the child's parent
3782
- collectAncestorParameters(childPatternObj);
3783
-
3784
- // Add parent parameters from the immediate calling context
3785
- for (const [paramName, parentValue] of Object.entries(
3786
- parentResolvedParams,
3787
- )) {
3788
- // Skip if already collected from ancestors or child handles it
3789
- if (paramName in baseParams) {
3790
3362
  continue;
3791
3363
  }
3792
-
3793
- // Skip if child route already handles this parameter
3794
- if (
3795
- childPatternObj.pathConnectionMap.has(paramName) ||
3796
- childPatternObj.queryConnectionMap.has(paramName)
3797
- ) {
3798
- continue; // Child route handles this parameter directly
3799
- }
3800
-
3801
- // Skip if parameter is consumed by child's literal path segments
3802
- const isConsumedByChildPath = childPatternObj.pattern.segments.some(
3803
- (segment) =>
3804
- segment.type === "literal" && segment.value === parentValue,
3805
- );
3806
- if (isConsumedByChildPath) {
3807
- continue; // Parameter is consumed by child's literal path
3808
- }
3809
-
3810
- // Check if parent parameter is at default value
3811
- const parentConnection =
3812
- pathConnectionMap.get(paramName) || queryConnectionMap.get(paramName);
3813
- const parentDefault = parentConnection
3814
- ? parentConnection.getDefaultValue()
3815
- : undefined;
3816
- if (parentValue === parentDefault) {
3817
- continue; // Don't inherit default values
3818
- }
3819
-
3820
- // Inherit this parameter as it's not handled by child and not at default
3821
- baseParams[paramName] = parentValue;
3822
- }
3823
-
3824
- // Apply user params with filtering logic
3825
- for (const [paramName, userValue] of Object.entries(params)) {
3826
- const childConnection =
3827
- childPatternObj.pathConnectionMap.get(paramName) ||
3828
- childPatternObj.queryConnectionMap.get(paramName);
3829
-
3830
- if (childConnection) {
3831
- // Only include if it's a custom value (not default)
3832
- if (childConnection.isCustomValue(userValue)) {
3833
- baseParams[paramName] = userValue;
3834
- } else {
3835
- // User provided the default value - complete omission
3836
- delete baseParams[paramName];
3837
- }
3838
- } else {
3839
- // Check if param corresponds to a literal segment in child pattern
3840
- const isConsumedByChildPath = childPatternObj.pattern.segments.some(
3841
- (segment) =>
3842
- segment.type === "literal" && segment.value === userValue,
3843
- );
3844
-
3845
- if (!isConsumedByChildPath) {
3846
- // Not consumed by child path, keep it as query param
3847
- baseParams[paramName] = userValue;
3848
- }
3849
- }
3850
- }
3851
-
3852
- // Build child URL using buildUrl (not buildMostPreciseUrl) to prevent recursion
3853
- const childUrl = buildUrlFromPattern(
3854
- childPatternObj.pattern,
3855
- baseParams,
3856
- childPatternObj.originalPattern,
3857
- childPatternObj,
3858
- );
3859
-
3860
- if (childUrl && !childUrl.includes(":")) {
3861
- // Check for parent optimization before returning
3862
- const optimizedUrl = checkChildParentOptimization(
3863
- childPatternObj,
3864
- childUrl,
3865
- baseParams,
3866
- );
3867
- return optimizedUrl || childUrl;
3868
- }
3869
-
3870
- return null;
3871
- };
3872
-
3873
- /**
3874
- * Helper: Check if parent route optimization applies to child route
3875
- */
3876
- const checkChildParentOptimization = (
3877
- childPatternObj,
3878
- childUrl,
3879
- baseParams,
3880
- ) => {
3881
- const childParent = childPatternObj.parent;
3882
-
3883
- if (childParent && childParent.originalPattern === pattern) {
3884
- // Check if child path segments correspond to parent's default path parameters
3885
- // If so, we can optimize to use parent's path but preserve child's query parameters
3886
-
3887
- let canOptimizeToParent = true;
3888
- const parentPathDefaults = {};
3889
-
3890
- // Check each segment in child vs parent to see if child literals match parent defaults
3891
- for (
3892
- let i = 0;
3893
- i < childPatternObj.pattern.segments.length &&
3894
- i < parsedPattern.segments.length;
3895
- i++
3896
- ) {
3897
- const childSegment = childPatternObj.pattern.segments[i];
3898
- const parentSegment = parsedPattern.segments[i];
3899
-
3900
- if (
3901
- childSegment.type === "literal" &&
3902
- parentSegment &&
3903
- parentSegment.type === "param"
3904
- ) {
3905
- // Child has literal where parent has parameter - check if literal matches default
3906
- const paramName = parentSegment.name;
3907
- const connection =
3908
- pathConnectionMap.get(paramName) ||
3909
- queryConnectionMap.get(paramName);
3910
-
3911
- if (connection) {
3912
- const defaultValue = connection.getDefaultValue();
3913
- if (childSegment.value === defaultValue) {
3914
- // Child literal matches parent default - this is optimizable
3915
- parentPathDefaults[paramName] = defaultValue;
3916
- } else {
3917
- // Child literal doesn't match parent default - can't optimize
3918
- canOptimizeToParent = false;
3919
- break;
3920
- }
3921
- } else {
3922
- canOptimizeToParent = false;
3923
- break;
3924
- }
3925
- }
3926
- }
3927
-
3928
- if (canOptimizeToParent && Object.keys(parentPathDefaults).length > 0) {
3929
- if (DEBUG$2) {
3930
- console.debug(
3931
- `[${pattern}] checkChildParentOptimization: checking child ${childPatternObj.originalPattern}`,
3932
- { parentPathDefaults, canOptimizeToParent },
3933
- );
3934
- }
3935
-
3936
- // CRITICAL: Check if child route has non-default path parameters
3937
- // If it does, don't optimize away the child route structure
3938
- for (const [
3939
- paramName,
3940
- connection,
3941
- ] of childPatternObj.pathConnectionMap) {
3942
- const signalValue = readSignalForUrlBuild(connection);
3943
- if (
3944
- signalValue !== undefined &&
3945
- connection.isCustomValue(signalValue)
3946
- ) {
3947
- // Child has non-default path parameters - don't optimize away the structure
3948
- if (DEBUG$2) {
3949
- console.debug(
3950
- `[${pattern}] Not optimizing child route because it has non-default path parameter '${paramName}=${signalValue}'`,
3951
- );
3952
- }
3953
- return null;
3954
- }
3955
- }
3956
-
3957
- // Check if child has non-default query parameters that should be preserved
3958
- const nonDefaultQueryParams = {};
3959
-
3960
- for (const [
3961
- paramName,
3962
- connection,
3963
- ] of childPatternObj.queryConnectionMap) {
3964
- const signalValue = readSignalForUrlBuild(connection);
3965
- if (
3966
- signalValue !== undefined &&
3967
- connection.isCustomValue(signalValue)
3968
- ) {
3969
- nonDefaultQueryParams[paramName] = signalValue;
3970
- }
3971
- }
3972
-
3973
- // Also include any query parameters from baseParams
3974
- for (const [paramName, paramValue] of Object.entries(baseParams)) {
3975
- // Check if this parameter is not a path parameter that we're optimizing away
3976
- if (!(paramName in parentPathDefaults)) {
3977
- nonDefaultQueryParams[paramName] = paramValue;
3978
- }
3979
- }
3980
-
3981
- // Build optimized URL using parent path but child's query parameters
3982
- // Always optimize when we can, even if there are no query parameters
3983
- const parentParams = { ...nonDefaultQueryParams };
3984
-
3985
- // Remove default path parameters to get clean parent URL
3986
- for (const defaultParam of Object.keys(parentPathDefaults)) {
3987
- delete parentParams[defaultParam];
3988
- }
3989
-
3990
- const optimizedUrl = buildUrlFromPattern(
3991
- parsedPattern,
3992
- parentParams,
3993
- pattern,
3994
- patternObject,
3995
- );
3996
-
3997
- if (DEBUG$2) {
3998
- console.debug(
3999
- `[${pattern}] Optimizing child route ${childPatternObj.originalPattern} to parent with query params:`,
4000
- { parentPathDefaults, nonDefaultQueryParams, optimizedUrl },
4001
- );
4002
- }
4003
-
4004
- return optimizedUrl;
4005
- }
4006
- }
4007
-
4008
- return null;
4009
- };
4010
-
4011
- const buildMostPreciseUrl = (params = {}) => {
4012
- if (DEBUG$2) {
4013
- console.debug(`[${pattern}] buildMostPreciseUrl called`);
4014
- }
4015
-
4016
- // Use the pattern object's signalSet (updated by setupPatterns)
4017
- const effectiveSignalSet = patternObject.signalSet;
4018
-
4019
- // Access signal.value to trigger dependency tracking
4020
- if (DEBUG$2) {
4021
- console.debug(
4022
- `[${pattern}] Reading ${effectiveSignalSet.size} signals for reactive dependencies`,
4023
- );
4024
- }
4025
- // for (const signal of effectiveSignalSet) {
4026
- // // Access signal.value to trigger dependency tracking
4027
- // // eslint-disable-next-line no-unused-expressions
4028
- // signal.value; // This line is critical for signal reactivity - when commented out, routes may not update properly
4029
- // }
4030
-
4031
- // Step 1: Resolve and clean parameters
4032
- const resolvedParams = resolveParams(params);
4033
-
4034
- // Step 2: Try ancestors first - find the highest ancestor that works
4035
- const parentPattern = patternObject.parent;
4036
-
4037
- if (DEBUG$2 && parentPattern) {
4038
- console.debug(
4039
- `[${pattern}] Available ancestor:`,
4040
- parentPattern.originalPattern,
4041
- );
4042
- }
4043
-
4044
- let bestAncestorUrl = null;
4045
- if (parentPattern) {
4046
- // Skip root route - never use as optimization target
4047
- if (parentPattern.originalPattern !== "/") {
4048
- // Try to use this ancestor and traverse up to find the highest possible
4049
- const highestAncestorUrl = findHighestAncestor(
4050
- parentPattern,
4051
- resolvedParams,
4052
- );
4053
- if (DEBUG$2) {
4054
- console.debug(
4055
- `[${pattern}] Highest ancestor from ${parentPattern.originalPattern}:`,
4056
- highestAncestorUrl,
4057
- );
4058
- }
4059
-
4060
- if (highestAncestorUrl) {
4061
- bestAncestorUrl = highestAncestorUrl;
4062
- }
4063
- }
4064
- }
4065
-
4066
- if (bestAncestorUrl) {
4067
- if (DEBUG$2) {
4068
- console.debug(`[${pattern}] Using ancestor optimization`);
4069
- }
4070
- return bestAncestorUrl;
4071
- }
4072
-
4073
- // Step 3: Remove default values for normal URL building
4074
- let finalParams = removeDefaultValues(resolvedParams);
4075
-
4076
- // Step 4: Try descendants - find the deepest descendant that works
4077
- const childPatternObjs = patternObject.children;
4078
-
4079
- let bestDescendantUrl = null;
4080
- for (const childPatternObj of childPatternObjs) {
4081
- const deepestDescendantUrl = findDeepestDescendant(
4082
- childPatternObj,
4083
- params,
4084
- resolvedParams,
4085
- );
4086
- if (deepestDescendantUrl) {
4087
- // Take the first valid deepest descendant we find (or keep deepest among multiple)
4088
- if (!bestDescendantUrl) {
4089
- bestDescendantUrl = deepestDescendantUrl;
4090
- }
4091
- }
4092
- }
4093
-
4094
- if (bestDescendantUrl) {
4095
- if (DEBUG$2) {
4096
- console.debug(`[${pattern}] Using descendant optimization`);
4097
- }
4098
- return bestDescendantUrl;
4099
- }
4100
- if (DEBUG$2) {
4101
- console.debug(`[${pattern}] No suitable child route found`);
4102
- }
4103
-
4104
- // Step 5: Inherit parameters from parent routes
4105
- inheritParentParameters(finalParams);
4106
-
4107
- // Step 6: Build the current route URL
4108
- const generatedUrl = buildCurrentRouteUrl(finalParams);
4109
-
4110
- return generatedUrl;
4111
- };
4112
-
4113
- /**
4114
- * Helper: Find the highest ancestor by traversing parent chain recursively
4115
- */
4116
- const findHighestAncestor = (startAncestor, resolvedParams) => {
4117
- // Check if we can use this ancestor directly
4118
- const directUrl = tryUseAncestor(startAncestor, resolvedParams);
4119
- if (!directUrl) {
4120
- return null;
4121
- }
4122
-
4123
- // Look for an even higher ancestor by checking the ancestor's parent
4124
- if (startAncestor.parent) {
4125
- const higherAncestor = startAncestor.parent;
4126
-
4127
- // Skip root pattern
4128
- if (higherAncestor.originalPattern === "/") {
4129
- return directUrl;
4130
- }
4131
-
4132
- // Recursively check if we can optimize to an even higher ancestor
4133
- const higherUrl = findHighestAncestor(higherAncestor, resolvedParams);
4134
- if (higherUrl) {
4135
- return higherUrl; // Found a higher ancestor, return that
4136
- }
4137
- }
4138
-
4139
- // No higher ancestor found, return the direct optimization
4140
- return directUrl;
4141
- };
4142
-
4143
- /**
4144
- * Helper: Find the deepest descendant that can be used for this route
4145
- */
4146
- const findDeepestDescendant = (startChild, params, resolvedParams) => {
4147
- // Check if we can use this child directly
4148
- const directUrl = tryUseDescendant(startChild, params, resolvedParams);
4149
- if (!directUrl) {
4150
- return null;
4151
- }
4152
-
4153
- // Now traverse down the child chain to find the deepest possible descendant
4154
- let currentChild = startChild;
4155
- let deepestUrl = directUrl;
4156
-
4157
- while (true) {
4158
- const childChildren = currentChild.children || [];
4159
-
4160
- let foundDeeper = false;
4161
- for (const deeperChild of childChildren) {
4162
- const deeperUrl = tryUseDescendant(deeperChild, params, resolvedParams);
4163
- if (deeperUrl) {
4164
- // Found a deeper descendant that works
4165
- deepestUrl = deeperUrl;
4166
- currentChild = deeperChild;
4167
- foundDeeper = true;
4168
- break;
4169
- }
4170
- }
4171
-
4172
- if (!foundDeeper) {
4173
- break; // No deeper descendant found, we're at the bottom
4174
- }
4175
- }
4176
-
4177
- return deepestUrl;
4178
- };
4179
-
4180
- /**
4181
- * Helper: Check if child route can optimize to parent based on path segment matching
4182
- */
4183
- const canChildOptimizeToParentPath = (
4184
- childPattern,
4185
- parentPattern,
4186
- parentConnections,
4187
- ) => {
4188
- if (!childPattern || !parentPattern) {
4189
- return false;
4190
- }
4191
-
4192
- // Check each segment in child vs parent to see if child literals match parent defaults
4193
- let hasMatchingPathOptimization = false;
4194
- for (
4195
- let i = 0;
4196
- i < childPattern.segments.length && i < parentPattern.segments.length;
4197
- i++
4198
- ) {
4199
- const childSegment = childPattern.segments[i];
4200
- const parentSegment = parentPattern.segments[i];
4201
-
4202
- if (
4203
- childSegment.type === "literal" &&
4204
- parentSegment &&
4205
- parentSegment.type === "param"
4206
- ) {
4207
- // Child has literal where parent has parameter - check if literal matches default
4208
- const paramName = parentSegment.name;
4209
- const connection = parentConnections.find(
4210
- (conn) => conn.paramName === paramName,
4211
- );
4212
-
4213
- if (connection) {
4214
- const defaultValue = connection.getDefaultValue();
4215
- if (childSegment.value === defaultValue) {
4216
- // Child literal matches parent default - this enables path-based optimization
4217
- hasMatchingPathOptimization = true;
4218
- } else {
4219
- // Child literal doesn't match parent default - can't optimize
4220
- return false;
4221
- }
4222
- } else {
4223
- return false;
4224
- }
4225
- }
4226
- }
4227
-
4228
- return hasMatchingPathOptimization;
4229
- };
4230
-
4231
- /**
4232
- * Helper: Try to use an ancestor route (only immediate parent for parameter optimization)
4233
- */
4234
- const tryUseAncestor = (ancestorPatternObj, resolvedParams) => {
4235
- // Check if this ancestor is the immediate parent (for parameter optimization safety)
4236
- const immediateParent = patternObject.parent;
4237
-
4238
- if (
4239
- immediateParent &&
4240
- immediateParent.originalPattern === ancestorPatternObj.originalPattern
4241
- ) {
4242
- // This is the immediate parent - check if we can optimize
4243
- if (DEBUG$2) {
4244
- console.debug(
4245
- `[${pattern}] tryUseAncestor: Trying immediate parent ${ancestorPatternObj.originalPattern}`,
4246
- );
4247
- }
4248
-
4249
- // For immediate parent optimization, check if we can optimize based on path segments
4250
- // Even if query parameters are non-default, we should still optimize if the child's
4251
- // literal path segments correspond to the parent's default path parameter values
4252
- const canOptimizeBasedOnPath = canChildOptimizeToParentPath(
4253
- parsedPattern,
4254
- ancestorPatternObj.pattern,
4255
- ancestorPatternObj.connections,
4256
- );
4257
-
4258
- if (canOptimizeBasedOnPath) {
4259
- // Path-based optimization is possible, but ALSO check if current PATH parameters are defaults
4260
- // Query parameters should not block path-based optimization, only path parameters should
4261
- const hasNonDefaultPathParameters = connections.some((connection) => {
4262
- const resolvedValue = resolvedParams[connection.paramName];
4263
-
4264
- // Only check path parameters, not query parameters
4265
- const isPathParameter = parsedPattern.segments.some(
4266
- (segment) =>
4267
- segment.type === "param" && segment.name === connection.paramName,
4268
- );
4269
-
4270
- return isPathParameter && connection.isCustomValue(resolvedValue);
4271
- });
4272
-
4273
- if (!hasNonDefaultPathParameters) {
4274
- // Child has no non-default path parameters - proceed with optimization
4275
- // Query parameters can be moved to the parent route
4276
- const result = tryDirectOptimization(
4277
- parsedPattern,
4278
- connections,
4279
- ancestorPatternObj,
4280
- resolvedParams,
4281
- );
4282
- if (DEBUG$2) {
4283
- console.debug(
4284
- `[${pattern}] tryUseAncestor: Path-based optimization result:`,
4285
- result,
4286
- );
4287
- }
4288
- return result;
4289
- }
4290
-
4291
- if (DEBUG$2) {
4292
- console.debug(
4293
- `[${pattern}] tryUseAncestor: Path-based optimization blocked - child has non-default path parameters`,
4294
- );
4295
- }
4296
- }
4297
-
4298
- // For other cases, check if current route's OWN parameters have non-default values
4299
- // Only check parameters that belong to this route, not inherited ones
4300
- const hasNonDefaultOwnParameters = connections.some((connection) => {
4301
- // Skip inherited connections - they shouldn't block optimization
4302
- if (connection.inherited) {
4303
- return false;
4304
- }
4305
- const resolvedValue = resolvedParams[connection.paramName];
4306
- return connection.isCustomValue(resolvedValue);
4307
- });
4308
-
4309
- if (hasNonDefaultOwnParameters) {
4310
- if (DEBUG$2) {
4311
- console.debug(
4312
- `[${pattern}] tryUseAncestor: Has non-default own parameters, skipping immediate parent optimization`,
4313
- );
4314
- }
4315
- return null;
4316
- }
4317
-
4318
- // All own parameters have default values - proceed with optimization
4319
- const result = tryDirectOptimization(
4320
- parsedPattern,
4321
- connections,
4322
- ancestorPatternObj,
4323
- resolvedParams,
4324
- );
4325
- if (DEBUG$2) {
4326
- console.debug(
4327
- `[${pattern}] tryUseAncestor: tryDirectOptimization result:`,
4328
- result,
4329
- );
4330
- }
4331
- return result;
4332
- }
4333
-
4334
- // For non-immediate parents, only allow optimization if all own parameters have default values
4335
- const hasNonDefaultOwnParameters = connections.some((connection) => {
4336
- // Skip inherited connections - they shouldn't block optimization
4337
- if (connection.inherited) {
4338
- return false;
4339
- }
4340
- const resolvedValue = resolvedParams[connection.paramName];
4341
- return connection.isCustomValue(resolvedValue);
4342
- });
4343
-
4344
- if (hasNonDefaultOwnParameters) {
4345
- if (DEBUG$2) {
4346
- console.debug(
4347
- `[${pattern}] tryUseAncestor: Non-immediate parent with non-default own parameters, skipping`,
4348
- );
4349
- }
4350
- return null;
4351
- }
4352
-
4353
- // This is not the immediate parent - only allow literal-only optimization
4354
- const hasParameters =
4355
- connections.length > 0 ||
4356
- parsedPattern.segments.some((seg) => seg.type === "param");
4357
-
4358
- if (hasParameters) {
4359
- if (DEBUG$2) {
4360
- console.debug(
4361
- `[${pattern}] tryUseAncestor: Non-immediate parent with parameters, skipping`,
4362
- );
4363
- }
4364
- return null;
4365
- }
4366
-
4367
- // Pure literal route optimization
4368
- // Allow literal routes to optimize to parametric ancestors if literal segments match parameter defaults
4369
- if (DEBUG$2) {
4370
- console.debug(
4371
- `[${pattern}] tryUseAncestor: Trying optimization to ${ancestorPatternObj.originalPattern}`,
4372
- );
4373
- }
4374
-
4375
- const result = tryDirectOptimization(
4376
- parsedPattern,
4377
- connections,
4378
- ancestorPatternObj,
4379
- resolvedParams,
4380
- );
4381
- if (DEBUG$2) {
4382
- console.debug(
4383
- `[${pattern}] tryUseAncestor: tryDirectOptimization result:`,
4384
- result,
4385
- );
4386
- }
4387
- return result;
4388
- };
4389
-
4390
- /**
4391
- * Helper: Check if current literal route can be optimized to target ancestor
4392
- */
4393
- const tryDirectOptimization = (
4394
- sourcePattern,
4395
- sourceConnections,
4396
- targetAncestor,
4397
- resolvedParams,
4398
- ) => {
4399
- const sourceLiterals = sourcePattern.segments
4400
- .filter((seg) => seg.type === "literal")
4401
- .map((seg) => seg.value);
4402
-
4403
- const targetLiterals = targetAncestor.pattern.segments
4404
- .filter((seg) => seg.type === "literal")
4405
- .map((seg) => seg.value);
4406
-
4407
- const targetParams = targetAncestor.pattern.segments.filter(
4408
- (seg) => seg.type === "param",
4409
- );
4410
-
4411
- if (DEBUG$2) {
4412
- console.debug(
4413
- `[${pattern}] tryDirectOptimization: sourceLiterals:`,
4414
- sourceLiterals,
4415
- );
4416
- console.debug(
4417
- `[${pattern}] tryDirectOptimization: targetLiterals:`,
4418
- targetLiterals,
4419
- );
4420
- console.debug(
4421
- `[${pattern}] tryDirectOptimization: targetParams:`,
4422
- targetParams,
4423
- );
4424
- }
4425
-
4426
- // Source must extend target's literal path
4427
- if (sourceLiterals.length <= targetLiterals.length) {
4428
- if (DEBUG$2) {
4429
- console.debug(`[${pattern}] tryDirectOptimization: Source too short`);
4430
- }
4431
- return null;
4432
- }
4433
-
4434
- // Source must start with same literals as target
4435
- for (let i = 0; i < targetLiterals.length; i++) {
4436
- if (sourceLiterals[i] !== targetLiterals[i]) {
4437
- if (DEBUG$2) {
4438
- console.debug(
4439
- `[${pattern}] tryDirectOptimization: Literal mismatch at ${i}`,
4440
- );
4441
- }
4442
- return null;
4443
- }
4444
- }
4445
-
4446
- // For literal-only optimization: if both source and target have only literals AND no parameters,
4447
- // and source extends target, we can optimize directly
4448
- const sourceHasOnlyLiterals =
4449
- sourcePattern.segments.every((seg) => seg.type === "literal") &&
4450
- sourceConnections.length === 0;
4451
-
4452
- const targetHasOnlyLiterals =
4453
- targetAncestor.pattern.segments.every((seg) => seg.type === "literal") &&
4454
- targetAncestor.connections.length === 0;
4455
-
4456
- if (sourceHasOnlyLiterals && targetHasOnlyLiterals) {
4457
- // Two pure literal routes have no parametric relationship — nothing to optimize.
4458
- // /dashboard/section must never collapse to /dashboard.
4459
- if (DEBUG$2) {
4460
- console.debug(
4461
- `[${pattern}] tryDirectOptimization: Both are pure literal-only routes, no optimization possible`,
4462
- );
4463
- }
4464
- return null;
4465
- }
4466
-
4467
- // For parametric optimization: remaining segments must match target's parameter defaults
4468
- const extraSegments = sourceLiterals.slice(targetLiterals.length);
4469
- if (extraSegments.length !== targetParams.length) {
4470
- if (DEBUG$2) {
4471
- console.debug(
4472
- `[${pattern}] tryDirectOptimization: Extra segments ${extraSegments.length} != target params ${targetParams.length}`,
4473
- );
4474
- }
4475
- return null;
4476
- }
4477
-
4478
- for (let i = 0; i < extraSegments.length; i++) {
4479
- const segment = extraSegments[i];
4480
- const param = targetParams[i];
4481
- const connection = targetAncestor.connections.find(
4482
- (conn) => conn.paramName === param.name,
4483
- );
4484
- if (!connection || connection.getDefaultValue() !== segment) {
4485
- if (DEBUG$2) {
4486
- console.debug(
4487
- `[${pattern}] tryDirectOptimization: Parameter default mismatch for ${param.name}`,
4488
- );
4489
- }
4490
- return null;
4491
- }
4492
- }
4493
-
4494
- if (DEBUG$2) {
4495
- console.debug(
4496
- `[${pattern}] tryDirectOptimization: SUCCESS! Returning ancestor URL`,
4497
- );
4498
- console.debug(
4499
- `[${pattern}] tryDirectOptimization: resolvedParams:`,
4500
- resolvedParams,
4501
- );
4502
- }
4503
-
4504
- // Build ancestor URL with inherited parameters that don't conflict with optimization
4505
- const ancestorParams = {};
4506
-
4507
- // First, add extra parameters from the original resolvedParams
4508
- // These are parameters that don't correspond to any pattern segments or query params
4509
- const sourcePatternParamNames = new Set(
4510
- sourceConnections.map((conn) => conn.paramName),
4511
- );
4512
- const sourceQueryParamNames = new Set(
4513
- sourcePattern.queryParams.map((qp) => qp.name),
4514
- );
4515
- const targetPatternParamNames = new Set(
4516
- targetAncestor.connections.map((conn) => conn.paramName),
4517
- );
4518
- const targetQueryParamNames = new Set(
4519
- targetAncestor.pattern.queryParams.map((qp) => qp.name),
4520
- );
4521
-
4522
- for (const [paramName, value] of Object.entries(resolvedParams)) {
4523
- if (DEBUG$2) {
4524
- console.debug(
4525
- `[${pattern}] tryDirectOptimization: Considering param ${paramName}=${value}`,
4526
- );
4527
- }
4528
- // Include parameters that target pattern specifically needs
4529
- if (targetQueryParamNames.has(paramName)) {
4530
- // Only include if the value is not the default value
4531
- const connection =
4532
- targetAncestor.pathConnectionMap.get(paramName) ||
4533
- targetAncestor.queryConnectionMap.get(paramName);
4534
- if (connection && connection.getDefaultValue() !== value) {
4535
- ancestorParams[paramName] = value;
4536
- if (DEBUG$2) {
4537
- console.debug(
4538
- `[${pattern}] tryDirectOptimization: Added target param ${paramName}=${value}`,
4539
- );
4540
- }
4541
- }
4542
- }
4543
- // Include source query parameters (these should be inherited during ancestor optimization)
4544
- else if (sourceQueryParamNames.has(paramName)) {
4545
- // Only include source parameters if they're not default values
4546
- // Default values should still be omitted from URLs to keep them clean
4547
- const connection = sourceConnections.find(
4548
- (conn) => conn.paramName === paramName,
4549
- );
4550
- if (
4551
- connection &&
4552
- value !== undefined &&
4553
- connection.getDefaultValue() !== value
4554
- ) {
4555
- ancestorParams[paramName] = value;
4556
- if (DEBUG$2) {
4557
- console.debug(
4558
- `[${pattern}] tryDirectOptimization: Added source param ${paramName}=${value}`,
4559
- );
4560
- }
4561
- }
4562
- }
4563
- // Include extra parameters that are not part of either pattern (true extra parameters)
4564
- else if (
4565
- !sourcePatternParamNames.has(paramName) &&
4566
- !targetPatternParamNames.has(paramName)
4567
- ) {
4568
- ancestorParams[paramName] = value;
4569
- if (DEBUG$2) {
4570
- console.debug(
4571
- `[${pattern}] tryDirectOptimization: Added extra param ${paramName}=${value}`,
4572
- );
4573
- }
4574
- }
4575
- }
4576
-
4577
- // Also check target ancestor's own signal values for parameters not in resolvedParams
4578
- if (DEBUG$2) {
4579
- console.debug(
4580
- `[${pattern}] tryDirectOptimization: Target ancestor has ${targetAncestor.connections.length} connections`,
4581
- );
4582
- for (const conn of targetAncestor.connections) {
4583
- console.debug(
4584
- `[${pattern}] tryDirectOptimization: Target connection ${conn.paramName}: value=${readSignalForUrlBuild(conn)}, isCustom=${conn.isCustomValue(readSignalForUrlBuild(conn))}`,
4585
- );
4586
- }
4587
- }
4588
-
4589
- for (const connection of targetAncestor.connections) {
4590
- const { paramName } = connection;
4591
- if (paramName in ancestorParams) {
4592
- if (DEBUG$2) {
4593
- console.debug(
4594
- `[${pattern}] tryDirectOptimization: Skipping ${paramName} - already in ancestorParams`,
4595
- );
4596
- }
4597
- continue;
4598
- }
4599
-
4600
- // Only include if not already processed and has custom value (not default)
4601
- const signalValue = readSignalForUrlBuild(connection);
4602
- if (signalValue !== undefined) {
4603
- // Don't include path parameters that correspond to literal segments we're optimizing away
4604
- const targetParam = targetParams.find((p) => p.name === paramName);
4605
- const isPathParam = targetParam !== undefined; // Any param in segments is a path param
4606
- if (isPathParam) {
4607
- // Skip path parameters - we want them to use default values for optimization
4608
- if (DEBUG$2) {
4609
- console.debug(
4610
- `[${pattern}] tryDirectOptimization: Skipping path param ${paramName}=${signalValue} (will use default)`,
4611
- );
4612
- }
4613
- continue;
4614
- }
4615
-
4616
- // For query parameters, only include custom values (not defaults)
4617
- if (connection.isCustomValue(signalValue)) {
4618
- ancestorParams[paramName] = signalValue;
4619
- if (DEBUG$2) {
4620
- console.debug(
4621
- `[${pattern}] tryDirectOptimization: Added target signal param ${paramName}=${signalValue}`,
4622
- );
4623
- }
4624
- } else if (DEBUG$2) {
4625
- console.debug(
4626
- `[${pattern}] tryDirectOptimization: Skipping default value ${paramName}=${signalValue}`,
4627
- );
4628
- }
4629
- } else if (DEBUG$2) {
4630
- console.debug(
4631
- `[${pattern}] tryDirectOptimization: Skipping ${paramName}=${signalValue} - undefined value`,
4632
- );
4633
- }
4634
- }
4635
-
4636
- // Then, get all ancestors starting from the target ancestor's parent (skip the target itself)
4637
- let currentParent = targetAncestor.parent;
4638
-
4639
- while (currentParent) {
4640
- for (const connection of currentParent.connections) {
4641
- const { paramName } = connection;
4642
- if (paramName in ancestorParams) {
4643
- continue;
4644
- }
4645
-
4646
- // Only inherit custom values (not defaults) that we don't already have
4647
- const signalValue = readSignalForUrlBuild(connection);
3364
+ if (!conn.isCustomValue(entry.value)) {
3365
+ continue;
3366
+ }
3367
+ if (conn.paramType === "path") {
3368
+ const literalSeg =
3369
+ entry.segmentIndex === undefined
3370
+ ? undefined
3371
+ : candidateSegments[entry.segmentIndex];
4648
3372
  if (
4649
- signalValue !== undefined &&
4650
- connection.isCustomValue(signalValue)
3373
+ literalSeg &&
3374
+ literalSeg.type === "literal" &&
3375
+ literalSeg.value === String(entry.value)
4651
3376
  ) {
4652
- // Check if this parameter would be redundant with target ancestor's literal segments
4653
- const isRedundant = isParameterRedundantWithLiteralSegments(
4654
- targetAncestor.pattern,
4655
- currentParent.pattern,
4656
- paramName);
4657
-
4658
- if (!isRedundant) {
4659
- ancestorParams[paramName] = signalValue;
3377
+ continue; // the candidate's path itself encodes this value
3378
+ }
3379
+ if (entry.explicit && entry.owner === "ancestor") {
3380
+ urlParams[name] = entry.value;
3381
+ continue;
3382
+ }
3383
+ if (entry.pageNaming) {
3384
+ // deliberately left out: this url does not follow the signal
3385
+ continue;
3386
+ }
3387
+ if (lenient) {
3388
+ if (entry.explicit) {
3389
+ urlParams[name] = entry.value;
4660
3390
  }
3391
+ continue;
4661
3392
  }
3393
+ return null;
4662
3394
  }
4663
-
4664
- // Move up the parent chain
4665
- currentParent = currentParent.parent;
3395
+ urlParams[name] = entry.value;
4666
3396
  }
4667
-
4668
- return buildUrlFromPattern(
4669
- targetAncestor.pattern,
4670
- ancestorParams,
4671
- targetAncestor.originalPattern,
4672
- targetAncestor,
4673
- );
4674
- };
4675
-
4676
- /**
4677
- * Helper: Try to use a descendant route (simple compatibility check)
4678
- */
4679
- const tryUseDescendant = (
4680
- descendantPatternObj,
4681
- params,
4682
- parentResolvedParams,
4683
- ) => {
4684
- // Check basic compatibility
4685
- const compatibility = checkChildRouteCompatibility(
4686
- descendantPatternObj,
4687
- params,
4688
- );
4689
- if (!compatibility.isCompatible) {
4690
- return null;
3397
+ let buildPattern = candidate.pattern;
3398
+ if (dropMissing) {
3399
+ const keptSegments = candidateSegments.filter(
3400
+ (seg) => seg.type !== "param" || seg.name in urlParams,
3401
+ );
3402
+ if (keptSegments.length !== candidateSegments.length) {
3403
+ buildPattern = { ...buildPattern, segments: keptSegments };
3404
+ if (buildPattern.trailingSlash) {
3405
+ buildPattern.trailingSlash = false;
3406
+ }
3407
+ }
4691
3408
  }
4692
-
4693
- // Check if we should use this descendant
4694
- const shouldUse = shouldUseChildRoute(
4695
- descendantPatternObj,
4696
- params,
4697
- compatibility,
4698
- parentResolvedParams,
3409
+ const url = buildUrlFromPattern(
3410
+ buildPattern,
3411
+ urlParams,
3412
+ candidate.originalPattern,
3413
+ candidate,
4699
3414
  );
4700
- if (!shouldUse) {
3415
+ if (url.includes("/:")) {
4701
3416
  return null;
4702
3417
  }
3418
+ return url;
3419
+ };
4703
3420
 
4704
- // Build descendant URL using buildUrl (not buildMostPreciseUrl) to prevent recursion
4705
- return buildChildRouteUrl(
4706
- descendantPatternObj,
4707
- params,
4708
- parentResolvedParams,
4709
- );
3421
+ const urlValueEquals = (intendedValue, reproducedValue) => {
3422
+ let a = intendedValue;
3423
+ if (a && typeof a === "object" && a[rawUrlPartSymbol]) {
3424
+ a = a.value;
3425
+ }
3426
+ if (a instanceof Date) {
3427
+ const yyyy = a.getUTCFullYear();
3428
+ const mm = String(a.getUTCMonth() + 1).padStart(2, "0");
3429
+ const dd = String(a.getUTCDate()).padStart(2, "0");
3430
+ a = `${yyyy}-${mm}-${dd}`;
3431
+ }
3432
+ if (compareTwoJsValues(a, reproducedValue)) {
3433
+ return true;
3434
+ }
3435
+ if (a === true && reproducedValue === "") {
3436
+ // `true` is written as a bare "?flag", which an untyped connection
3437
+ // reads back as an empty string
3438
+ return true;
3439
+ }
3440
+ return String(a) === String(reproducedValue);
4710
3441
  };
4711
3442
 
4712
3443
  /**
4713
- * Helper: Inherit query parameters from parent patterns
3444
+ * Does the url reproduce the intended state?
3445
+ * A connection's reproduced value is what the url gives back on a reload:
3446
+ * the value extracted by a matching family pattern that carries the
3447
+ * connection, or the connection's default when no matching pattern does.
4714
3448
  */
4715
- const inheritParentParameters = (finalParams) => {
4716
- let currentParent = patternObject.parent;
4717
-
4718
- // Traverse up the parent chain to inherit parameters
4719
- while (currentParent) {
4720
- // Check parent's signal connections for non-default values to inherit
4721
- // Only inherit query (search) parameters, not path parameters
4722
- // Path parameters are structural and correspond to specific path segments
4723
- for (const parentConnection of currentParent.connections) {
4724
- if (parentConnection.paramType === "path") {
3449
+ const verifyUrl = (url, intended) => {
3450
+ if (!applyOn(url)) {
3451
+ return false; // whatever this url is, it is not one of OUR urls
3452
+ }
3453
+ const familyPatterns = [];
3454
+ const seen = new Set();
3455
+ const visit = (patternObj) => {
3456
+ if (seen.has(patternObj)) {
3457
+ return;
3458
+ }
3459
+ seen.add(patternObj);
3460
+ familyPatterns.push(patternObj);
3461
+ for (const child of patternObj.children) {
3462
+ visit(child);
3463
+ }
3464
+ };
3465
+ visit(patternObject.familyRoot || patternObject);
3466
+ const matchResultMap = new Map();
3467
+ for (const familyPattern of familyPatterns) {
3468
+ matchResultMap.set(familyPattern, familyPattern.applyOn(url));
3469
+ }
3470
+ for (const [name, entry] of intended) {
3471
+ const conn = entry.connection;
3472
+ if (!conn) {
3473
+ continue; // extra params have no state to lose
3474
+ }
3475
+ if (entry.pageNaming) {
3476
+ continue; // losing it is deliberate (see buildIntendedState)
3477
+ }
3478
+ let reproducedValue;
3479
+ let extracted = false;
3480
+ for (const familyPattern of familyPatterns) {
3481
+ const matchResult = matchResultMap.get(familyPattern);
3482
+ if (!matchResult) {
4725
3483
  continue;
4726
3484
  }
4727
- const { paramName } = parentConnection;
4728
- if (paramName in finalParams) {
4729
- continue; // Already have this parameter
3485
+ const holdsParam =
3486
+ familyPattern.queryConnectionMap.has(name) ||
3487
+ familyPattern.pattern.segments.some(
3488
+ (seg) => seg.type === "param" && seg.name === name,
3489
+ );
3490
+ if (!holdsParam) {
3491
+ continue;
4730
3492
  }
4731
-
4732
- // Only inherit if we don't have this param and parent has custom value (not default)
4733
- const parentSignalValue = readSignalForUrlBuild(parentConnection);
4734
- if (
4735
- parentSignalValue !== undefined &&
4736
- parentConnection.isCustomValue(parentSignalValue)
4737
- ) {
4738
- // Don't inherit if parameter corresponds to a literal in our path
4739
- const shouldInherit = !isParameterRedundantWithLiteralSegments(
4740
- parsedPattern,
4741
- currentParent.pattern,
4742
- paramName);
4743
-
4744
- if (shouldInherit) {
4745
- finalParams[paramName] = parentSignalValue;
4746
- }
3493
+ if (name in matchResult && matchResult[name] !== undefined) {
3494
+ reproducedValue = matchResult[name];
3495
+ extracted = true;
3496
+ break;
4747
3497
  }
4748
3498
  }
4749
- // Move to the next parent up the chain
4750
- currentParent = currentParent.parent;
3499
+ if (!extracted) {
3500
+ reproducedValue = conn.getDefaultValue();
3501
+ }
3502
+ const wantedValue =
3503
+ entry.value === undefined ? conn.getDefaultValue() : entry.value;
3504
+ if (!urlValueEquals(wantedValue, reproducedValue)) {
3505
+ return false;
3506
+ }
4751
3507
  }
3508
+ return true;
4752
3509
  };
4753
3510
 
4754
3511
  /**
4755
- * Helper: Build URL for current route with filtered pattern
3512
+ * When may this route's url collapse to an ancestor's shorter url?
3513
+ * Policy kept from behavior the round-trip check cannot decide alone
3514
+ * (several faithful urls exist, one is canonical):
3515
+ * - immediate parent, when this pattern's literals sitting at the parent's
3516
+ * param positions all spell the params' DEFAULT values ("/admin/settings"
3517
+ * is "/admin" when settings is the default section) — custom query values
3518
+ * ride along, custom own path values forbid it
3519
+ * - immediate parent otherwise: only when none of this route's own
3520
+ * (non-inherited) connections holds a custom value
3521
+ * - higher ancestors: only for a pure-literal route with no connections
4756
3522
  */
4757
- const buildCurrentRouteUrl = (finalParams) => {
4758
- if (!parsedPattern.segments) {
4759
- return "/";
3523
+ const isAncestorCollapseAllowed = (ancestorPattern, intended) => {
3524
+ const entryIsMeaningful = (conn) => {
3525
+ const entry = intended.get(conn.paramName);
3526
+ return (
3527
+ Boolean(entry) && entry.value !== undefined && conn.isCustomValue(entry.value)
3528
+ );
3529
+ };
3530
+ if (ancestorPattern !== patternObject.parent) {
3531
+ return (
3532
+ connections.length === 0 &&
3533
+ parsedPattern.segments.every((seg) => seg.type === "literal")
3534
+ );
3535
+ }
3536
+ let literalsPinDefaults = false;
3537
+ for (const seg of ancestorPattern.pattern.segments) {
3538
+ if (seg.type !== "param") {
3539
+ continue;
3540
+ }
3541
+ const conn = ancestorPattern.pathConnectionMap.get(seg.name);
3542
+ const selfSeg = parsedPattern.segments[seg.index];
3543
+ if (!conn || !selfSeg || selfSeg.type !== "literal") {
3544
+ continue;
3545
+ }
3546
+ if (selfSeg.value !== String(conn.getDefaultValue())) {
3547
+ literalsPinDefaults = false;
3548
+ break;
3549
+ }
3550
+ literalsPinDefaults = true;
3551
+ }
3552
+ if (literalsPinDefaults) {
3553
+ return !connections.some(
3554
+ (conn) =>
3555
+ !conn.inherited &&
3556
+ conn.paramType === "path" &&
3557
+ entryIsMeaningful(conn),
3558
+ );
4760
3559
  }
3560
+ return !connections.some(
3561
+ (conn) => !conn.inherited && entryIsMeaningful(conn),
3562
+ );
3563
+ };
4761
3564
 
4762
- // Filter out parameter segments that don't have values
4763
- const filteredPattern = {
4764
- ...parsedPattern,
4765
- segments: parsedPattern.segments.filter((segment) => {
4766
- if (segment.type === "param") {
4767
- return segment.name in finalParams;
3565
+ const buildMostPreciseUrl = (params = {}) => {
3566
+ const { intended, reachableDescendants } = buildIntendedState(params);
3567
+ // ancestors: start at the immediate parent, climb while urls keep round-
3568
+ // tripping, keep the highest one ("/" is never a target)
3569
+ let ancestorUrl = null;
3570
+ let ancestor = patternObject.parent;
3571
+ while (ancestor && ancestor.originalPattern !== "/") {
3572
+ if (!isAncestorCollapseAllowed(ancestor, intended)) {
3573
+ break;
3574
+ }
3575
+ const url = buildCandidateUrl(ancestor, intended, {
3576
+ dropMissing: true,
3577
+ });
3578
+ if (!url || !verifyUrl(url, intended)) {
3579
+ break;
3580
+ }
3581
+ debug$3(
3582
+ `[${pattern}] ancestor url ${url} (via ${ancestor.originalPattern})`,
3583
+ );
3584
+ ancestorUrl = url;
3585
+ ancestor = ancestor.parent;
3586
+ }
3587
+ if (ancestorUrl) {
3588
+ return ancestorUrl;
3589
+ }
3590
+ const selfUrl = buildCandidateUrl(patternObject, intended, {
3591
+ dropMissing: true,
3592
+ });
3593
+ if (selfUrl && verifyUrl(selfUrl, intended)) {
3594
+ return selfUrl;
3595
+ }
3596
+ // deepest reachable descendant whose url round-trips, walking greedily
3597
+ let current = patternObject;
3598
+ let descendantUrl = null;
3599
+ descend: while (true) {
3600
+ for (const child of current.children) {
3601
+ if (!reachableDescendants.has(child)) {
3602
+ continue;
4768
3603
  }
4769
- return true; // Keep literal segments
4770
- }),
4771
- };
4772
-
4773
- // Remove trailing slash if we filtered out segments
4774
- if (
4775
- filteredPattern.segments.length < parsedPattern.segments.length &&
4776
- parsedPattern.trailingSlash
4777
- ) {
4778
- filteredPattern.trailingSlash = false;
3604
+ const url = buildCandidateUrl(child, intended);
3605
+ if (url && verifyUrl(url, intended)) {
3606
+ debug$3(
3607
+ `[${pattern}] descendant url ${url} (via ${child.originalPattern})`,
3608
+ );
3609
+ descendantUrl = url;
3610
+ current = child;
3611
+ continue descend;
3612
+ }
3613
+ }
3614
+ break;
4779
3615
  }
4780
-
4781
- return buildUrlFromPattern(
4782
- filteredPattern,
4783
- finalParams,
4784
- pattern,
4785
- patternObject,
3616
+ if (descendantUrl) {
3617
+ return descendantUrl;
3618
+ }
3619
+ // no url round-trips (state not fully representable): this route's own
3620
+ // url, dropping what it cannot encode
3621
+ return (
3622
+ buildCandidateUrl(patternObject, intended, {
3623
+ dropMissing: true,
3624
+ lenient: true,
3625
+ }) || "/"
4786
3626
  );
4787
3627
  };
4788
3628
 
@@ -4821,10 +3661,8 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
4821
3661
  // changes must not end the visit this param qualifies.
4822
3662
  const carryOverWeakParams = (currentUrl, params) => {
4823
3663
  let paramsWithWeak = params;
4824
- for (const [paramName, connection] of [
4825
- ...pathConnectionMap,
4826
- ...queryConnectionMap,
4827
- ]) {
3664
+ for (const connection of connections) {
3665
+ const { paramName } = connection;
4828
3666
  if (!connection.weak || paramName in paramsWithWeak) {
4829
3667
  continue;
4830
3668
  }
@@ -4871,20 +3709,18 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
4871
3709
 
4872
3710
  // Pattern object with unified data and methods
4873
3711
  const patternObject = {
4874
- // Pattern data properties (formerly patternData)
4875
3712
  urlPatternRaw: pattern,
4876
3713
  cleanPattern,
4877
3714
  connections,
4878
3715
  pathConnectionMap, // Separate map for path parameters
4879
3716
  queryConnectionMap, // Separate map for query parameters
4880
3717
  parsedPattern,
4881
- signalSet,
4882
3718
  children: [],
4883
3719
  parent: null,
3720
+ familyRoot: null, // Topmost ancestor, computed during setupPatterns
4884
3721
  depth: 0, // Will be calculated after relationships are built
4885
3722
  descendantPathSignals: new Map(), // Precomputed during setupPatterns (Map<segmentIndex, conn[]>)
4886
3723
 
4887
- // Pattern methods (formerly patternObj methods)
4888
3724
  originalPattern: pattern,
4889
3725
  pattern: parsedPattern,
4890
3726
  applyOn,
@@ -4985,55 +3821,6 @@ const detectSignals = (routePattern) => {
4985
3821
  return [updatedPattern, signalConnections];
4986
3822
  };
4987
3823
 
4988
- /**
4989
- * Helper: Check if parameter matches any literal in child pattern
4990
- */
4991
- const paramMatchesChildLiteral = (paramValue, childParsedPattern) => {
4992
- return childParsedPattern.segments.some(
4993
- (segment) => segment.type === "literal" && segment.value === paramValue,
4994
- );
4995
- };
4996
-
4997
- /**
4998
- * Helper: Check if a parent parameter can semantically reach a child route
4999
- * This replaces the fragile position-based matching with semantic verification
5000
- */
5001
- const canParameterReachChildRoute = (
5002
- paramName,
5003
- paramValue,
5004
- parentPattern,
5005
- childPattern,
5006
- ) => {
5007
- // Find the parent parameter segment
5008
- const parentParamSegment = parentPattern.segments.find(
5009
- (segment) => segment.type === "param" && segment.name === paramName,
5010
- );
5011
-
5012
- if (!parentParamSegment) {
5013
- return true; // Not a path parameter, no conflict
5014
- }
5015
-
5016
- // Get parameter's logical path position (not array index)
5017
- const paramPathPosition = parentParamSegment.index;
5018
-
5019
- // Find corresponding child segment at the same logical path position
5020
- const childSegmentAtSamePosition = childPattern.segments.find(
5021
- (segment) => segment.index === paramPathPosition,
5022
- );
5023
-
5024
- if (!childSegmentAtSamePosition) {
5025
- return true; // Child doesn't extend to this position, no conflict
5026
- }
5027
-
5028
- if (childSegmentAtSamePosition.type === "literal") {
5029
- // Child has a literal at this position - parent parameter must match exactly
5030
- return childSegmentAtSamePosition.value === paramValue;
5031
- }
5032
-
5033
- // Child has parameter at same position - compatible
5034
- return true;
5035
- };
5036
-
5037
3824
  /**
5038
3825
  * Parse a route pattern string into structured segments
5039
3826
  */
@@ -5546,9 +4333,6 @@ const extractSearchParams = (urlObj, queryConnectionMap) => {
5546
4333
  return params;
5547
4334
  };
5548
4335
 
5549
- /**
5550
- * Build query parameters respecting hierarchical order from ancestor patterns
5551
- */
5552
4336
  /**
5553
4337
  * Build hierarchical query parameters from pattern hierarchy
5554
4338
  *
@@ -5587,17 +4371,13 @@ const buildHierarchicalQueryParams = (
5587
4371
  }
5588
4372
  }
5589
4373
 
5590
- // DEBUG: Log what we found
5591
- if (DEBUG$2) {
5592
- // Force debug for now
5593
- console.debug(`Building params for ${originalPattern}`);
5594
- console.debug(`parsedPattern:`, parsedPattern.original);
5595
- console.debug(`params:`, params);
5596
- console.debug(
5597
- `ancestorPatterns:`,
5598
- ancestorPatterns.map((p) => p.urlPatternRaw),
5599
- );
5600
- }
4374
+ debug$3(`Building params for ${originalPattern}`);
4375
+ debug$3(`parsedPattern:`, parsedPattern.original);
4376
+ debug$3(`params:`, params);
4377
+ debug$3(
4378
+ `ancestorPatterns:`,
4379
+ ancestorPatterns.map((p) => p.urlPatternRaw),
4380
+ );
5601
4381
 
5602
4382
  // Step 1: Add query parameters from ancestor patterns (oldest to newest)
5603
4383
  // This ensures ancestor parameters come first in their declaration order
@@ -5610,23 +4390,17 @@ const buildHierarchicalQueryParams = (
5610
4390
  queryParams[paramName] = params[paramName];
5611
4391
  processedParams.add(paramName);
5612
4392
 
5613
- if (DEBUG$2) {
5614
- console.debug(
5615
- `Added ancestor param: ${paramName}=${params[paramName]}`,
5616
- );
5617
- }
4393
+ debug$3(`Added ancestor param: ${paramName}=${params[paramName]}`);
5618
4394
  }
5619
4395
  }
5620
4396
  }
5621
4397
 
5622
4398
  // Step 2: Add query parameters from current pattern
5623
4399
  if (parsedPattern.queryParams) {
5624
- if (DEBUG$2) {
5625
- console.debug(
5626
- `Processing current pattern query params:`,
5627
- parsedPattern.queryParams.map((q) => q.name),
5628
- );
5629
- }
4400
+ debug$3(
4401
+ `Processing current pattern query params:`,
4402
+ parsedPattern.queryParams.map((q) => q.name),
4403
+ );
5630
4404
 
5631
4405
  for (const queryParam of parsedPattern.queryParams) {
5632
4406
  const paramName = queryParam.name;
@@ -5634,11 +4408,7 @@ const buildHierarchicalQueryParams = (
5634
4408
  queryParams[paramName] = params[paramName];
5635
4409
  processedParams.add(paramName);
5636
4410
 
5637
- if (DEBUG$2) {
5638
- console.debug(
5639
- `Added current param: ${paramName}=${params[paramName]}`,
5640
- );
5641
- }
4411
+ debug$3(`Added current param: ${paramName}=${params[paramName]}`);
5642
4412
  }
5643
4413
  }
5644
4414
  }
@@ -5842,43 +4612,6 @@ const isChildPattern = (childPattern, parentPattern) => {
5842
4612
  return childSegments.length > parentSegments.length || hasMoreSpecificSegment;
5843
4613
  };
5844
4614
 
5845
- /**
5846
- * Check if a parameter is redundant because the child pattern already has it as a literal segment
5847
- * E.g., parameter "section" is redundant for pattern "/admin/settings/:tab" because "settings" is literal
5848
- */
5849
- const isParameterRedundantWithLiteralSegments = (
5850
- childPattern,
5851
- parentPattern,
5852
- paramName,
5853
- ) => {
5854
- // Find which segment position corresponds to this parameter in the parent
5855
- let paramSegmentIndex = -1;
5856
- for (let i = 0; i < parentPattern.segments.length; i++) {
5857
- const segment = parentPattern.segments[i];
5858
- if (segment.type === "param" && segment.name === paramName) {
5859
- paramSegmentIndex = i;
5860
- break;
5861
- }
5862
- }
5863
-
5864
- // If parameter not found in parent segments, it's not redundant with path
5865
- if (paramSegmentIndex === -1) {
5866
- return false;
5867
- }
5868
-
5869
- // Check if child has a literal segment at the same position
5870
- if (childPattern.segments.length > paramSegmentIndex) {
5871
- const childSegment = childPattern.segments[paramSegmentIndex];
5872
- if (childSegment.type === "literal") {
5873
- // Child has a literal segment where parent has parameter
5874
- // This means the child is more specific and shouldn't inherit this parameter
5875
- return true; // Redundant - child already specifies this position with a literal
5876
- }
5877
- }
5878
-
5879
- return false;
5880
- };
5881
-
5882
4615
  /**
5883
4616
  * Register all patterns at once and build their relationships
5884
4617
  */
@@ -5914,6 +4647,17 @@ const setupRoutePatterns = (routePatterns) => {
5914
4647
  otherRoutePattern.children.push(routePattern);
5915
4648
  }
5916
4649
  }
4650
+ // Phase 2b: Compute family roots. Two patterns are in the same family when
4651
+ // their parent chains meet — one is ancestor of the other, or they share a
4652
+ // common ancestor. Each pattern has a single parent, so that is exactly:
4653
+ // same topmost ancestor (familyRoot equality).
4654
+ for (const routePattern of routePatternSet) {
4655
+ let root = routePattern;
4656
+ while (root.parent) {
4657
+ root = root.parent;
4658
+ }
4659
+ routePattern.familyRoot = root;
4660
+ }
5917
4661
  // Phase 3: Inherit search parameter connections from ancestors
5918
4662
  // Search params are global and should be inherited by descendants regardless of path segments
5919
4663
  for (const routePattern of routePatternSet) {
@@ -5971,13 +4715,11 @@ const setupRoutePatterns = (routePatterns) => {
5971
4715
  routePattern.queryConnectionMap.set(paramName, inheritedConnection);
5972
4716
  routePattern.connections.push(inheritedConnection);
5973
4717
 
5974
- if (DEBUG$2) {
5975
- console.debug(
5976
- `[${routePattern.originalPattern}] Inherited search param "${paramName}" from ancestor [${ancestorRoutePattern.originalPattern}]`,
5977
- );
5978
- }
5979
- } else if (DEBUG$2) {
5980
- console.debug(
4718
+ debug$3(
4719
+ `[${routePattern.originalPattern}] Inherited search param "${paramName}" from ancestor [${ancestorRoutePattern.originalPattern}]`,
4720
+ );
4721
+ } else {
4722
+ debug$3(
5981
4723
  `[${routePattern.originalPattern}] Skipped inheriting "${paramName}" - child uses default values, not truly more specific`,
5982
4724
  );
5983
4725
  }
@@ -5985,48 +4727,7 @@ const setupRoutePatterns = (routePatterns) => {
5985
4727
  ancestorRoutePattern = ancestorRoutePattern.parent;
5986
4728
  }
5987
4729
  }
5988
- // Phase 4: Collect all relevant signals for each pattern based on relationships
5989
- for (const routePattern of routePatternSet) {
5990
- const allRelevantSignals = new Set();
5991
-
5992
- // Add own signals
5993
- for (const signal of routePattern.signalSet) {
5994
- allRelevantSignals.add(signal);
5995
- }
5996
-
5997
- // Add signals from ancestors (they might be inherited)
5998
- let parentRoutePattern = routePattern.parent;
5999
- while (parentRoutePattern) {
6000
- for (const connection of parentRoutePattern.connections) {
6001
- allRelevantSignals.add(connection.signal);
6002
- }
6003
- // Move up the parent chain
6004
- parentRoutePattern = parentRoutePattern.parent;
6005
- }
6006
-
6007
- // Add signals from descendants (they might be used for optimization)
6008
- const addDescendantSignals = (patternObj) => {
6009
- for (const childPattern of patternObj.children) {
6010
- // Add child's own signals
6011
- for (const connection of childPattern.connections) {
6012
- allRelevantSignals.add(connection.signal);
6013
- }
6014
- // Recursively add grandchildren signals
6015
- addDescendantSignals(childPattern);
6016
- }
6017
- };
6018
- addDescendantSignals(routePattern);
6019
-
6020
- // Update the pattern's signalSet with all relevant signals
6021
- routePattern.signalSet = allRelevantSignals;
6022
-
6023
- if (DEBUG$2 && allRelevantSignals.size > 0) {
6024
- console.debug(
6025
- `[${routePattern.urlPatternRaw}] Collected ${allRelevantSignals.size} relevant signals`,
6026
- );
6027
- }
6028
- }
6029
- // Phase 5: Precompute descendant path signals for each pattern (used by canReachLiteralValue)
4730
+ // Phase 4: Precompute descendant path signals for each pattern (used by canReachLiteralValue)
6030
4731
  // Stored as a Map<segmentIndex, conn[]> for O(1) lookup by position.
6031
4732
  for (const routePattern of routePatternSet) {
6032
4733
  const descendantPathSignalsByIndex = new Map();
@@ -6054,7 +4755,7 @@ const setupRoutePatterns = (routePatterns) => {
6054
4755
  collectDescendantPathSignals(routePattern);
6055
4756
  routePattern.descendantPathSignals = descendantPathSignalsByIndex;
6056
4757
  }
6057
- // Phase 5b: Flag path params whose values are ALSO declared as literal routes
4758
+ // Phase 4b: Flag path params whose values are ALSO declared as literal routes
6058
4759
  // ("/games/me/done" next to "/games/me/:section"). That declaration is the
6059
4760
  // only reliable statement that the param names pages rather than qualifying
6060
4761
  // one — read by shouldUseChildRoute to decide whether an ancestor url may
@@ -6105,13 +4806,11 @@ const setupRoutePatterns = (routePatterns) => {
6105
4806
  }
6106
4807
  }
6107
4808
  }
6108
- // Phase 6: Calculate depths for all patterns
4809
+ // Phase 5: Calculate depths for all patterns
6109
4810
  for (const routePattern of routePatternSet) {
6110
4811
  calculatePatternDepth(routePattern);
6111
4812
  }
6112
- if (DEBUG$2) {
6113
- console.debug("Pattern registry updated");
6114
- }
4813
+ debug$3("Pattern registry updated");
6115
4814
  };
6116
4815
  // Store the most specific parent (closest parent in hierarchy)
6117
4816
  const getPathSegmentCount = (pattern) => {
@@ -6430,14 +5129,11 @@ const route = (pattern, { searchParams } = {}) => {
6430
5129
  // Only sync non-default values to keep URLs clean (static fallbacks stay invisible)
6431
5130
  registerSetup(() => {
6432
5131
  const cleanupSignalUrlEffectSet = new Set();
6433
- const { pathConnectionMap, queryConnectionMap } = routePattern;
6434
- // important: keep this connectionMap after setup so that connectionMap correctly inherits parent pattern signals
6435
- const connectionMap = new Map([
6436
- ...pathConnectionMap,
6437
- ...queryConnectionMap,
6438
- ]);
6439
- for (const [paramName, connection] of connectionMap) {
6440
- const { signal: paramSignal, debug } = connection;
5132
+ // important: read connections at setup time so it includes query connections
5133
+ // inherited from ancestor patterns
5134
+ const { connections } = routePattern;
5135
+ for (const connection of connections) {
5136
+ const { signal: paramSignal, debug, paramName } = connection;
6441
5137
  if (debug) {
6442
5138
  console.debug(
6443
5139
  `[route] connecting url param "${paramName}" to signal`,
@@ -6654,14 +5350,11 @@ This prevents cross-test pollution and ensures clean state.`,
6654
5350
  newMatching,
6655
5351
  } of routeMatchInfoSet) {
6656
5352
  const { routePattern } = routePrivateProperties;
6657
- const { pathConnectionMap, queryConnectionMap } = routePattern;
6658
- const connectionMap = new Map([
6659
- ...pathConnectionMap,
6660
- ...queryConnectionMap,
6661
- ]);
6662
-
6663
- for (const [paramName, connection] of connectionMap) {
6664
- const { signal: paramSignal, debug } = connection;
5353
+ const { pathConnectionMap, queryConnectionMap, connections } =
5354
+ routePattern;
5355
+
5356
+ for (const connection of connections) {
5357
+ const { signal: paramSignal, debug, paramName } = connection;
6665
5358
  const rawParams = route.rawParamsSignal.value;
6666
5359
  const urlParamValue = rawParams[paramName];
6667
5360
 
@@ -6675,66 +5368,17 @@ This prevents cross-test pollution and ensures clean state.`,
6675
5368
  continue;
6676
5369
  }
6677
5370
  const otherRawParams = otherRoute.rawParamsSignal.value;
6678
- const otherRoutePrivateProperties =
6679
- getRoutePrivateProperties(otherRoute);
6680
5371
 
6681
5372
  // Check if this matching route extracts the parameter
6682
5373
  if (paramName in otherRawParams) {
6683
5374
  parameterExtractedByMatchingRoute = true;
6684
5375
  }
6685
5376
 
6686
- // Check if this matching route is in the same family using parent-child relationships
6687
- const thisPatternObj = routePattern;
5377
+ // Same family = same topmost ancestor
5378
+ // (familyRoot, computed in setupRoutePatterns)
6688
5379
  const otherPatternObj =
6689
- otherRoutePrivateProperties.routePattern;
6690
-
6691
- // Routes are in same family if they share a hierarchical relationship:
6692
- // 1. One is parent/ancestor of the other
6693
- // 2. They share a common parent/ancestor
6694
- let inSameFamily = false;
6695
-
6696
- // Check if other route is ancestor of this route
6697
- let currentParent = thisPatternObj.parent;
6698
- while (currentParent) {
6699
- if (currentParent === otherPatternObj) {
6700
- inSameFamily = true;
6701
- break;
6702
- }
6703
- currentParent = currentParent.parent;
6704
- }
6705
-
6706
- // Check if this route is ancestor of other route
6707
- if (!inSameFamily) {
6708
- currentParent = otherPatternObj.parent;
6709
- while (currentParent) {
6710
- if (currentParent === thisPatternObj) {
6711
- inSameFamily = true;
6712
- break;
6713
- }
6714
- currentParent = currentParent.parent;
6715
- }
6716
- }
6717
-
6718
- // Check if they share a common parent (siblings or cousins)
6719
- if (!inSameFamily) {
6720
- const thisAncestors = new Set();
6721
- currentParent = thisPatternObj.parent;
6722
- while (currentParent) {
6723
- thisAncestors.add(currentParent);
6724
- currentParent = currentParent.parent;
6725
- }
6726
-
6727
- currentParent = otherPatternObj.parent;
6728
- while (currentParent) {
6729
- if (thisAncestors.has(currentParent)) {
6730
- inSameFamily = true;
6731
- break;
6732
- }
6733
- currentParent = currentParent.parent;
6734
- }
6735
- }
6736
-
6737
- if (inSameFamily) {
5380
+ getRoutePrivateProperties(otherRoute).routePattern;
5381
+ if (otherPatternObj.familyRoot === routePattern.familyRoot) {
6738
5382
  matchingRouteInSameFamily = true;
6739
5383
  }
6740
5384
  }
@@ -21855,11 +20499,11 @@ const markAutofocusRestoreOnClose = (
21855
20499
  * @param {object} [options]
21856
20500
  * @param {boolean} [options.skipFirstFocusable]
21857
20501
  * Drops step 2 — the focus then goes where something ASKED for it, or to the
21858
- * last resort, which for a container is itself. For a surface that is read
21859
- * before it is reached: the first focusable is wherever the content happens
21860
- * to put it, so landing there scrolls whatever comes before it out of sight
21861
- * (see open_controller.js, which turns this on wherever the keyboard is a
21862
- * virtual one).
20502
+ * last resort, which for a container is itself. What arrives is read before
20503
+ * it is reached: the first focusable is wherever the content happens to put
20504
+ * it, so landing there scrolls whatever comes before it out of sight.
20505
+ * transferFocus turns this on by itself wherever the keyboard is a virtual
20506
+ * one — see the reasoning there.
21863
20507
  * @returns {{target: HTMLElement, reason: string}|undefined}
21864
20508
  */
21865
20509
  const findFocusTarget = (containerEl, { skipFirstFocusable } = {}) => {
@@ -21978,11 +20622,22 @@ const prepareFocusTransfer = (prepareEvent, debugFocus) => {
21978
20622
  * undefined when it focused straight away and there is nothing to take
21979
20623
  * back.
21980
20624
  */
21981
- transferFocus: (
21982
- transferEvent,
21983
- containerEl,
21984
- { getDelay, skipFirstFocusable } = {},
21985
- ) => {
20625
+ transferFocus: (transferEvent, containerEl, { getDelay } = {}) => {
20626
+ // Where the keyboard is a virtual one, an arrival lands on what ASKED for
20627
+ // the focus, or on the surface — never on the first focusable that
20628
+ // happens to be there. That element costs the top of what just arrived
20629
+ // twice over: the browser scrolls it into view, and a field raises a
20630
+ // keyboard taking a third of what is left, so the title and the sentence
20631
+ // saying what this is about are gone before it has been looked at. A
20632
+ // field that really is what one came for asks by name (step 2) and gets
20633
+ // the keyboard anyway.
20634
+ //
20635
+ // The device, not the interaction (unlike the delay callers apply on top
20636
+ // of this): whether focusing raises a keyboard over what arrived is true
20637
+ // of the screen, and an arrival with no pointer in it at all — a popup
20638
+ // opened by the page loading, a travel asked for by code — is precisely
20639
+ // the one that must not be answered "no keyboard here".
20640
+ const skipFirstFocusable = coarsePointerSignal.value;
21986
20641
  let target;
21987
20642
  let reason;
21988
20643
  containerEl.removeAttribute(AUTOFOCUS_UNPLACED_ATTRIBUTE);
@@ -25002,6 +23657,22 @@ const useUIStateController = (
25002
23657
  const isProxy = Boolean(props["navi-control-proxy-for"]);
25003
23658
  const emptyUIState = resolveEmptyUIState(props, controlType);
25004
23659
 
23660
+ // Live values controller methods read through the scope (`s.…`) — one list
23661
+ // feeding both init and update: init so the values exist before any
23662
+ // re-render, update so they follow the renders. A value listed in only one
23663
+ // of the two goes stale on mount or across re-renders, silently.
23664
+ const liveValues = () => ({
23665
+ ref: props.ref,
23666
+ id: props.id,
23667
+ name: props.name,
23668
+ props,
23669
+ controlInfo,
23670
+ syncDomState,
23671
+ uiAction: props.uiAction,
23672
+ uiActionInternal,
23673
+ parentUIStateController,
23674
+ });
23675
+
25005
23676
  const scope = useRenderScope(
25006
23677
  // ── init: runs once on mount ───────────────────────────────────────────
25007
23678
  // Creates the controller and all long-lived objects. Captures first-render
@@ -25088,7 +23759,9 @@ const useUIStateController = (
25088
23759
  if (controller.facadeChild) {
25089
23760
  const child = controller.facadeChild;
25090
23761
  const childManaged = child.getManagedControls();
25091
- if (childManaged.length > 0) return childManaged;
23762
+ if (childManaged.length > 0) {
23763
+ return childManaged;
23764
+ }
25092
23765
  return [child];
25093
23766
  }
25094
23767
  return [];
@@ -25210,7 +23883,9 @@ const useUIStateController = (
25210
23883
  // set immediatly (don't wait for preact re-render) so ui is in the right state for:
25211
23884
  // - side effect
25212
23885
  // - any "input" event that might be dispatched below
25213
- syncDomState(newUIState, e);
23886
+ // Read through the scope: syncDomState closes over the render's props
23887
+ // (ref, type, pad), so the mount-time one would write a stale element.
23888
+ s.syncDomState(newUIState, e);
25214
23889
  controller.uiState = newUIState;
25215
23890
  ownUIStateSignal.value = newUIState;
25216
23891
  const controlProxyFor =
@@ -25231,7 +23906,9 @@ const useUIStateController = (
25231
23906
  );
25232
23907
  chainEvent(siblingUncheckEvent, e);
25233
23908
  for (const siblingController of siblings) {
25234
- if (siblingController === controller) continue;
23909
+ if (siblingController === controller) {
23910
+ continue;
23911
+ }
25235
23912
  if (
25236
23913
  siblingController.parentUIStateController !==
25237
23914
  s.parentUIStateController
@@ -25443,10 +24120,8 @@ const useUIStateController = (
25443
24120
  resetUIState: (e) => {
25444
24121
  controller.setUIState(controller.state, e);
25445
24122
  },
25446
- onActionEnd: async (e) => {
24123
+ onActionEnd: (e) => {
25447
24124
  debugUIState(`"${controlType}" actionEnd called`);
25448
- // wait for preact to re-render to update readonly as action end side effects are runned
25449
- // await new Promise((r) => requestAnimationFrame(r));
25450
24125
  controller.rules.validation.syncValidity(e);
25451
24126
  },
25452
24127
  onActionError: (e) => {
@@ -25482,20 +24157,10 @@ const useUIStateController = (
25482
24157
  });
25483
24158
  controller.rules = rules;
25484
24159
 
25485
- // Include all values that controller methods read from the scope so they
25486
- // are available immediately — even if no re-render happens before the
25487
- // first user interaction (update only runs on re-renders, not on mount).
25488
24160
  return {
25489
24161
  controller,
25490
- ref: props.ref,
25491
- id: props.id,
25492
- name: props.name,
25493
- props,
25494
- controlInfo,
25495
- uiAction: props.uiAction,
25496
- uiActionInternal,
25497
- parentUIStateController,
25498
24162
  parentUiStateSignalHolder,
24163
+ ...liveValues(),
25499
24164
  };
25500
24165
  },
25501
24166
  // ── update: runs every render after the first ─────────────────────────
@@ -25556,16 +24221,7 @@ const useUIStateController = (
25556
24221
  }
25557
24222
  }
25558
24223
  }
25559
- return {
25560
- ref: props.ref,
25561
- id: props.id,
25562
- name: props.name,
25563
- props,
25564
- controlInfo,
25565
- uiAction: props.uiAction,
25566
- uiActionInternal,
25567
- parentUIStateController,
25568
- };
24224
+ return liveValues();
25569
24225
  },
25570
24226
  );
25571
24227
  scope.parentUiStateSignalHolder.value =
@@ -25596,11 +24252,13 @@ const useUIStateController = (
25596
24252
  return undefined;
25597
24253
  }
25598
24254
 
25599
- debugUIState(`"${controlType}" registering into "${parent.controlType}"`);
24255
+ debugUIState(
24256
+ `"${controlType}" registering into "${parentController.controlType}"`,
24257
+ );
25600
24258
  parentController.registerChild(controller);
25601
24259
  return () => {
25602
24260
  debugUIState(
25603
- `"${controlType}" unregistering from "${parent.controlType}"`,
24261
+ `"${controlType}" unregistering from "${parentController.controlType}"`,
25604
24262
  );
25605
24263
  parentController.unregisterChild(controller);
25606
24264
  };
@@ -25609,42 +24267,8 @@ const useUIStateController = (
25609
24267
  return controller;
25610
24268
  };
25611
24269
 
25612
- /**
25613
- * Manages the aggregated UI state of a group of child controls (radio list, checkbox list, etc.).
25614
- *
25615
- * Children register themselves automatically on mount and unregister on unmount.
25616
- * Whenever a child fires a UI action, the group re-aggregates all child states
25617
- * via `aggregateChildStates` and reacts accordingly.
25618
- *
25619
- * **Three distinct methods — each with a clear responsibility**:
25620
- *
25621
- * - `setUIState(newUIState, e)` — called when a child UI action **changes** the aggregated value.
25622
- * Updates the group state, then calls `onUIAction(e)` for user-observable reactions
25623
- * (uiAction, command), then dispatches `navi_ui_state_change` so `control_hooks.jsx`
25624
- * can trigger the action pipeline (constraints → execute action).
25625
- *
25626
- * - `syncInternalState(newUIState)` — called silently during mount/unmount/render-batch.
25627
- * Updates state and signal with no external reactions whatsoever.
25628
- *
25629
- * - `onUIAction(e)` — called when a child's UI action does **not** change the aggregated
25630
- * value (e.g. re-clicking an already-selected radio). Fires `uiAction` + `command` only;
25631
- * does not touch state, does not trigger the action pipeline.
25632
- *
25633
- * **Child UI action flow**:
25634
- * 1. Child leaf fires `notifyParentAboutChildUIAction(e, { stateChanged })`.
25635
- * 2. Group's `onChildUIAction` receives it.
25636
- * - If `stateChanged=true`: re-aggregates → `setUIState` → full reactions + action pipeline.
25637
- * - If `stateChanged=false`: calls `onUIAction` → uiAction + command only.
25638
- *
25639
- * **Filtering**: `childControlFilter` can exclude certain child types from aggregation
25640
- * (e.g. ignoring buttons inside a selectable list).
25641
- */
25642
24270
  const CANNOT_DERIVE = Symbol("cannot_derive");
25643
-
25644
- // Default aggregate/distribute implementations keyed by controlType or stateType.
25645
- // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
25646
- // distributeChildUIState. If neither a default nor an explicit impl is found for a
25647
- // group, creation throws so the caller knows it must supply them.
24271
+
25648
24272
  // A child that groups other controls and was given no name of its own, holding
25649
24273
  // an object — the only shape that can be merged into the object around it.
25650
24274
  // A nameless LEAF (an input nobody named) is still a mistake and still warns.
@@ -25654,19 +24278,25 @@ const isNamelessGrouping = (child, uiState) =>
25654
24278
  typeof uiState === "object" &&
25655
24279
  !Array.isArray(uiState);
25656
24280
 
24281
+ const firstDefinedChildUIState = (children) => {
24282
+ for (const child of children) {
24283
+ const childUIState = child.uiState;
24284
+ if (childUIState !== undefined) {
24285
+ return childUIState;
24286
+ }
24287
+ }
24288
+ return undefined;
24289
+ };
24290
+
24291
+ // Default aggregate/distribute implementations keyed by controlType or stateType.
24292
+ // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
24293
+ // distributeChildUIState. If neither a default nor an explicit impl is found for a
24294
+ // group, creation throws so the caller knows it must supply them.
25657
24295
  const GROUP_DEFAULTS = {
25658
24296
  radio_group: {
25659
24297
  childControlFilter: (child) =>
25660
24298
  child.controlType === "input" && child.controlHostProps?.type === "radio",
25661
- aggregateChildStates: (children) => {
25662
- for (const child of children) {
25663
- const childUIState = child.uiState;
25664
- if (childUIState !== undefined) {
25665
- return childUIState;
25666
- }
25667
- }
25668
- return undefined;
25669
- },
24299
+ aggregateChildStates: firstDefinedChildUIState,
25670
24300
  distributeChildUIState: (newUIState, childUIStateController) => {
25671
24301
  const childSelected = childUIStateController.props.value === newUIState;
25672
24302
  if (childSelected) {
@@ -25723,15 +24353,7 @@ const GROUP_DEFAULTS = {
25723
24353
  }
25724
24354
  return true;
25725
24355
  },
25726
- aggregateChildStates: (children) => {
25727
- for (const child of children) {
25728
- const childUIState = child.uiState;
25729
- if (childUIState !== undefined) {
25730
- return childUIState;
25731
- }
25732
- }
25733
- return undefined;
25734
- },
24356
+ aggregateChildStates: firstDefinedChildUIState,
25735
24357
  distributeChildUIState: (newUIState) => newUIState,
25736
24358
  },
25737
24359
  object: {
@@ -25800,6 +24422,36 @@ const GROUP_DEFAULTS = {
25800
24422
  },
25801
24423
  };
25802
24424
 
24425
+ /**
24426
+ * Manages the aggregated UI state of a group of child controls (radio list, checkbox list, etc.).
24427
+ *
24428
+ * Children register themselves automatically on mount and unregister on unmount.
24429
+ * Whenever a child fires a UI action, the group re-aggregates all child states
24430
+ * via `aggregateChildStates` and reacts accordingly.
24431
+ *
24432
+ * **Three distinct methods — each with a clear responsibility**:
24433
+ *
24434
+ * - `setUIState(newUIState, e)` — called when a child UI action **changes** the aggregated value.
24435
+ * Updates the group state, then calls `onUIAction(e)` for user-observable reactions
24436
+ * (uiAction, command), then dispatches `navi_ui_state_change` so `control_hooks.jsx`
24437
+ * can trigger the action pipeline (constraints → execute action).
24438
+ *
24439
+ * - `syncInternalState(newUIState)` — called silently during mount/unmount/render-batch.
24440
+ * Updates state and signal with no external reactions whatsoever.
24441
+ *
24442
+ * - `onUIAction(e)` — called when a child's UI action does **not** change the aggregated
24443
+ * value (e.g. re-clicking an already-selected radio). Fires `uiAction` + `command` only;
24444
+ * does not touch state, does not trigger the action pipeline.
24445
+ *
24446
+ * **Child UI action flow**:
24447
+ * 1. Child leaf fires `notifyParentAboutChildUIAction(e, { stateChanged })`.
24448
+ * 2. Group's `onChildUIAction` receives it.
24449
+ * - If `stateChanged=true`: re-aggregates → `setUIState` → full reactions + action pipeline.
24450
+ * - If `stateChanged=false`: calls `onUIAction` → uiAction + command only.
24451
+ *
24452
+ * **Filtering**: `childControlFilter` can exclude certain child types from aggregation
24453
+ * (e.g. ignoring buttons inside a selectable list).
24454
+ */
25803
24455
  const useUIGroupStateController = (
25804
24456
  props,
25805
24457
  controlType,
@@ -25879,7 +24531,9 @@ const useUIGroupStateController = (
25879
24531
  pendingChangeRef.current = null;
25880
24532
 
25881
24533
  const isMonitoringChild = (childUIStateController) => {
25882
- if (childUIStateController.isProxy) return false;
24534
+ if (childUIStateController.isProxy) {
24535
+ return false;
24536
+ }
25883
24537
  if (
25884
24538
  resolvedChildControlFilter &&
25885
24539
  !resolvedChildControlFilter(childUIStateController)
@@ -25889,12 +24543,39 @@ const useUIGroupStateController = (
25889
24543
  return true;
25890
24544
  };
25891
24545
  const shouldPropagateStateToChild = (childUIStateController) => {
25892
- if (!isMonitoringChild(childUIStateController)) return false;
25893
- if (childUIStateController.controlType === "button") return false;
25894
- if (childUIStateController.controlType === "link") return false;
24546
+ if (!isMonitoringChild(childUIStateController)) {
24547
+ return false;
24548
+ }
24549
+ if (childUIStateController.controlType === "button") {
24550
+ return false;
24551
+ }
24552
+ if (childUIStateController.controlType === "link") {
24553
+ return false;
24554
+ }
25895
24555
  return true;
25896
24556
  };
25897
24557
 
24558
+ // Live values controller methods read through the scope (`s.…`) — same
24559
+ // contract as the leaf controller's liveValues above: one list feeding both
24560
+ // init and update.
24561
+ const liveValues = () => ({
24562
+ ref,
24563
+ parentUIStateController,
24564
+ uiAction,
24565
+ uiActionInternal,
24566
+ id,
24567
+ name,
24568
+ value,
24569
+ defaultValue,
24570
+ hasValueProp,
24571
+ hasDefaultValueProp,
24572
+ // `props` is what writeBoundSignal reads to find the bound `signal`.
24573
+ // Missing here, a group whose component never re-renders between mount
24574
+ // and the first choice wrote nothing back into its signal — and said
24575
+ // nothing about it: the list showed the choice, the signal stayed empty.
24576
+ props,
24577
+ });
24578
+
25898
24579
  const scope = useRenderScope(
25899
24580
  // ── init: runs once on mount ───────────────────────────────────────────
25900
24581
  (s) => {
@@ -25904,6 +24585,14 @@ const useUIGroupStateController = (
25904
24585
  const [publishUIState, subscribeUIState] = createPubSub();
25905
24586
  const uiStateSignal = signal(fallbackState);
25906
24587
 
24588
+ const aggregateGroupUIState = () => {
24589
+ const aggChildState = resolvedAggregateChildStates(
24590
+ childUIStateControllerArray,
24591
+ fallbackState,
24592
+ );
24593
+ return aggChildState === undefined ? fallbackState : aggChildState;
24594
+ };
24595
+
25907
24596
  // onChange and applyState live inside init so they close over the stable
25908
24597
  // signals/pubsub without needing external refs.
25909
24598
  const onChange = (e, { notifyExternal }) => {
@@ -25922,12 +24611,7 @@ const useUIGroupStateController = (
25922
24611
  };
25923
24612
  return;
25924
24613
  }
25925
- const aggChildState = resolvedAggregateChildStates(
25926
- childUIStateControllerArray,
25927
- fallbackState,
25928
- );
25929
- const groupUIState =
25930
- aggChildState === undefined ? fallbackState : aggChildState;
24614
+ const groupUIState = aggregateGroupUIState();
25931
24615
  debugUIGroup(
25932
24616
  e,
25933
24617
  `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
@@ -25936,13 +24620,13 @@ const useUIGroupStateController = (
25936
24620
  if (notifyExternal === true) {
25937
24621
  applyState(groupUIState, e);
25938
24622
  } else if (notifyExternal === "silent") {
25939
- controller.syncInternalState(groupUIState, e);
24623
+ controller.syncInternalState(groupUIState);
25940
24624
  s.parentUIStateController?.onChildUIAction(controller, e, {
25941
24625
  stateChanged: true,
25942
24626
  silent: true,
25943
24627
  });
25944
24628
  } else {
25945
- controller.syncInternalState(groupUIState, e);
24629
+ controller.syncInternalState(groupUIState);
25946
24630
  writeBoundSignal(groupUIState);
25947
24631
  }
25948
24632
  };
@@ -26062,12 +24746,7 @@ const useUIGroupStateController = (
26062
24746
  propagateDownEvent,
26063
24747
  );
26064
24748
  }
26065
- const aggChildState = resolvedAggregateChildStates(
26066
- childUIStateControllerArray,
26067
- fallbackState,
26068
- );
26069
- const groupUIState =
26070
- aggChildState === undefined ? fallbackState : aggChildState;
24749
+ const groupUIState = aggregateGroupUIState();
26071
24750
  if (e.type === "initial_state_push") {
26072
24751
  controller.syncInternalState(groupUIState);
26073
24752
  return;
@@ -26079,7 +24758,9 @@ const useUIGroupStateController = (
26079
24758
  },
26080
24759
  syncInternalState: (newUIState) => {
26081
24760
  const currentUIState = controller.uiState;
26082
- if (newUIState === currentUIState) return;
24761
+ if (newUIState === currentUIState) {
24762
+ return;
24763
+ }
26083
24764
  controller.uiState = newUIState;
26084
24765
  uiStateSignal.value = newUIState;
26085
24766
  publishUIState(newUIState);
@@ -26097,7 +24778,9 @@ const useUIGroupStateController = (
26097
24778
  s.uiActionInternal?.(currentUIState, e);
26098
24779
  if (!skipCommand && controller.props.command) {
26099
24780
  const el = controller.ref.current;
26100
- if (el) triggerNaviCommand(el, controller.props.command, e);
24781
+ if (el) {
24782
+ triggerNaviCommand(el, controller.props.command, e);
24783
+ }
26101
24784
  }
26102
24785
  },
26103
24786
  registerChild: (childUIStateController) => {
@@ -26148,7 +24831,9 @@ const useUIGroupStateController = (
26148
24831
  });
26149
24832
  return;
26150
24833
  }
26151
- if (!isMonitoringChild(childUIStateController)) return;
24834
+ if (!isMonitoringChild(childUIStateController)) {
24835
+ return;
24836
+ }
26152
24837
  const childControlType = childUIStateController.controlType;
26153
24838
  debugUIGroup(
26154
24839
  `${controlType}.onChildUIAction("${childControlType}") stateChanged=${stateChanged} -> child state: ${JSON.stringify(
@@ -26170,7 +24855,9 @@ const useUIGroupStateController = (
26170
24855
  delegatedTo.unregisterChild(childUIStateController);
26171
24856
  return;
26172
24857
  }
26173
- if (!isMonitoringChild(childUIStateController)) return;
24858
+ if (!isMonitoringChild(childUIStateController)) {
24859
+ return;
24860
+ }
26174
24861
  const childControlType = childUIStateController.controlType;
26175
24862
  const index = childUIStateControllerArray.indexOf(
26176
24863
  childUIStateController,
@@ -26183,7 +24870,7 @@ const useUIGroupStateController = (
26183
24870
  }
26184
24871
  childUIStateControllerArray.splice(index, 1);
26185
24872
  debugUIGroup(
26186
- `${controlType}.unregisterChild("${childControlType}") -> unregisteed (remaining: ${childUIStateControllerArray.length})`,
24873
+ `${controlType}.unregisterChild("${childControlType}") -> unregistered (remaining: ${childUIStateControllerArray.length})`,
26187
24874
  );
26188
24875
  onChange(new CustomEvent(`${childControlType}_unmount`), {
26189
24876
  notifyExternal: "silent",
@@ -26195,7 +24882,9 @@ const useUIGroupStateController = (
26195
24882
  });
26196
24883
  chainEvent(ev, e);
26197
24884
  for (const c of childUIStateControllerArray) {
26198
- if (shouldPropagateStateToChild(c)) c.resetUIState(ev);
24885
+ if (shouldPropagateStateToChild(c)) {
24886
+ c.resetUIState(ev);
24887
+ }
26199
24888
  }
26200
24889
  onChange(e, { notifyExternal: true });
26201
24890
  },
@@ -26243,13 +24932,17 @@ const useUIGroupStateController = (
26243
24932
  },
26244
24933
  findChildById: (searchId) => {
26245
24934
  for (const c of childUIStateControllerArray) {
26246
- if (c.id === searchId) return c;
24935
+ if (c.id === searchId) {
24936
+ return c;
24937
+ }
26247
24938
  }
26248
24939
  return null;
26249
24940
  },
26250
24941
  getChildControllers: () => childUIStateControllerArray,
26251
24942
  getManagedControls: () => {
26252
- if (!cascadeValidationToChildren) return [];
24943
+ if (!cascadeValidationToChildren) {
24944
+ return [];
24945
+ }
26253
24946
  return childUIStateControllerArray.slice();
26254
24947
  },
26255
24948
  // Group children sit next to the group itself: a busy one really does
@@ -26267,20 +24960,10 @@ const useUIGroupStateController = (
26267
24960
  });
26268
24961
  controller.rules = rules;
26269
24962
 
26270
- // Include all values read by controller methods so they are immediately
26271
- // available, even if the user interacts before the first re-render.
26272
24963
  return {
26273
24964
  controller,
26274
24965
  _onChange: onChange,
26275
- ref,
26276
- parentUIStateController,
26277
- uiAction,
26278
- uiActionInternal,
26279
- // `props` is what writeBoundSignal reads to find the bound `signal`.
26280
- // Missing here, a group whose component never re-renders between mount
26281
- // and the first choice wrote nothing back into its signal — and said
26282
- // nothing about it: the list showed the choice, the signal stayed empty.
26283
- props,
24966
+ ...liveValues(),
26284
24967
  };
26285
24968
  },
26286
24969
  // ── update: runs every render after the first ─────────────────────────
@@ -26297,10 +24980,7 @@ const useUIGroupStateController = (
26297
24980
  controller.defaultValue = defaultValue;
26298
24981
  controller.hasValueProp = hasValueProp;
26299
24982
  controller.hasDefaultValueProp = hasDefaultValueProp;
26300
- if (
26301
- hasValueProp &&
26302
- (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
26303
- ) {
24983
+ const placeChildrenFrom = (groupUIState) => {
26304
24984
  const propagateDownEvent = new CustomEvent(
26305
24985
  "propagate_down_set_ui_state",
26306
24986
  { detail: {} },
@@ -26308,11 +24988,17 @@ const useUIGroupStateController = (
26308
24988
  for (const childUIStateController of childUIStateControllerArray) {
26309
24989
  controller.placeChildUIState(
26310
24990
  childUIStateController,
26311
- value,
24991
+ groupUIState,
26312
24992
  propagateDownEvent,
26313
24993
  );
26314
24994
  }
26315
- controller.syncInternalState(value);
24995
+ controller.syncInternalState(groupUIState);
24996
+ };
24997
+ if (
24998
+ hasValueProp &&
24999
+ (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
25000
+ ) {
25001
+ placeChildrenFrom(value);
26316
25002
  }
26317
25003
  if (
26318
25004
  boundSignal &&
@@ -26323,33 +25009,10 @@ const useUIGroupStateController = (
26323
25009
  // again, exactly as they were when they registered. Without this a
26324
25010
  // group would answer a write to its own signal by silently writing its
26325
25011
  // former value back over it on the next child interaction.
26326
- const propagateDownEvent = new CustomEvent(
26327
- "propagate_down_set_ui_state",
26328
- { detail: {} },
26329
- );
26330
- for (const childUIStateController of childUIStateControllerArray) {
26331
- controller.placeChildUIState(
26332
- childUIStateController,
26333
- defaultValue,
26334
- propagateDownEvent,
26335
- );
26336
- }
26337
- controller.syncInternalState(defaultValue);
25012
+ placeChildrenFrom(defaultValue);
26338
25013
  }
26339
25014
 
26340
- return {
26341
- ref,
26342
- parentUIStateController,
26343
- uiAction,
26344
- uiActionInternal,
26345
- id,
26346
- name,
26347
- value,
26348
- defaultValue,
26349
- hasValueProp,
26350
- hasDefaultValueProp,
26351
- props,
26352
- };
25015
+ return liveValues();
26353
25016
  },
26354
25017
  );
26355
25018
 
@@ -26360,6 +25023,9 @@ const useUIGroupStateController = (
26360
25023
  el.__uiStateController__ = controller;
26361
25024
  }
26362
25025
  return () => {
25026
+ if (el && el.__uiStateController__ === controller) {
25027
+ delete el.__uiStateController__;
25028
+ }
26363
25029
  onUIStateControllerDestroyed(controller);
26364
25030
  };
26365
25031
  }, []);
@@ -26446,10 +25112,18 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26446
25112
  // ── init: runs once on mount ───────────────────────────────────────────
26447
25113
  (s) => {
26448
25114
  const canRegisterAsFacadeChild = (childController) => {
26449
- if (childController.controlType === "button") return false;
26450
- if (childController.controlType === "link") return false;
26451
- if (childController.controlType === "facade") return false;
26452
- if (childController.isProxy) return false;
25115
+ if (childController.controlType === "button") {
25116
+ return false;
25117
+ }
25118
+ if (childController.controlType === "link") {
25119
+ return false;
25120
+ }
25121
+ if (childController.controlType === "facade") {
25122
+ return false;
25123
+ }
25124
+ if (childController.isProxy) {
25125
+ return false;
25126
+ }
26453
25127
  if (childController.allowNameless) {
26454
25128
  // A control saying it is not a field is not the one the picker talks
26455
25129
  // to: the search box above the list, the "select all" switch beside
@@ -26900,8 +25574,9 @@ const useControlProps = (props, {
26900
25574
  props.id = props.id || controlId || idDefault;
26901
25575
  const controlName = useContext(ControlNameContext);
26902
25576
  props.name = props.name || controlName;
25577
+ const isCheckable = isCheckableInput(controlType, props.type);
26903
25578
  const toDomProps = newUIState => {
26904
- if (controlType === "input" && (props.type === "radio" || props.type === "checkbox")) {
25579
+ if (isCheckable) {
26905
25580
  const domValue = toDomValue(props.value, {
26906
25581
  controlType,
26907
25582
  id: props.id,
@@ -27280,8 +25955,7 @@ const useControlProps = (props, {
27280
25955
  }
27281
25956
  return keyDownDefault(e);
27282
25957
  };
27283
- const isInputCheckable = controlType === "input" && (props.type === "radio" || props.type === "checkbox");
27284
- if (isInputCheckable) {
25958
+ if (isCheckable) {
27285
25959
  const isRadio = props.type === "radio";
27286
25960
 
27287
25961
  // I've decided that enter on radio/checkbox would not submit form like browser does but
@@ -27296,7 +25970,6 @@ const useControlProps = (props, {
27296
25970
  keyDown: e => {
27297
25971
  if (e.key === "Enter") {
27298
25972
  const inputEl = ref.current;
27299
- const isRadio = props.type === "radio";
27300
25973
  const checked = inputEl.checked;
27301
25974
  const always = () => {
27302
25975
  if (inputEl.form) {
@@ -27319,18 +25992,9 @@ const useControlProps = (props, {
27319
25992
  always
27320
25993
  };
27321
25994
  }
27322
- if (checked) {
27323
- return {
27324
- name: "enter to uncheck checkbox",
27325
- allowed: () => dispatchRequestSetUIState(inputEl, undefined, {
27326
- event: e
27327
- }),
27328
- always
27329
- };
27330
- }
27331
25995
  return {
27332
- name: "enter to check checkbox",
27333
- allowed: () => dispatchRequestSetUIState(inputEl, uiStateController.value, {
25996
+ name: checked ? "enter to uncheck checkbox" : "enter to check checkbox",
25997
+ allowed: () => dispatchRequestSetUIState(inputEl, checked ? undefined : uiStateController.value, {
27334
25998
  event: e
27335
25999
  }),
27336
26000
  always
@@ -27465,7 +26129,6 @@ const useControlProps = (props, {
27465
26129
  // Same for radio siblings: when a sibling check unchecks this radio
27466
26130
  // (radio_sibling_uncheck, internal event, no synthetic input), lastActionValueRef
27467
26131
  // keeps the stale value and blocks the user from re-checking this radio.
27468
- const isCheckable = controlType === "input" && (props.type === "radio" || props.type === "checkbox");
27469
26132
  if (!isCheckable) {
27470
26133
  const lastActionValue = lastActionValueRef.current;
27471
26134
  const valueSameAsLastAction = lastActionValue !== NO_ACTION_YET && compareTwoJsValues(currentValue, lastActionValue);
@@ -27537,11 +26200,16 @@ const useControlProps = (props, {
27537
26200
  // a custom concept being combination of "input", "change" and may other events
27538
26201
  // this even if trigerred when value changes and can be controlled by actionDebounce and actionAfterChange
27539
26202
  const hasNaviChangeEventReaction = Boolean(eventReactionDefinitions?.naviChange || defaultEventReactionDefinitions?.naviChange);
26203
+ // The input effect is installed once per element/options while the reaction
26204
+ // closures (boundAction, custom reactions) are per-render: read through a
26205
+ // ref so the effect always fires the current render's reaction.
26206
+ const applyEventReactionRef = useRef();
26207
+ applyEventReactionRef.current = applyEventReaction;
27540
26208
  const refCallback = useCallback(field => {
27541
26209
  if (!hasNaviChangeEventReaction || actionEvent === "custom") {
27542
26210
  return undefined;
27543
26211
  }
27544
- return addInputEffect(field, e => applyEventReaction("naviChange", e), {
26212
+ return addInputEffect(field, e => applyEventReactionRef.current("naviChange", e), {
27545
26213
  waitForChange: actionAfterChange,
27546
26214
  debounce: actionDebounce,
27547
26215
  debugInteraction
@@ -27685,36 +26353,13 @@ const createControlInfo = (props, {
27685
26353
  } else {
27686
26354
  statePropName = "value";
27687
26355
  defaultStatePropName = "defaultValue";
27688
- if (signal) {
27689
- if (Object.hasOwn(props, "defaultValue")) {
27690
- // resolveInputProps seeds defaultValue from a bound signal's default,
27691
- // so an input+signal is uncontrolled-with-default; the signal only
27692
- // receives write-backs (onUIAction).
27693
- hasStateProp = false;
27694
- // A signal holding something wins over the default: `defaultValue` is
27695
- // a suggestion of what to start from (and what a reset goes back to),
27696
- // not an answer — while the signal's value IS the answer, restored
27697
- // from the url or set by whoever owns it. Taking the default here
27698
- // would show a suggestion in place of the value on every reload.
27699
- stateInitial = signal.value !== undefined ? signal.value : props.defaultValue;
27700
- stateFromSignal = stateInitial;
27701
- } else {
27702
- // A plain bound signal with no default (e.g. Wheel): its live value
27703
- // seeds and controls the state.
27704
- hasStateProp = true;
27705
- value = signal.value;
27706
- stateInitial = value;
27707
- }
27708
- } else if (Object.hasOwn(props, "value")) {
27709
- hasStateProp = true;
27710
- value = props.value;
27711
- stateInitial = value;
27712
- } else if (Object.hasOwn(props, "defaultValue")) {
27713
- hasStateProp = false;
27714
- stateInitial = props.defaultValue;
27715
- } else {
27716
- hasStateProp = false;
27717
- stateInitial = undefined;
26356
+ ({
26357
+ hasStateProp,
26358
+ stateInitial,
26359
+ stateFromSignal
26360
+ } = resolveValueState(props, controlType, signal));
26361
+ if (hasStateProp) {
26362
+ value = stateInitial;
27718
26363
  }
27719
26364
  readOnlySupported = INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
27720
26365
  }
@@ -27731,27 +26376,11 @@ const createControlInfo = (props, {
27731
26376
  } else if (controlType === "picker" || controlType === "select") {
27732
26377
  statePropName = "value";
27733
26378
  defaultStatePropName = "defaultValue";
27734
- if (signal) {
27735
- if (Object.hasOwn(props, "defaultValue")) {
27736
- hasStateProp = false;
27737
- // The signal's value is the answer, defaultValue only the suggestion to
27738
- // start from.
27739
- stateInitial = signal.value !== undefined ? signal.value : props.defaultValue;
27740
- stateFromSignal = stateInitial;
27741
- } else {
27742
- hasStateProp = true;
27743
- stateInitial = signal.value;
27744
- }
27745
- } else if (Object.hasOwn(props, "value")) {
27746
- hasStateProp = true;
27747
- stateInitial = props.value;
27748
- } else if (Object.hasOwn(props, "defaultValue")) {
27749
- hasStateProp = false;
27750
- stateInitial = props.defaultValue;
27751
- } else {
27752
- hasStateProp = false;
27753
- stateInitial = undefined;
27754
- }
26379
+ ({
26380
+ hasStateProp,
26381
+ stateInitial,
26382
+ stateFromSignal
26383
+ } = resolveValueState(props, controlType, signal));
27755
26384
  disabledSupported = true;
27756
26385
  // A native <select> has no readonly attribute. What says it is read-only is
27757
26386
  // aria-readonly plus a refused interaction — see the select reactions in
@@ -27786,6 +26415,52 @@ const createControlInfo = (props, {
27786
26415
  disabledSupported
27787
26416
  };
27788
26417
  };
26418
+ // Who says what a value-holding control is worth — a bound signal, a `value`,
26419
+ // a `defaultValue` — resolved the same way for every control holding one value
26420
+ // (text input, picker, select). The checkbox/radio branch has its own
26421
+ // resolution: `checked` speaks in booleans and translates to the value.
26422
+ const resolveValueState = (props, controlType, signal) => {
26423
+ if (signal) {
26424
+ if (Object.hasOwn(props, "defaultValue")) {
26425
+ // A bound signal's own default is seeded into `defaultValue` (see
26426
+ // resolveInputProps), so such a control is uncontrolled-with-default;
26427
+ // the signal only receives write-backs (onUIAction).
26428
+ // A signal holding something wins over the default: `defaultValue` is
26429
+ // a suggestion of what to start from (and what a reset goes back to),
26430
+ // not an answer — while the signal's value IS the answer, restored
26431
+ // from the url or set by whoever owns it. Taking the default here
26432
+ // would show a suggestion in place of the value on every reload.
26433
+ const stateInitial = signal.value !== undefined ? signal.value : props.defaultValue;
26434
+ return {
26435
+ hasStateProp: false,
26436
+ stateInitial,
26437
+ stateFromSignal: stateInitial
26438
+ };
26439
+ }
26440
+ // A plain bound signal with no default (e.g. Wheel): its live value
26441
+ // seeds and controls the state.
26442
+ return {
26443
+ hasStateProp: true,
26444
+ stateInitial: signal.value
26445
+ };
26446
+ }
26447
+ if (Object.hasOwn(props, "value")) {
26448
+ return {
26449
+ hasStateProp: true,
26450
+ stateInitial: props.value
26451
+ };
26452
+ }
26453
+ if (Object.hasOwn(props, "defaultValue")) {
26454
+ return {
26455
+ hasStateProp: false,
26456
+ stateInitial: props.defaultValue
26457
+ };
26458
+ }
26459
+ return {
26460
+ hasStateProp: false,
26461
+ stateInitial: undefined
26462
+ };
26463
+ };
27789
26464
  // color, radio, image, file etc do not support readonly
27790
26465
  const INPUT_TYPE_SUPPORTING_READONLY_SET = new Set(["text", "date", "datetime-local", "email", "month", "number", "password", "search", "tel", "time", "url", "week"]);
27791
26466
  // Who, if anyone, is listening to what this control is worth: a handler of its
@@ -27804,6 +26479,7 @@ const useIsControlListenedTo = props => {
27804
26479
  };
27805
26480
  const useReadOnlyUncontrolled = (props, controlInfo) => {
27806
26481
  const listenedTo = useIsControlListenedTo(props);
26482
+ useRef(false);
27807
26483
  if (!controlInfo.hasStateProp) {
27808
26484
  return false;
27809
26485
  }
@@ -27858,6 +26534,7 @@ const useControlgroupProps = (props, {
27858
26534
  // can't change. A form or a group around it IS someone listening — that is
27859
26535
  // what will send the value and hand a new one back.
27860
26536
  const listenedTo = useIsControlListenedTo(props);
26537
+ useRef(false);
27861
26538
  const implicitReadOnly = uiGroupStateController.hasValueProp && !listenedTo;
27862
26539
  if (implicitReadOnly && !props.readOnly) {
27863
26540
  props.readOnly = true;
@@ -27891,9 +26568,9 @@ const useControlgroupProps = (props, {
27891
26568
  return [controlRootProps, {
27892
26569
  ...controlgroupProps,
27893
26570
  "name": undefined,
27894
- // useful to children, not the the group itself
26571
+ // useful to children, not the group itself
27895
26572
  "required": undefined,
27896
- // useful to children, not the the group itself
26573
+ // useful to children, not the group itself
27897
26574
  // How many items the group accepts, read by its controller and by the
27898
26575
  // children asking whether there is still room for them. Not an attribute
27899
26576
  // any element wears: a <fieldset maxlength> means nothing.
@@ -27906,29 +26583,6 @@ const useControlgroupProps = (props, {
27906
26583
  }, controlgroupChildrenWrapperProps];
27907
26584
  };
27908
26585
 
27909
- /**
27910
- * Like `useControlProps` but also establishes a 1:1 facade sync between the
27911
- * picker's hidden input and the first child control inside the picker popup.
27912
- *
27913
- * Child → picker input: when the child's UI state changes, the picker input
27914
- * is updated automatically (no `command="--navi-update"` needed on the child).
27915
- *
27916
- * Picker input → child: when the picker input is updated externally (e.g.
27917
- * via `--navi-update` or `--navi-clear` from outside), the change is
27918
- * propagated down to the child automatically.
27919
- *
27920
- * Returns a 3-tuple `[controlRootProps, controlHostProps, facadeChildrenProps]`.
27921
- * Use `ControlFacadeChildrenWrapper` with the third element to wrap the popup
27922
- * children — it resets field contexts and injects the facade controller:
27923
- *
27924
- * ```jsx
27925
- * const [controlRootProps, controlHostProps, facadeChildrenProps] = useControlFacadeProps(props, options);
27926
- * // …
27927
- * <ControlFacadeChildrenWrapper {...facadeChildrenProps}>
27928
- * {children}
27929
- * </ControlFacadeChildrenWrapper>
27930
- * ```
27931
- */
27932
26586
  /**
27933
26587
  * What a control holds, read from the control itself.
27934
26588
  *
@@ -27975,6 +26629,30 @@ const useControlUIState = (ref, uiStateInitial) => {
27975
26629
  }, [ref]);
27976
26630
  return uiState;
27977
26631
  };
26632
+
26633
+ /**
26634
+ * Like `useControlProps` but also establishes a 1:1 facade sync between the
26635
+ * picker's hidden input and the first child control inside the picker popup.
26636
+ *
26637
+ * Child → picker input: when the child's UI state changes, the picker input
26638
+ * is updated automatically (no `command="--navi-update"` needed on the child).
26639
+ *
26640
+ * Picker input → child: when the picker input is updated externally (e.g.
26641
+ * via `--navi-update` or `--navi-clear` from outside), the change is
26642
+ * propagated down to the child automatically.
26643
+ *
26644
+ * Returns a 3-tuple `[controlRootProps, controlHostProps, facadeChildrenProps]`.
26645
+ * Use `ControlFacadeChildrenWrapper` with the third element to wrap the popup
26646
+ * children — it resets field contexts and injects the facade controller:
26647
+ *
26648
+ * ```jsx
26649
+ * const [controlRootProps, controlHostProps, facadeChildrenProps] = useControlFacadeProps(props, options);
26650
+ * // …
26651
+ * <ControlFacadeChildrenWrapper {...facadeChildrenProps}>
26652
+ * {children}
26653
+ * </ControlFacadeChildrenWrapper>
26654
+ * ```
26655
+ */
27978
26656
  const useControlFacadeProps = (props, options) => {
27979
26657
  const [controlRootProps, controlHostProps, {
27980
26658
  uiStateController
@@ -28001,21 +26679,9 @@ const useControlFacadeProps = (props, options) => {
28001
26679
  const ControlFacadeChildrenWrapper = ({
28002
26680
  children,
28003
26681
  facadeController
28004
- }) => jsx(ParentUIStateControllerContext.Provider, {
28005
- value: facadeController,
28006
- children: jsx(MessagePropsRefContext.Provider, {
28007
- value: undefined,
28008
- children: jsx(ControlIdContext.Provider, {
28009
- value: undefined,
28010
- children: jsx(RequiredContext.Provider, {
28011
- value: undefined,
28012
- children: jsx(ControlNameContext.Provider, {
28013
- value: undefined,
28014
- children: children
28015
- })
28016
- })
28017
- })
28018
- })
26682
+ }) => jsx(ControlChildrenWrapper, {
26683
+ uiStateController: facadeController,
26684
+ children: children
28019
26685
  });
28020
26686
  const useInteractiveProps = (props, {
28021
26687
  uiStateController,
@@ -28162,7 +26828,7 @@ const useInteractiveProps = (props, {
28162
26828
  }, []);
28163
26829
  }
28164
26830
  {
28165
- const isCheckable = uiStateController.controlType === "input" && (props.type === "radio" || props.type === "checkbox");
26831
+ const isCheckable = isCheckableInput(uiStateController.controlType, props.type);
28166
26832
  Object.assign(controlHostProps, {
28167
26833
  onnavi_clear_ui_state: e => {
28168
26834
  uiStateController.clearUIState(e);
@@ -28395,8 +27061,7 @@ const splitControlProps = props => {
28395
27061
  ref
28396
27062
  };
28397
27063
  const controlRootProps = {};
28398
- const propKeySet = new Set(Object.keys(props));
28399
- for (const key of propKeySet) {
27064
+ for (const key of Object.keys(props)) {
28400
27065
  if (CONTROL_PROP_SET.has(key)) {
28401
27066
  if (CONTROL_ATTRIBUTE_SET.has(key)) {
28402
27067
  controlHostProps[key] = props[key];
@@ -28407,6 +27072,7 @@ const splitControlProps = props => {
28407
27072
  }
28408
27073
  return [controlRootProps, controlHostProps];
28409
27074
  };
27075
+ const isCheckableInput = (controlType, typeProp) => controlType === "input" && (typeProp === "radio" || typeProp === "checkbox");
28410
27076
 
28411
27077
  // The labels the DOM itself can hand over: a wrapping <label>, or a label[for]
28412
27078
  // pointing at a native form element. Everything else — a label[for] on a
@@ -29413,19 +28079,6 @@ const createOpenController = (
29413
28079
  findEvent(requestOpenEvent, isTouchDrivenEvent),
29414
28080
  );
29415
28081
  const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
29416
- // A popup is READ before it is reached wherever the keyboard is a
29417
- // virtual one. Landing on the first focusable there costs the top of
29418
- // the popup twice over: the browser scrolls that element into view,
29419
- // and a field raises a keyboard that takes a third of what is left —
29420
- // so the title and the sentence saying what this is about are gone
29421
- // before the popup has been looked at. Only something that ASKED for
29422
- // the focus is worth that, and asking is what `autoFocus` is.
29423
- //
29424
- // The device, not the opening (unlike the delay below): whether
29425
- // focusing raises a keyboard over the popup is true of the screen,
29426
- // and a popup opened by the page loading — no pointer in it at all —
29427
- // is precisely the one that must not be answered "no keyboard here".
29428
- skipFirstFocusable: coarsePointerSignal.value,
29429
28082
  getDelay: (target) =>
29430
28083
  openedByTouch && isEditableTarget(target)
29431
28084
  ? FOCUS_DELAY_ON_KEYBOARD_MS