@jsenv/navi 0.29.87 → 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
- continue;
3791
- }
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);
3362
+ continue;
3363
+ }
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
  }
@@ -25013,6 +23657,22 @@ const useUIStateController = (
25013
23657
  const isProxy = Boolean(props["navi-control-proxy-for"]);
25014
23658
  const emptyUIState = resolveEmptyUIState(props, controlType);
25015
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
+
25016
23676
  const scope = useRenderScope(
25017
23677
  // ── init: runs once on mount ───────────────────────────────────────────
25018
23678
  // Creates the controller and all long-lived objects. Captures first-render
@@ -25099,7 +23759,9 @@ const useUIStateController = (
25099
23759
  if (controller.facadeChild) {
25100
23760
  const child = controller.facadeChild;
25101
23761
  const childManaged = child.getManagedControls();
25102
- if (childManaged.length > 0) return childManaged;
23762
+ if (childManaged.length > 0) {
23763
+ return childManaged;
23764
+ }
25103
23765
  return [child];
25104
23766
  }
25105
23767
  return [];
@@ -25221,7 +23883,9 @@ const useUIStateController = (
25221
23883
  // set immediatly (don't wait for preact re-render) so ui is in the right state for:
25222
23884
  // - side effect
25223
23885
  // - any "input" event that might be dispatched below
25224
- 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);
25225
23889
  controller.uiState = newUIState;
25226
23890
  ownUIStateSignal.value = newUIState;
25227
23891
  const controlProxyFor =
@@ -25242,7 +23906,9 @@ const useUIStateController = (
25242
23906
  );
25243
23907
  chainEvent(siblingUncheckEvent, e);
25244
23908
  for (const siblingController of siblings) {
25245
- if (siblingController === controller) continue;
23909
+ if (siblingController === controller) {
23910
+ continue;
23911
+ }
25246
23912
  if (
25247
23913
  siblingController.parentUIStateController !==
25248
23914
  s.parentUIStateController
@@ -25454,10 +24120,8 @@ const useUIStateController = (
25454
24120
  resetUIState: (e) => {
25455
24121
  controller.setUIState(controller.state, e);
25456
24122
  },
25457
- onActionEnd: async (e) => {
24123
+ onActionEnd: (e) => {
25458
24124
  debugUIState(`"${controlType}" actionEnd called`);
25459
- // wait for preact to re-render to update readonly as action end side effects are runned
25460
- // await new Promise((r) => requestAnimationFrame(r));
25461
24125
  controller.rules.validation.syncValidity(e);
25462
24126
  },
25463
24127
  onActionError: (e) => {
@@ -25493,20 +24157,10 @@ const useUIStateController = (
25493
24157
  });
25494
24158
  controller.rules = rules;
25495
24159
 
25496
- // Include all values that controller methods read from the scope so they
25497
- // are available immediately — even if no re-render happens before the
25498
- // first user interaction (update only runs on re-renders, not on mount).
25499
24160
  return {
25500
24161
  controller,
25501
- ref: props.ref,
25502
- id: props.id,
25503
- name: props.name,
25504
- props,
25505
- controlInfo,
25506
- uiAction: props.uiAction,
25507
- uiActionInternal,
25508
- parentUIStateController,
25509
24162
  parentUiStateSignalHolder,
24163
+ ...liveValues(),
25510
24164
  };
25511
24165
  },
25512
24166
  // ── update: runs every render after the first ─────────────────────────
@@ -25567,16 +24221,7 @@ const useUIStateController = (
25567
24221
  }
25568
24222
  }
25569
24223
  }
25570
- return {
25571
- ref: props.ref,
25572
- id: props.id,
25573
- name: props.name,
25574
- props,
25575
- controlInfo,
25576
- uiAction: props.uiAction,
25577
- uiActionInternal,
25578
- parentUIStateController,
25579
- };
24224
+ return liveValues();
25580
24225
  },
25581
24226
  );
25582
24227
  scope.parentUiStateSignalHolder.value =
@@ -25607,11 +24252,13 @@ const useUIStateController = (
25607
24252
  return undefined;
25608
24253
  }
25609
24254
 
25610
- debugUIState(`"${controlType}" registering into "${parent.controlType}"`);
24255
+ debugUIState(
24256
+ `"${controlType}" registering into "${parentController.controlType}"`,
24257
+ );
25611
24258
  parentController.registerChild(controller);
25612
24259
  return () => {
25613
24260
  debugUIState(
25614
- `"${controlType}" unregistering from "${parent.controlType}"`,
24261
+ `"${controlType}" unregistering from "${parentController.controlType}"`,
25615
24262
  );
25616
24263
  parentController.unregisterChild(controller);
25617
24264
  };
@@ -25620,42 +24267,8 @@ const useUIStateController = (
25620
24267
  return controller;
25621
24268
  };
25622
24269
 
25623
- /**
25624
- * Manages the aggregated UI state of a group of child controls (radio list, checkbox list, etc.).
25625
- *
25626
- * Children register themselves automatically on mount and unregister on unmount.
25627
- * Whenever a child fires a UI action, the group re-aggregates all child states
25628
- * via `aggregateChildStates` and reacts accordingly.
25629
- *
25630
- * **Three distinct methods — each with a clear responsibility**:
25631
- *
25632
- * - `setUIState(newUIState, e)` — called when a child UI action **changes** the aggregated value.
25633
- * Updates the group state, then calls `onUIAction(e)` for user-observable reactions
25634
- * (uiAction, command), then dispatches `navi_ui_state_change` so `control_hooks.jsx`
25635
- * can trigger the action pipeline (constraints → execute action).
25636
- *
25637
- * - `syncInternalState(newUIState)` — called silently during mount/unmount/render-batch.
25638
- * Updates state and signal with no external reactions whatsoever.
25639
- *
25640
- * - `onUIAction(e)` — called when a child's UI action does **not** change the aggregated
25641
- * value (e.g. re-clicking an already-selected radio). Fires `uiAction` + `command` only;
25642
- * does not touch state, does not trigger the action pipeline.
25643
- *
25644
- * **Child UI action flow**:
25645
- * 1. Child leaf fires `notifyParentAboutChildUIAction(e, { stateChanged })`.
25646
- * 2. Group's `onChildUIAction` receives it.
25647
- * - If `stateChanged=true`: re-aggregates → `setUIState` → full reactions + action pipeline.
25648
- * - If `stateChanged=false`: calls `onUIAction` → uiAction + command only.
25649
- *
25650
- * **Filtering**: `childControlFilter` can exclude certain child types from aggregation
25651
- * (e.g. ignoring buttons inside a selectable list).
25652
- */
25653
- const CANNOT_DERIVE = Symbol("cannot_derive");
25654
-
25655
- // Default aggregate/distribute implementations keyed by controlType or stateType.
25656
- // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
25657
- // distributeChildUIState. If neither a default nor an explicit impl is found for a
25658
- // group, creation throws so the caller knows it must supply them.
24270
+ const CANNOT_DERIVE = Symbol("cannot_derive");
24271
+
25659
24272
  // A child that groups other controls and was given no name of its own, holding
25660
24273
  // an object — the only shape that can be merged into the object around it.
25661
24274
  // A nameless LEAF (an input nobody named) is still a mistake and still warns.
@@ -25665,19 +24278,25 @@ const isNamelessGrouping = (child, uiState) =>
25665
24278
  typeof uiState === "object" &&
25666
24279
  !Array.isArray(uiState);
25667
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.
25668
24295
  const GROUP_DEFAULTS = {
25669
24296
  radio_group: {
25670
24297
  childControlFilter: (child) =>
25671
24298
  child.controlType === "input" && child.controlHostProps?.type === "radio",
25672
- aggregateChildStates: (children) => {
25673
- for (const child of children) {
25674
- const childUIState = child.uiState;
25675
- if (childUIState !== undefined) {
25676
- return childUIState;
25677
- }
25678
- }
25679
- return undefined;
25680
- },
24299
+ aggregateChildStates: firstDefinedChildUIState,
25681
24300
  distributeChildUIState: (newUIState, childUIStateController) => {
25682
24301
  const childSelected = childUIStateController.props.value === newUIState;
25683
24302
  if (childSelected) {
@@ -25734,15 +24353,7 @@ const GROUP_DEFAULTS = {
25734
24353
  }
25735
24354
  return true;
25736
24355
  },
25737
- aggregateChildStates: (children) => {
25738
- for (const child of children) {
25739
- const childUIState = child.uiState;
25740
- if (childUIState !== undefined) {
25741
- return childUIState;
25742
- }
25743
- }
25744
- return undefined;
25745
- },
24356
+ aggregateChildStates: firstDefinedChildUIState,
25746
24357
  distributeChildUIState: (newUIState) => newUIState,
25747
24358
  },
25748
24359
  object: {
@@ -25811,6 +24422,36 @@ const GROUP_DEFAULTS = {
25811
24422
  },
25812
24423
  };
25813
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
+ */
25814
24455
  const useUIGroupStateController = (
25815
24456
  props,
25816
24457
  controlType,
@@ -25890,7 +24531,9 @@ const useUIGroupStateController = (
25890
24531
  pendingChangeRef.current = null;
25891
24532
 
25892
24533
  const isMonitoringChild = (childUIStateController) => {
25893
- if (childUIStateController.isProxy) return false;
24534
+ if (childUIStateController.isProxy) {
24535
+ return false;
24536
+ }
25894
24537
  if (
25895
24538
  resolvedChildControlFilter &&
25896
24539
  !resolvedChildControlFilter(childUIStateController)
@@ -25900,12 +24543,39 @@ const useUIGroupStateController = (
25900
24543
  return true;
25901
24544
  };
25902
24545
  const shouldPropagateStateToChild = (childUIStateController) => {
25903
- if (!isMonitoringChild(childUIStateController)) return false;
25904
- if (childUIStateController.controlType === "button") return false;
25905
- 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
+ }
25906
24555
  return true;
25907
24556
  };
25908
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
+
25909
24579
  const scope = useRenderScope(
25910
24580
  // ── init: runs once on mount ───────────────────────────────────────────
25911
24581
  (s) => {
@@ -25915,6 +24585,14 @@ const useUIGroupStateController = (
25915
24585
  const [publishUIState, subscribeUIState] = createPubSub();
25916
24586
  const uiStateSignal = signal(fallbackState);
25917
24587
 
24588
+ const aggregateGroupUIState = () => {
24589
+ const aggChildState = resolvedAggregateChildStates(
24590
+ childUIStateControllerArray,
24591
+ fallbackState,
24592
+ );
24593
+ return aggChildState === undefined ? fallbackState : aggChildState;
24594
+ };
24595
+
25918
24596
  // onChange and applyState live inside init so they close over the stable
25919
24597
  // signals/pubsub without needing external refs.
25920
24598
  const onChange = (e, { notifyExternal }) => {
@@ -25933,12 +24611,7 @@ const useUIGroupStateController = (
25933
24611
  };
25934
24612
  return;
25935
24613
  }
25936
- const aggChildState = resolvedAggregateChildStates(
25937
- childUIStateControllerArray,
25938
- fallbackState,
25939
- );
25940
- const groupUIState =
25941
- aggChildState === undefined ? fallbackState : aggChildState;
24614
+ const groupUIState = aggregateGroupUIState();
25942
24615
  debugUIGroup(
25943
24616
  e,
25944
24617
  `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
@@ -25947,13 +24620,13 @@ const useUIGroupStateController = (
25947
24620
  if (notifyExternal === true) {
25948
24621
  applyState(groupUIState, e);
25949
24622
  } else if (notifyExternal === "silent") {
25950
- controller.syncInternalState(groupUIState, e);
24623
+ controller.syncInternalState(groupUIState);
25951
24624
  s.parentUIStateController?.onChildUIAction(controller, e, {
25952
24625
  stateChanged: true,
25953
24626
  silent: true,
25954
24627
  });
25955
24628
  } else {
25956
- controller.syncInternalState(groupUIState, e);
24629
+ controller.syncInternalState(groupUIState);
25957
24630
  writeBoundSignal(groupUIState);
25958
24631
  }
25959
24632
  };
@@ -26073,12 +24746,7 @@ const useUIGroupStateController = (
26073
24746
  propagateDownEvent,
26074
24747
  );
26075
24748
  }
26076
- const aggChildState = resolvedAggregateChildStates(
26077
- childUIStateControllerArray,
26078
- fallbackState,
26079
- );
26080
- const groupUIState =
26081
- aggChildState === undefined ? fallbackState : aggChildState;
24749
+ const groupUIState = aggregateGroupUIState();
26082
24750
  if (e.type === "initial_state_push") {
26083
24751
  controller.syncInternalState(groupUIState);
26084
24752
  return;
@@ -26090,7 +24758,9 @@ const useUIGroupStateController = (
26090
24758
  },
26091
24759
  syncInternalState: (newUIState) => {
26092
24760
  const currentUIState = controller.uiState;
26093
- if (newUIState === currentUIState) return;
24761
+ if (newUIState === currentUIState) {
24762
+ return;
24763
+ }
26094
24764
  controller.uiState = newUIState;
26095
24765
  uiStateSignal.value = newUIState;
26096
24766
  publishUIState(newUIState);
@@ -26108,7 +24778,9 @@ const useUIGroupStateController = (
26108
24778
  s.uiActionInternal?.(currentUIState, e);
26109
24779
  if (!skipCommand && controller.props.command) {
26110
24780
  const el = controller.ref.current;
26111
- if (el) triggerNaviCommand(el, controller.props.command, e);
24781
+ if (el) {
24782
+ triggerNaviCommand(el, controller.props.command, e);
24783
+ }
26112
24784
  }
26113
24785
  },
26114
24786
  registerChild: (childUIStateController) => {
@@ -26159,7 +24831,9 @@ const useUIGroupStateController = (
26159
24831
  });
26160
24832
  return;
26161
24833
  }
26162
- if (!isMonitoringChild(childUIStateController)) return;
24834
+ if (!isMonitoringChild(childUIStateController)) {
24835
+ return;
24836
+ }
26163
24837
  const childControlType = childUIStateController.controlType;
26164
24838
  debugUIGroup(
26165
24839
  `${controlType}.onChildUIAction("${childControlType}") stateChanged=${stateChanged} -> child state: ${JSON.stringify(
@@ -26181,7 +24855,9 @@ const useUIGroupStateController = (
26181
24855
  delegatedTo.unregisterChild(childUIStateController);
26182
24856
  return;
26183
24857
  }
26184
- if (!isMonitoringChild(childUIStateController)) return;
24858
+ if (!isMonitoringChild(childUIStateController)) {
24859
+ return;
24860
+ }
26185
24861
  const childControlType = childUIStateController.controlType;
26186
24862
  const index = childUIStateControllerArray.indexOf(
26187
24863
  childUIStateController,
@@ -26194,7 +24870,7 @@ const useUIGroupStateController = (
26194
24870
  }
26195
24871
  childUIStateControllerArray.splice(index, 1);
26196
24872
  debugUIGroup(
26197
- `${controlType}.unregisterChild("${childControlType}") -> unregisteed (remaining: ${childUIStateControllerArray.length})`,
24873
+ `${controlType}.unregisterChild("${childControlType}") -> unregistered (remaining: ${childUIStateControllerArray.length})`,
26198
24874
  );
26199
24875
  onChange(new CustomEvent(`${childControlType}_unmount`), {
26200
24876
  notifyExternal: "silent",
@@ -26206,7 +24882,9 @@ const useUIGroupStateController = (
26206
24882
  });
26207
24883
  chainEvent(ev, e);
26208
24884
  for (const c of childUIStateControllerArray) {
26209
- if (shouldPropagateStateToChild(c)) c.resetUIState(ev);
24885
+ if (shouldPropagateStateToChild(c)) {
24886
+ c.resetUIState(ev);
24887
+ }
26210
24888
  }
26211
24889
  onChange(e, { notifyExternal: true });
26212
24890
  },
@@ -26254,13 +24932,17 @@ const useUIGroupStateController = (
26254
24932
  },
26255
24933
  findChildById: (searchId) => {
26256
24934
  for (const c of childUIStateControllerArray) {
26257
- if (c.id === searchId) return c;
24935
+ if (c.id === searchId) {
24936
+ return c;
24937
+ }
26258
24938
  }
26259
24939
  return null;
26260
24940
  },
26261
24941
  getChildControllers: () => childUIStateControllerArray,
26262
24942
  getManagedControls: () => {
26263
- if (!cascadeValidationToChildren) return [];
24943
+ if (!cascadeValidationToChildren) {
24944
+ return [];
24945
+ }
26264
24946
  return childUIStateControllerArray.slice();
26265
24947
  },
26266
24948
  // Group children sit next to the group itself: a busy one really does
@@ -26278,20 +24960,10 @@ const useUIGroupStateController = (
26278
24960
  });
26279
24961
  controller.rules = rules;
26280
24962
 
26281
- // Include all values read by controller methods so they are immediately
26282
- // available, even if the user interacts before the first re-render.
26283
24963
  return {
26284
24964
  controller,
26285
24965
  _onChange: onChange,
26286
- ref,
26287
- parentUIStateController,
26288
- uiAction,
26289
- uiActionInternal,
26290
- // `props` is what writeBoundSignal reads to find the bound `signal`.
26291
- // Missing here, a group whose component never re-renders between mount
26292
- // and the first choice wrote nothing back into its signal — and said
26293
- // nothing about it: the list showed the choice, the signal stayed empty.
26294
- props,
24966
+ ...liveValues(),
26295
24967
  };
26296
24968
  },
26297
24969
  // ── update: runs every render after the first ─────────────────────────
@@ -26308,10 +24980,7 @@ const useUIGroupStateController = (
26308
24980
  controller.defaultValue = defaultValue;
26309
24981
  controller.hasValueProp = hasValueProp;
26310
24982
  controller.hasDefaultValueProp = hasDefaultValueProp;
26311
- if (
26312
- hasValueProp &&
26313
- (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
26314
- ) {
24983
+ const placeChildrenFrom = (groupUIState) => {
26315
24984
  const propagateDownEvent = new CustomEvent(
26316
24985
  "propagate_down_set_ui_state",
26317
24986
  { detail: {} },
@@ -26319,11 +24988,17 @@ const useUIGroupStateController = (
26319
24988
  for (const childUIStateController of childUIStateControllerArray) {
26320
24989
  controller.placeChildUIState(
26321
24990
  childUIStateController,
26322
- value,
24991
+ groupUIState,
26323
24992
  propagateDownEvent,
26324
24993
  );
26325
24994
  }
26326
- controller.syncInternalState(value);
24995
+ controller.syncInternalState(groupUIState);
24996
+ };
24997
+ if (
24998
+ hasValueProp &&
24999
+ (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
25000
+ ) {
25001
+ placeChildrenFrom(value);
26327
25002
  }
26328
25003
  if (
26329
25004
  boundSignal &&
@@ -26334,33 +25009,10 @@ const useUIGroupStateController = (
26334
25009
  // again, exactly as they were when they registered. Without this a
26335
25010
  // group would answer a write to its own signal by silently writing its
26336
25011
  // former value back over it on the next child interaction.
26337
- const propagateDownEvent = new CustomEvent(
26338
- "propagate_down_set_ui_state",
26339
- { detail: {} },
26340
- );
26341
- for (const childUIStateController of childUIStateControllerArray) {
26342
- controller.placeChildUIState(
26343
- childUIStateController,
26344
- defaultValue,
26345
- propagateDownEvent,
26346
- );
26347
- }
26348
- controller.syncInternalState(defaultValue);
25012
+ placeChildrenFrom(defaultValue);
26349
25013
  }
26350
25014
 
26351
- return {
26352
- ref,
26353
- parentUIStateController,
26354
- uiAction,
26355
- uiActionInternal,
26356
- id,
26357
- name,
26358
- value,
26359
- defaultValue,
26360
- hasValueProp,
26361
- hasDefaultValueProp,
26362
- props,
26363
- };
25015
+ return liveValues();
26364
25016
  },
26365
25017
  );
26366
25018
 
@@ -26371,6 +25023,9 @@ const useUIGroupStateController = (
26371
25023
  el.__uiStateController__ = controller;
26372
25024
  }
26373
25025
  return () => {
25026
+ if (el && el.__uiStateController__ === controller) {
25027
+ delete el.__uiStateController__;
25028
+ }
26374
25029
  onUIStateControllerDestroyed(controller);
26375
25030
  };
26376
25031
  }, []);
@@ -26457,10 +25112,18 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26457
25112
  // ── init: runs once on mount ───────────────────────────────────────────
26458
25113
  (s) => {
26459
25114
  const canRegisterAsFacadeChild = (childController) => {
26460
- if (childController.controlType === "button") return false;
26461
- if (childController.controlType === "link") return false;
26462
- if (childController.controlType === "facade") return false;
26463
- 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
+ }
26464
25127
  if (childController.allowNameless) {
26465
25128
  // A control saying it is not a field is not the one the picker talks
26466
25129
  // to: the search box above the list, the "select all" switch beside
@@ -26911,8 +25574,9 @@ const useControlProps = (props, {
26911
25574
  props.id = props.id || controlId || idDefault;
26912
25575
  const controlName = useContext(ControlNameContext);
26913
25576
  props.name = props.name || controlName;
25577
+ const isCheckable = isCheckableInput(controlType, props.type);
26914
25578
  const toDomProps = newUIState => {
26915
- if (controlType === "input" && (props.type === "radio" || props.type === "checkbox")) {
25579
+ if (isCheckable) {
26916
25580
  const domValue = toDomValue(props.value, {
26917
25581
  controlType,
26918
25582
  id: props.id,
@@ -27291,8 +25955,7 @@ const useControlProps = (props, {
27291
25955
  }
27292
25956
  return keyDownDefault(e);
27293
25957
  };
27294
- const isInputCheckable = controlType === "input" && (props.type === "radio" || props.type === "checkbox");
27295
- if (isInputCheckable) {
25958
+ if (isCheckable) {
27296
25959
  const isRadio = props.type === "radio";
27297
25960
 
27298
25961
  // I've decided that enter on radio/checkbox would not submit form like browser does but
@@ -27307,7 +25970,6 @@ const useControlProps = (props, {
27307
25970
  keyDown: e => {
27308
25971
  if (e.key === "Enter") {
27309
25972
  const inputEl = ref.current;
27310
- const isRadio = props.type === "radio";
27311
25973
  const checked = inputEl.checked;
27312
25974
  const always = () => {
27313
25975
  if (inputEl.form) {
@@ -27330,18 +25992,9 @@ const useControlProps = (props, {
27330
25992
  always
27331
25993
  };
27332
25994
  }
27333
- if (checked) {
27334
- return {
27335
- name: "enter to uncheck checkbox",
27336
- allowed: () => dispatchRequestSetUIState(inputEl, undefined, {
27337
- event: e
27338
- }),
27339
- always
27340
- };
27341
- }
27342
25995
  return {
27343
- name: "enter to check checkbox",
27344
- allowed: () => dispatchRequestSetUIState(inputEl, uiStateController.value, {
25996
+ name: checked ? "enter to uncheck checkbox" : "enter to check checkbox",
25997
+ allowed: () => dispatchRequestSetUIState(inputEl, checked ? undefined : uiStateController.value, {
27345
25998
  event: e
27346
25999
  }),
27347
26000
  always
@@ -27476,7 +26129,6 @@ const useControlProps = (props, {
27476
26129
  // Same for radio siblings: when a sibling check unchecks this radio
27477
26130
  // (radio_sibling_uncheck, internal event, no synthetic input), lastActionValueRef
27478
26131
  // keeps the stale value and blocks the user from re-checking this radio.
27479
- const isCheckable = controlType === "input" && (props.type === "radio" || props.type === "checkbox");
27480
26132
  if (!isCheckable) {
27481
26133
  const lastActionValue = lastActionValueRef.current;
27482
26134
  const valueSameAsLastAction = lastActionValue !== NO_ACTION_YET && compareTwoJsValues(currentValue, lastActionValue);
@@ -27548,11 +26200,16 @@ const useControlProps = (props, {
27548
26200
  // a custom concept being combination of "input", "change" and may other events
27549
26201
  // this even if trigerred when value changes and can be controlled by actionDebounce and actionAfterChange
27550
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;
27551
26208
  const refCallback = useCallback(field => {
27552
26209
  if (!hasNaviChangeEventReaction || actionEvent === "custom") {
27553
26210
  return undefined;
27554
26211
  }
27555
- return addInputEffect(field, e => applyEventReaction("naviChange", e), {
26212
+ return addInputEffect(field, e => applyEventReactionRef.current("naviChange", e), {
27556
26213
  waitForChange: actionAfterChange,
27557
26214
  debounce: actionDebounce,
27558
26215
  debugInteraction
@@ -27696,36 +26353,13 @@ const createControlInfo = (props, {
27696
26353
  } else {
27697
26354
  statePropName = "value";
27698
26355
  defaultStatePropName = "defaultValue";
27699
- if (signal) {
27700
- if (Object.hasOwn(props, "defaultValue")) {
27701
- // resolveInputProps seeds defaultValue from a bound signal's default,
27702
- // so an input+signal is uncontrolled-with-default; the signal only
27703
- // receives write-backs (onUIAction).
27704
- hasStateProp = false;
27705
- // A signal holding something wins over the default: `defaultValue` is
27706
- // a suggestion of what to start from (and what a reset goes back to),
27707
- // not an answer — while the signal's value IS the answer, restored
27708
- // from the url or set by whoever owns it. Taking the default here
27709
- // would show a suggestion in place of the value on every reload.
27710
- stateInitial = signal.value !== undefined ? signal.value : props.defaultValue;
27711
- stateFromSignal = stateInitial;
27712
- } else {
27713
- // A plain bound signal with no default (e.g. Wheel): its live value
27714
- // seeds and controls the state.
27715
- hasStateProp = true;
27716
- value = signal.value;
27717
- stateInitial = value;
27718
- }
27719
- } else if (Object.hasOwn(props, "value")) {
27720
- hasStateProp = true;
27721
- value = props.value;
27722
- stateInitial = value;
27723
- } else if (Object.hasOwn(props, "defaultValue")) {
27724
- hasStateProp = false;
27725
- stateInitial = props.defaultValue;
27726
- } else {
27727
- hasStateProp = false;
27728
- stateInitial = undefined;
26356
+ ({
26357
+ hasStateProp,
26358
+ stateInitial,
26359
+ stateFromSignal
26360
+ } = resolveValueState(props, controlType, signal));
26361
+ if (hasStateProp) {
26362
+ value = stateInitial;
27729
26363
  }
27730
26364
  readOnlySupported = INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
27731
26365
  }
@@ -27742,27 +26376,11 @@ const createControlInfo = (props, {
27742
26376
  } else if (controlType === "picker" || controlType === "select") {
27743
26377
  statePropName = "value";
27744
26378
  defaultStatePropName = "defaultValue";
27745
- if (signal) {
27746
- if (Object.hasOwn(props, "defaultValue")) {
27747
- hasStateProp = false;
27748
- // The signal's value is the answer, defaultValue only the suggestion to
27749
- // start from.
27750
- stateInitial = signal.value !== undefined ? signal.value : props.defaultValue;
27751
- stateFromSignal = stateInitial;
27752
- } else {
27753
- hasStateProp = true;
27754
- stateInitial = signal.value;
27755
- }
27756
- } else if (Object.hasOwn(props, "value")) {
27757
- hasStateProp = true;
27758
- stateInitial = props.value;
27759
- } else if (Object.hasOwn(props, "defaultValue")) {
27760
- hasStateProp = false;
27761
- stateInitial = props.defaultValue;
27762
- } else {
27763
- hasStateProp = false;
27764
- stateInitial = undefined;
27765
- }
26379
+ ({
26380
+ hasStateProp,
26381
+ stateInitial,
26382
+ stateFromSignal
26383
+ } = resolveValueState(props, controlType, signal));
27766
26384
  disabledSupported = true;
27767
26385
  // A native <select> has no readonly attribute. What says it is read-only is
27768
26386
  // aria-readonly plus a refused interaction — see the select reactions in
@@ -27797,6 +26415,52 @@ const createControlInfo = (props, {
27797
26415
  disabledSupported
27798
26416
  };
27799
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
+ };
27800
26464
  // color, radio, image, file etc do not support readonly
27801
26465
  const INPUT_TYPE_SUPPORTING_READONLY_SET = new Set(["text", "date", "datetime-local", "email", "month", "number", "password", "search", "tel", "time", "url", "week"]);
27802
26466
  // Who, if anyone, is listening to what this control is worth: a handler of its
@@ -27815,6 +26479,7 @@ const useIsControlListenedTo = props => {
27815
26479
  };
27816
26480
  const useReadOnlyUncontrolled = (props, controlInfo) => {
27817
26481
  const listenedTo = useIsControlListenedTo(props);
26482
+ useRef(false);
27818
26483
  if (!controlInfo.hasStateProp) {
27819
26484
  return false;
27820
26485
  }
@@ -27869,6 +26534,7 @@ const useControlgroupProps = (props, {
27869
26534
  // can't change. A form or a group around it IS someone listening — that is
27870
26535
  // what will send the value and hand a new one back.
27871
26536
  const listenedTo = useIsControlListenedTo(props);
26537
+ useRef(false);
27872
26538
  const implicitReadOnly = uiGroupStateController.hasValueProp && !listenedTo;
27873
26539
  if (implicitReadOnly && !props.readOnly) {
27874
26540
  props.readOnly = true;
@@ -27902,9 +26568,9 @@ const useControlgroupProps = (props, {
27902
26568
  return [controlRootProps, {
27903
26569
  ...controlgroupProps,
27904
26570
  "name": undefined,
27905
- // useful to children, not the the group itself
26571
+ // useful to children, not the group itself
27906
26572
  "required": undefined,
27907
- // useful to children, not the the group itself
26573
+ // useful to children, not the group itself
27908
26574
  // How many items the group accepts, read by its controller and by the
27909
26575
  // children asking whether there is still room for them. Not an attribute
27910
26576
  // any element wears: a <fieldset maxlength> means nothing.
@@ -27917,29 +26583,6 @@ const useControlgroupProps = (props, {
27917
26583
  }, controlgroupChildrenWrapperProps];
27918
26584
  };
27919
26585
 
27920
- /**
27921
- * Like `useControlProps` but also establishes a 1:1 facade sync between the
27922
- * picker's hidden input and the first child control inside the picker popup.
27923
- *
27924
- * Child → picker input: when the child's UI state changes, the picker input
27925
- * is updated automatically (no `command="--navi-update"` needed on the child).
27926
- *
27927
- * Picker input → child: when the picker input is updated externally (e.g.
27928
- * via `--navi-update` or `--navi-clear` from outside), the change is
27929
- * propagated down to the child automatically.
27930
- *
27931
- * Returns a 3-tuple `[controlRootProps, controlHostProps, facadeChildrenProps]`.
27932
- * Use `ControlFacadeChildrenWrapper` with the third element to wrap the popup
27933
- * children — it resets field contexts and injects the facade controller:
27934
- *
27935
- * ```jsx
27936
- * const [controlRootProps, controlHostProps, facadeChildrenProps] = useControlFacadeProps(props, options);
27937
- * // …
27938
- * <ControlFacadeChildrenWrapper {...facadeChildrenProps}>
27939
- * {children}
27940
- * </ControlFacadeChildrenWrapper>
27941
- * ```
27942
- */
27943
26586
  /**
27944
26587
  * What a control holds, read from the control itself.
27945
26588
  *
@@ -27986,6 +26629,30 @@ const useControlUIState = (ref, uiStateInitial) => {
27986
26629
  }, [ref]);
27987
26630
  return uiState;
27988
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
+ */
27989
26656
  const useControlFacadeProps = (props, options) => {
27990
26657
  const [controlRootProps, controlHostProps, {
27991
26658
  uiStateController
@@ -28012,21 +26679,9 @@ const useControlFacadeProps = (props, options) => {
28012
26679
  const ControlFacadeChildrenWrapper = ({
28013
26680
  children,
28014
26681
  facadeController
28015
- }) => jsx(ParentUIStateControllerContext.Provider, {
28016
- value: facadeController,
28017
- children: jsx(MessagePropsRefContext.Provider, {
28018
- value: undefined,
28019
- children: jsx(ControlIdContext.Provider, {
28020
- value: undefined,
28021
- children: jsx(RequiredContext.Provider, {
28022
- value: undefined,
28023
- children: jsx(ControlNameContext.Provider, {
28024
- value: undefined,
28025
- children: children
28026
- })
28027
- })
28028
- })
28029
- })
26682
+ }) => jsx(ControlChildrenWrapper, {
26683
+ uiStateController: facadeController,
26684
+ children: children
28030
26685
  });
28031
26686
  const useInteractiveProps = (props, {
28032
26687
  uiStateController,
@@ -28173,7 +26828,7 @@ const useInteractiveProps = (props, {
28173
26828
  }, []);
28174
26829
  }
28175
26830
  {
28176
- const isCheckable = uiStateController.controlType === "input" && (props.type === "radio" || props.type === "checkbox");
26831
+ const isCheckable = isCheckableInput(uiStateController.controlType, props.type);
28177
26832
  Object.assign(controlHostProps, {
28178
26833
  onnavi_clear_ui_state: e => {
28179
26834
  uiStateController.clearUIState(e);
@@ -28406,8 +27061,7 @@ const splitControlProps = props => {
28406
27061
  ref
28407
27062
  };
28408
27063
  const controlRootProps = {};
28409
- const propKeySet = new Set(Object.keys(props));
28410
- for (const key of propKeySet) {
27064
+ for (const key of Object.keys(props)) {
28411
27065
  if (CONTROL_PROP_SET.has(key)) {
28412
27066
  if (CONTROL_ATTRIBUTE_SET.has(key)) {
28413
27067
  controlHostProps[key] = props[key];
@@ -28418,6 +27072,7 @@ const splitControlProps = props => {
28418
27072
  }
28419
27073
  return [controlRootProps, controlHostProps];
28420
27074
  };
27075
+ const isCheckableInput = (controlType, typeProp) => controlType === "input" && (typeProp === "radio" || typeProp === "checkbox");
28421
27076
 
28422
27077
  // The labels the DOM itself can hand over: a wrapping <label>, or a label[for]
28423
27078
  // pointing at a native form element. Everything else — a label[for] on a