@jsenv/navi 0.29.87 → 0.29.89

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 };
2874
+ const resolvedParams = { ...providedParams };
2881
2875
 
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
- }
2894
-
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".
3030
+ *
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").
3044
3042
  *
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.
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
- }
3069
- }
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;
3087
- }
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;
3088
3060
  }
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;
3103
- return (
3104
- effectiveValue === literalValue && conn.isCustomValue(effectiveValue)
3105
- );
3106
- });
3107
- if (parentCanProvide) {
3108
- return true;
3109
- }
3110
-
3111
- // Check user-provided parameters
3112
- const userCanProvide = Object.entries(params).some(
3113
- ([, value]) => value === literalValue,
3114
- );
3115
- if (userCanProvide) {
3116
- return true;
3117
- }
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;
3127
- }
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,
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,
3153
3073
  );
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
-
3196
- continue;
3197
- }
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
+ };
3198
3084
  }
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
- );
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
+ };
3206
3096
  }
3207
- return { isCompatible: false, childParams: {} };
3208
- }
3209
- }
3210
-
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
- );
3097
+ ancestor = ancestor.parent;
3231
3098
  }
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}'`,
3238
- );
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);
3239
3117
  }
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
3118
  };
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,
3119
+ visit(patternObject);
3120
+ return (
3121
+ found || {
3122
+ connection: undefined,
3123
+ segmentIndex: undefined,
3124
+ owner: "extra",
3125
+ }
3303
3126
  );
3127
+ };
3304
3128
 
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
- };
3129
+ for (const [name, value] of Object.entries(explicitParams)) {
3130
+ const { connection, segmentIndex, owner } = findFamilyConnection(name);
3131
+ setEntry(name, value, connection, segmentIndex, owner, true);
3320
3132
  }
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,
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,
3332
3144
  );
3333
- if (hasConflictingLiteral) {
3334
- return { isCompatible: false };
3335
- }
3336
3145
  }
3337
3146
  }
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,
3345
- };
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) {
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)) {
3374
3151
  continue;
3375
3152
  }
3376
- const siblingSignalValue = readSignalForUrlBuild(siblingConnection);
3377
- if (siblingSignalValue === undefined) {
3153
+ const conn = ancestor.pathConnectionMap.get(seg.name);
3154
+ if (!conn) {
3378
3155
  continue;
3379
3156
  }
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
- }
3395
- return false;
3157
+ const selfSeg = parsedPattern.segments[seg.index];
3158
+ if (selfSeg && selfSeg.type === "literal") {
3159
+ setEntry(seg.name, selfSeg.value, conn, seg.index, "ancestor", false);
3396
3160
  }
3397
3161
  }
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}"`,
3431
- );
3432
- }
3433
- break;
3162
+ for (const [name, conn] of ancestor.queryConnectionMap) {
3163
+ if (intended.has(name)) {
3164
+ continue;
3434
3165
  }
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;
3442
- }
3166
+ const value = readSignalForUrlBuild(conn);
3167
+ if (value !== undefined && conn.isCustomValue(value)) {
3168
+ setEntry(name, value, conn, undefined, "ancestor", false);
3443
3169
  }
3444
3170
  }
3171
+ ancestor = ancestor.parent;
3445
3172
  }
3446
3173
 
3447
- // Block incompatible child routes immediately
3448
- if (hasIncompatibleLiterals) {
3449
- return false;
3450
- }
3451
-
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
3471
- }
3472
- if (!connection.namedByLiteralRoutes) {
3473
- return false;
3474
- }
3475
- return connection.getDefaultValue() !== undefined;
3174
+ const intendedValueAt = (name) => {
3175
+ const entry = intended.get(name);
3176
+ return entry ? entry.value : undefined;
3476
3177
  };
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;
3178
+ const isDescendantReachable = (descendant) => {
3179
+ const selfSegments = parsedPattern.segments;
3180
+ for (const dSeg of descendant.pattern.segments) {
3181
+ if (dSeg.type !== "literal") {
3182
+ continue;
3498
3183
  }
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;
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;
3509
3195
  }
3196
+ continue;
3510
3197
  }
3511
- }
3512
- }
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") {
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);
3520
3204
  return (
3521
- segment.optional || initialMergedParams[segment.name] !== undefined
3205
+ entry &&
3206
+ entry.value !== undefined &&
3207
+ conn.isCustomValue(entry.value) &&
3208
+ String(entry.value) === dSeg.value
3522
3209
  );
3210
+ });
3211
+ if (justifiedBySignal) {
3212
+ continue;
3523
3213
  }
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;
3214
+ const justifiedByExplicit = [...intended.values()].some(
3215
+ (entry) => entry.explicit && String(entry.value) === dSeg.value,
3216
+ );
3217
+ if (!justifiedByExplicit) {
3218
+ return false;
3540
3219
  }
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;
3220
+ }
3221
+ return true;
3222
+ };
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") {
3231
+ continue;
3548
3232
  }
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;
3233
+ const selfSeg = selfSegments[dSeg.index];
3234
+ if (selfSeg) {
3235
+ if (selfSeg.type === "literal") {
3236
+ if (selfSeg.value !== dSeg.value) {
3237
+ return false;
3620
3238
  }
3239
+ continue;
3621
3240
  }
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
- }
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;
3652
3251
  }
3252
+ continue;
3653
3253
  }
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;
3254
+ const connsAtPosition = patternObject.descendantPathSignals.get(
3255
+ dSeg.index,
3256
+ );
3257
+ if (!connsAtPosition || connsAtPosition.length === 0) {
3258
+ return false;
3259
+ }
3260
+ }
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;
3676
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,
3302
+ );
3303
+ continue;
3304
+ }
3305
+ if (!queryReachable || value === undefined) {
3306
+ continue;
3307
+ }
3308
+ if (conn.isCustomValue(value)) {
3309
+ setEntry(paramName, value, conn, undefined, "descendant", false);
3677
3310
  }
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
3311
  }
3312
+ visitDescendants(child);
3683
3313
  }
3684
- }
3685
-
3686
- if (DEBUG$2 && shouldUse) {
3687
- console.debug(
3688
- `[${pattern}] Will use child route ${childPatternObj.originalPattern}`,
3689
- );
3690
- }
3314
+ };
3315
+ visitDescendants(patternObject);
3691
3316
 
3692
- return shouldUse;
3317
+ return { intended, reachableDescendants };
3693
3318
  };
3694
3319
 
3695
3320
  /**
3696
- * Helper: Build URL for selected child route with proper parameter filtering
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").
3697
3327
  */
3698
- const buildChildRouteUrl = (
3699
- childPatternObj,
3700
- params,
3701
- parentResolvedParams = {},
3328
+ const buildCandidateUrl = (
3329
+ candidate,
3330
+ intended,
3331
+ { dropMissing, lenient } = {},
3702
3332
  ) => {
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
- }
3333
+ const urlParams = {};
3334
+ const candidateSegments = candidate.pattern.segments;
3335
+ for (const seg of candidateSegments) {
3336
+ if (seg.type !== "param") {
3337
+ continue;
3775
3338
  }
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) {
3339
+ const entry = intended.get(seg.name);
3340
+ if (!entry || entry.value === undefined) {
3790
3341
  continue;
3791
3342
  }
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
3343
+ const meaningful = entry.connection
3344
+ ? entry.connection.isCustomValue(entry.value)
3345
+ : true;
3346
+ if (meaningful) {
3347
+ urlParams[seg.name] = entry.value;
3799
3348
  }
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
3349
+ }
3350
+ for (const [name, entry] of intended) {
3351
+ if (name in urlParams) {
3352
+ continue;
3808
3353
  }
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
3354
+ if (entry.value === undefined) {
3355
+ continue;
3818
3356
  }
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;
3357
+ const conn = entry.connection;
3358
+ if (!conn) {
3359
+ if (entry.explicit) {
3360
+ urlParams[name] = entry.value;
3848
3361
  }
3362
+ continue;
3849
3363
  }
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
-
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];
3900
3372
  if (
3901
- childSegment.type === "literal" &&
3902
- parentSegment &&
3903
- parentSegment.type === "param"
3373
+ literalSeg &&
3374
+ literalSeg.type === "literal" &&
3375
+ literalSeg.value === String(entry.value)
3904
3376
  ) {
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
- );
3377
+ continue; // the candidate's path itself encodes this value
3934
3378
  }
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
- }
3379
+ if (entry.explicit && entry.owner === "ancestor") {
3380
+ urlParams[name] = entry.value;
3381
+ continue;
3955
3382
  }
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
- }
3383
+ if (entry.pageNaming) {
3384
+ // deliberately left out: this url does not follow the signal
3385
+ continue;
3971
3386
  }
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;
3387
+ if (lenient) {
3388
+ if (entry.explicit) {
3389
+ urlParams[name] = entry.value;
3978
3390
  }
3391
+ continue;
3979
3392
  }
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`);
3393
+ return null;
4069
3394
  }
4070
- return bestAncestorUrl;
3395
+ urlParams[name] = entry.value;
4071
3396
  }
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,
3397
+ let buildPattern = candidate.pattern;
3398
+ if (dropMissing) {
3399
+ const keptSegments = candidateSegments.filter(
3400
+ (seg) => seg.type !== "param" || seg.name in urlParams,
4085
3401
  );
4086
- if (deepestDescendantUrl) {
4087
- // Take the first valid deepest descendant we find (or keep deepest among multiple)
4088
- if (!bestDescendantUrl) {
4089
- bestDescendantUrl = deepestDescendantUrl;
3402
+ if (keptSegments.length !== candidateSegments.length) {
3403
+ buildPattern = { ...buildPattern, segments: keptSegments };
3404
+ if (buildPattern.trailingSlash) {
3405
+ buildPattern.trailingSlash = false;
4090
3406
  }
4091
3407
  }
4092
3408
  }
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) {
3409
+ const url = buildUrlFromPattern(
3410
+ buildPattern,
3411
+ urlParams,
3412
+ candidate.originalPattern,
3413
+ candidate,
3414
+ );
3415
+ if (url.includes("/:")) {
4120
3416
  return null;
4121
3417
  }
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;
3418
+ return url;
4141
3419
  };
4142
3420
 
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;
3421
+ const urlValueEquals = (intendedValue, reproducedValue) => {
3422
+ let a = intendedValue;
3423
+ if (a && typeof a === "object" && a[rawUrlPartSymbol]) {
3424
+ a = a.value;
4151
3425
  }
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
- }
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}`;
4175
3431
  }
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;
3432
+ if (compareTwoJsValues(a, reproducedValue)) {
3433
+ return true;
4190
3434
  }
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
- }
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;
4226
3439
  }
4227
-
4228
- return hasMatchingPathOptimization;
3440
+ return String(a) === String(reproducedValue);
4229
3441
  };
4230
3442
 
4231
3443
  /**
4232
- * Helper: Try to use an ancestor route (only immediate parent for parameter optimization)
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.
4233
3448
  */
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
- );
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;
4247
3458
  }
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
- }
3459
+ seen.add(patternObj);
3460
+ familyPatterns.push(patternObj);
3461
+ for (const child of patternObj.children) {
3462
+ visit(child);
4296
3463
  }
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;
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) {
3483
+ continue;
4304
3484
  }
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`,
3485
+ const holdsParam =
3486
+ familyPattern.queryConnectionMap.has(name) ||
3487
+ familyPattern.pattern.segments.some(
3488
+ (seg) => seg.type === "param" && seg.name === name,
4313
3489
  );
3490
+ if (!holdsParam) {
3491
+ continue;
3492
+ }
3493
+ if (name in matchResult && matchResult[name] !== undefined) {
3494
+ reproducedValue = matchResult[name];
3495
+ extracted = true;
3496
+ break;
4314
3497
  }
4315
- return null;
4316
3498
  }
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
- );
3499
+ if (!extracted) {
3500
+ reproducedValue = conn.getDefaultValue();
4330
3501
  }
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) {
3502
+ const wantedValue =
3503
+ entry.value === undefined ? conn.getDefaultValue() : entry.value;
3504
+ if (!urlValueEquals(wantedValue, reproducedValue)) {
4338
3505
  return false;
4339
3506
  }
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
3507
  }
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;
3508
+ return true;
4388
3509
  };
4389
3510
 
4390
3511
  /**
4391
- * Helper: Check if current literal route can be optimized to target ancestor
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
4392
3522
  */
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,
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)
4419
3528
  );
4420
- console.debug(
4421
- `[${pattern}] tryDirectOptimization: targetParams:`,
4422
- targetParams,
3529
+ };
3530
+ if (ancestorPattern !== patternObject.parent) {
3531
+ return (
3532
+ connections.length === 0 &&
3533
+ parsedPattern.segments.every((seg) => seg.type === "literal")
4423
3534
  );
4424
3535
  }
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
- );
3536
+ let literalsPinDefaults = false;
3537
+ for (const seg of ancestorPattern.pattern.segments) {
3538
+ if (seg.type !== "param") {
3539
+ continue;
4463
3540
  }
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
- );
3541
+ const conn = ancestorPattern.pathConnectionMap.get(seg.name);
3542
+ const selfSeg = parsedPattern.segments[seg.index];
3543
+ if (!conn || !selfSeg || selfSeg.type !== "literal") {
3544
+ continue;
4474
3545
  }
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;
3546
+ if (selfSeg.value !== String(conn.getDefaultValue())) {
3547
+ literalsPinDefaults = false;
3548
+ break;
4491
3549
  }
3550
+ literalsPinDefaults = true;
4492
3551
  }
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,
3552
+ if (literalsPinDefaults) {
3553
+ return !connections.some(
3554
+ (conn) =>
3555
+ !conn.inherited &&
3556
+ conn.paramType === "path" &&
3557
+ entryIsMeaningful(conn),
4501
3558
  );
4502
3559
  }
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),
3560
+ return !connections.some(
3561
+ (conn) => !conn.inherited && entryIsMeaningful(conn),
4520
3562
  );
3563
+ };
4521
3564
 
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
- }
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;
4562
3574
  }
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
- }
3575
+ const url = buildCandidateUrl(ancestor, intended, {
3576
+ dropMissing: true,
3577
+ });
3578
+ if (!url || !verifyUrl(url, intended)) {
3579
+ break;
4574
3580
  }
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`,
3581
+ debug$3(
3582
+ `[${pattern}] ancestor url ${url} (via ${ancestor.originalPattern})`,
4581
3583
  );
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);
4648
- if (
4649
- signalValue !== undefined &&
4650
- connection.isCustomValue(signalValue)
4651
- ) {
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;
4660
- }
4661
- }
4662
- }
4663
-
4664
- // Move up the parent chain
4665
- currentParent = currentParent.parent;
3584
+ ancestorUrl = url;
3585
+ ancestor = ancestor.parent;
4666
3586
  }
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;
4691
- }
4692
-
4693
- // Check if we should use this descendant
4694
- const shouldUse = shouldUseChildRoute(
4695
- descendantPatternObj,
4696
- params,
4697
- compatibility,
4698
- parentResolvedParams,
4699
- );
4700
- if (!shouldUse) {
4701
- return null;
3587
+ if (ancestorUrl) {
3588
+ return ancestorUrl;
4702
3589
  }
4703
-
4704
- // Build descendant URL using buildUrl (not buildMostPreciseUrl) to prevent recursion
4705
- return buildChildRouteUrl(
4706
- descendantPatternObj,
4707
- params,
4708
- parentResolvedParams,
4709
- );
4710
- };
4711
-
4712
- /**
4713
- * Helper: Inherit query parameters from parent patterns
4714
- */
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") {
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)) {
4725
3602
  continue;
4726
3603
  }
4727
- const { paramName } = parentConnection;
4728
- if (paramName in finalParams) {
4729
- continue; // Already have this parameter
4730
- }
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
- }
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;
4747
3612
  }
4748
3613
  }
4749
- // Move to the next parent up the chain
4750
- currentParent = currentParent.parent;
4751
- }
4752
- };
4753
-
4754
- /**
4755
- * Helper: Build URL for current route with filtered pattern
4756
- */
4757
- const buildCurrentRouteUrl = (finalParams) => {
4758
- if (!parsedPattern.segments) {
4759
- return "/";
3614
+ break;
4760
3615
  }
4761
-
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;
4768
- }
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;
3616
+ if (descendantUrl) {
3617
+ return descendantUrl;
4779
3618
  }
4780
-
4781
- return buildUrlFromPattern(
4782
- filteredPattern,
4783
- finalParams,
4784
- pattern,
4785
- patternObject,
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
24270
  const CANNOT_DERIVE = Symbol("cannot_derive");
25654
24271
 
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.
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: {
@@ -25770,6 +24381,13 @@ const GROUP_DEFAULTS = {
25770
24381
  ? emptyUIState
25771
24382
  : child.uiState;
25772
24383
  if (!name) {
24384
+ if (allowNameless) {
24385
+ // A control that says it is not a field is not one, whatever it
24386
+ // holds: a picker used as a door holds the shape its popup draws,
24387
+ // and merging that in would put the popup's keys in the object as
24388
+ // if the door had been a group.
24389
+ continue;
24390
+ }
25773
24391
  // A nameless GROUP is a grouping, not a value: it exists to hold its
25774
24392
  // children together (a WheelGroup sharing navigation, a fieldset-ish
25775
24393
  // cluster) without claiming a key of its own, so what it holds is
@@ -25779,12 +24397,10 @@ const GROUP_DEFAULTS = {
25779
24397
  Object.assign(groupValues, uiState);
25780
24398
  continue;
25781
24399
  }
25782
- if (!allowNameless) {
25783
- console.warn(
25784
- "A group child is missing a name property, its state won't be included in the group state",
25785
- child,
25786
- );
25787
- }
24400
+ console.warn(
24401
+ "A group child is missing a name property, its state won't be included in the group state",
24402
+ child,
24403
+ );
25788
24404
  continue;
25789
24405
  }
25790
24406
  groupValues[name] = uiState;
@@ -25811,6 +24427,36 @@ const GROUP_DEFAULTS = {
25811
24427
  },
25812
24428
  };
25813
24429
 
24430
+ /**
24431
+ * Manages the aggregated UI state of a group of child controls (radio list, checkbox list, etc.).
24432
+ *
24433
+ * Children register themselves automatically on mount and unregister on unmount.
24434
+ * Whenever a child fires a UI action, the group re-aggregates all child states
24435
+ * via `aggregateChildStates` and reacts accordingly.
24436
+ *
24437
+ * **Three distinct methods — each with a clear responsibility**:
24438
+ *
24439
+ * - `setUIState(newUIState, e)` — called when a child UI action **changes** the aggregated value.
24440
+ * Updates the group state, then calls `onUIAction(e)` for user-observable reactions
24441
+ * (uiAction, command), then dispatches `navi_ui_state_change` so `control_hooks.jsx`
24442
+ * can trigger the action pipeline (constraints → execute action).
24443
+ *
24444
+ * - `syncInternalState(newUIState)` — called silently during mount/unmount/render-batch.
24445
+ * Updates state and signal with no external reactions whatsoever.
24446
+ *
24447
+ * - `onUIAction(e)` — called when a child's UI action does **not** change the aggregated
24448
+ * value (e.g. re-clicking an already-selected radio). Fires `uiAction` + `command` only;
24449
+ * does not touch state, does not trigger the action pipeline.
24450
+ *
24451
+ * **Child UI action flow**:
24452
+ * 1. Child leaf fires `notifyParentAboutChildUIAction(e, { stateChanged })`.
24453
+ * 2. Group's `onChildUIAction` receives it.
24454
+ * - If `stateChanged=true`: re-aggregates → `setUIState` → full reactions + action pipeline.
24455
+ * - If `stateChanged=false`: calls `onUIAction` → uiAction + command only.
24456
+ *
24457
+ * **Filtering**: `childControlFilter` can exclude certain child types from aggregation
24458
+ * (e.g. ignoring buttons inside a selectable list).
24459
+ */
25814
24460
  const useUIGroupStateController = (
25815
24461
  props,
25816
24462
  controlType,
@@ -25879,6 +24525,14 @@ const useUIGroupStateController = (
25879
24525
  : stateType === "object"
25880
24526
  ? EMPTY_OBJECT
25881
24527
  : undefined;
24528
+ // A group told what it holds holds it from the start, before any child has
24529
+ // registered to show it: what it was given is the answer, and the children
24530
+ // are where that answer is shown (see stateGivenFromAbove).
24531
+ const stateInitial = hasValueProp
24532
+ ? value
24533
+ : hasDefaultValueProp && defaultValue !== undefined
24534
+ ? defaultValue
24535
+ : fallbackState;
25882
24536
  const childUIStateControllerArrayRef = useRef([]);
25883
24537
  const childUIStateControllerArray = childUIStateControllerArrayRef.current;
25884
24538
  // Tracks children rejected by the filter and delegated upward (bubble-up).
@@ -25890,7 +24544,9 @@ const useUIGroupStateController = (
25890
24544
  pendingChangeRef.current = null;
25891
24545
 
25892
24546
  const isMonitoringChild = (childUIStateController) => {
25893
- if (childUIStateController.isProxy) return false;
24547
+ if (childUIStateController.isProxy) {
24548
+ return false;
24549
+ }
25894
24550
  if (
25895
24551
  resolvedChildControlFilter &&
25896
24552
  !resolvedChildControlFilter(childUIStateController)
@@ -25900,12 +24556,39 @@ const useUIGroupStateController = (
25900
24556
  return true;
25901
24557
  };
25902
24558
  const shouldPropagateStateToChild = (childUIStateController) => {
25903
- if (!isMonitoringChild(childUIStateController)) return false;
25904
- if (childUIStateController.controlType === "button") return false;
25905
- if (childUIStateController.controlType === "link") return false;
24559
+ if (!isMonitoringChild(childUIStateController)) {
24560
+ return false;
24561
+ }
24562
+ if (childUIStateController.controlType === "button") {
24563
+ return false;
24564
+ }
24565
+ if (childUIStateController.controlType === "link") {
24566
+ return false;
24567
+ }
25906
24568
  return true;
25907
24569
  };
25908
24570
 
24571
+ // Live values controller methods read through the scope (`s.…`) — same
24572
+ // contract as the leaf controller's liveValues above: one list feeding both
24573
+ // init and update.
24574
+ const liveValues = () => ({
24575
+ ref,
24576
+ parentUIStateController,
24577
+ uiAction,
24578
+ uiActionInternal,
24579
+ id,
24580
+ name,
24581
+ value,
24582
+ defaultValue,
24583
+ hasValueProp,
24584
+ hasDefaultValueProp,
24585
+ // `props` is what writeBoundSignal reads to find the bound `signal`.
24586
+ // Missing here, a group whose component never re-renders between mount
24587
+ // and the first choice wrote nothing back into its signal — and said
24588
+ // nothing about it: the list showed the choice, the signal stayed empty.
24589
+ props,
24590
+ });
24591
+
25909
24592
  const scope = useRenderScope(
25910
24593
  // ── init: runs once on mount ───────────────────────────────────────────
25911
24594
  (s) => {
@@ -25913,7 +24596,36 @@ const useUIGroupStateController = (
25913
24596
  `Creating "${controlType}" ui state controller (monitoring some descendants ui state(s))"`,
25914
24597
  );
25915
24598
  const [publishUIState, subscribeUIState] = createPubSub();
25916
- const uiStateSignal = signal(fallbackState);
24599
+ const uiStateSignal = signal(stateInitial);
24600
+
24601
+ // What the group is worth right now, and what it keeps when there is
24602
+ // nobody to ask: a list whose items have not arrived yet, a popup built
24603
+ // at open, a group whose children are still mounting. Such a group has
24604
+ // no opinion — its aggregate falls back to the empty of its type, and
24605
+ // taking that for an answer is how a value handed to it evaporates on
24606
+ // the way in, and how that emptiness then travels back up to whoever
24607
+ // handed it (a picker showing its row as unanswered).
24608
+ const aggregateGroupUIState = (whenNobodyCanAnswer) => {
24609
+ const someChildCanAnswer = childUIStateControllerArray.some(
24610
+ shouldPropagateStateToChild,
24611
+ );
24612
+ if (!someChildCanAnswer) {
24613
+ return whenNobodyCanAnswer;
24614
+ }
24615
+ const aggChildState = resolvedAggregateChildStates(
24616
+ childUIStateControllerArray,
24617
+ fallbackState,
24618
+ );
24619
+ if (aggChildState !== undefined) {
24620
+ return aggChildState;
24621
+ }
24622
+ // A group with an aggregate of its own is the one who knows what its
24623
+ // children add up to, `undefined` included — half a time is not a time,
24624
+ // wheels nobody turned have settled nothing. Only the default shapes
24625
+ // fall back to the empty of their type, where "no child says anything"
24626
+ // and "the value is empty" are the same sentence.
24627
+ return stateShapeIsTheDefaultOne ? fallbackState : undefined;
24628
+ };
25917
24629
 
25918
24630
  // onChange and applyState live inside init so they close over the stable
25919
24631
  // signals/pubsub without needing external refs.
@@ -25933,27 +24645,35 @@ const useUIGroupStateController = (
25933
24645
  };
25934
24646
  return;
25935
24647
  }
25936
- const aggChildState = resolvedAggregateChildStates(
25937
- childUIStateControllerArray,
25938
- fallbackState,
25939
- );
24648
+ const { controller } = s;
24649
+ // A child mounting or unmounting is not somebody answering: while the
24650
+ // children of a group are still arriving, their aggregate is a partial
24651
+ // reading, and taking it for the truth is how the value the group was
24652
+ // given gets destroyed one row at a time — the first row to register
24653
+ // aggregates alone, the group drops to that, and every row after it is
24654
+ // placed from what is left. A group that derived its own value has
24655
+ // nothing to protect and aggregates as usual.
25940
24656
  const groupUIState =
25941
- aggChildState === undefined ? fallbackState : aggChildState;
24657
+ notifyExternal === "silent" && controller.stateGivenFromAbove
24658
+ ? controller.uiState
24659
+ : aggregateGroupUIState(controller.uiState);
25942
24660
  debugUIGroup(
25943
24661
  e,
25944
24662
  `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
25945
24663
  );
25946
- const { controller } = s;
25947
24664
  if (notifyExternal === true) {
24665
+ // Somebody answered: what the group is worth is what its children say
24666
+ // between them, from here on.
24667
+ controller.stateGivenFromAbove = false;
25948
24668
  applyState(groupUIState, e);
25949
24669
  } else if (notifyExternal === "silent") {
25950
- controller.syncInternalState(groupUIState, e);
24670
+ controller.syncInternalState(groupUIState);
25951
24671
  s.parentUIStateController?.onChildUIAction(controller, e, {
25952
24672
  stateChanged: true,
25953
24673
  silent: true,
25954
24674
  });
25955
24675
  } else {
25956
- controller.syncInternalState(groupUIState, e);
24676
+ controller.syncInternalState(groupUIState);
25957
24677
  writeBoundSignal(groupUIState);
25958
24678
  }
25959
24679
  };
@@ -26010,7 +24730,11 @@ const useUIGroupStateController = (
26010
24730
  hasValueProp,
26011
24731
  hasDefaultValueProp,
26012
24732
  props,
26013
- uiState: fallbackState,
24733
+ uiState: stateInitial,
24734
+ // Whether what the group holds was HANDED to it (a parent distributing,
24735
+ // a picker filling its popup, a value prop) rather than worked out from
24736
+ // its children. What it protects is read in onChange.
24737
+ stateGivenFromAbove: hasValueProp || hasDefaultValueProp,
26014
24738
  uiStateSignal,
26015
24739
  wantRequesterButtonState,
26016
24740
  ref,
@@ -26058,6 +24782,7 @@ const useUIGroupStateController = (
26058
24782
  );
26059
24783
  return;
26060
24784
  }
24785
+ controller.stateGivenFromAbove = true;
26061
24786
  const propagateEventType =
26062
24787
  e.type === "initial_state_push"
26063
24788
  ? "initial_state_push"
@@ -26073,14 +24798,10 @@ const useUIGroupStateController = (
26073
24798
  propagateDownEvent,
26074
24799
  );
26075
24800
  }
26076
- const aggChildState = resolvedAggregateChildStates(
26077
- childUIStateControllerArray,
26078
- fallbackState,
26079
- );
26080
- const groupUIState =
26081
- aggChildState === undefined ? fallbackState : aggChildState;
24801
+ const groupUIState = aggregateGroupUIState(newUIState);
26082
24802
  if (e.type === "initial_state_push") {
26083
24803
  controller.syncInternalState(groupUIState);
24804
+ writeBoundSignal(groupUIState);
26084
24805
  return;
26085
24806
  }
26086
24807
  applyState(groupUIState, e, { internalBehavior: true });
@@ -26090,7 +24811,9 @@ const useUIGroupStateController = (
26090
24811
  },
26091
24812
  syncInternalState: (newUIState) => {
26092
24813
  const currentUIState = controller.uiState;
26093
- if (newUIState === currentUIState) return;
24814
+ if (newUIState === currentUIState) {
24815
+ return;
24816
+ }
26094
24817
  controller.uiState = newUIState;
26095
24818
  uiStateSignal.value = newUIState;
26096
24819
  publishUIState(newUIState);
@@ -26108,7 +24831,9 @@ const useUIGroupStateController = (
26108
24831
  s.uiActionInternal?.(currentUIState, e);
26109
24832
  if (!skipCommand && controller.props.command) {
26110
24833
  const el = controller.ref.current;
26111
- if (el) triggerNaviCommand(el, controller.props.command, e);
24834
+ if (el) {
24835
+ triggerNaviCommand(el, controller.props.command, e);
24836
+ }
26112
24837
  }
26113
24838
  },
26114
24839
  registerChild: (childUIStateController) => {
@@ -26128,15 +24853,28 @@ const useUIGroupStateController = (
26128
24853
  debugUIGroup(
26129
24854
  `${controlType}.registerChild("${childControlType}") -> registered (total: ${childUIStateControllerArray.length})`,
26130
24855
  );
26131
- if (controller.hasValueProp || controller.hasDefaultValueProp) {
24856
+ const stateToPlaceChildFrom = controller.hasValueProp
24857
+ ? controller.value
24858
+ : controller.hasDefaultValueProp
24859
+ ? controller.defaultValue
24860
+ : // What the group HOLDS, for a child arriving after the value
24861
+ // did: a list item loaded later, a row scrolled back into a
24862
+ // virtualized list, a popup built at open. Two conditions, and
24863
+ // both are about not overwriting an answer with a silence — the
24864
+ // group must actually hold something, and the child must have
24865
+ // nothing of its own to show (one arriving with its own default
24866
+ // is answering, and the group is what its answers add up to).
24867
+ uiStateHoldsNothing(controller.uiState) ||
24868
+ !uiStateHoldsNothing(childUIStateController.uiState)
24869
+ ? undefined
24870
+ : controller.uiState;
24871
+ if (stateToPlaceChildFrom !== undefined) {
26132
24872
  const initialEvent = new CustomEvent("initial_state_push", {
26133
24873
  detail: {},
26134
24874
  });
26135
24875
  controller.placeChildUIState(
26136
24876
  childUIStateController,
26137
- controller.hasValueProp
26138
- ? controller.value
26139
- : controller.defaultValue,
24877
+ stateToPlaceChildFrom,
26140
24878
  initialEvent,
26141
24879
  );
26142
24880
  }
@@ -26159,7 +24897,9 @@ const useUIGroupStateController = (
26159
24897
  });
26160
24898
  return;
26161
24899
  }
26162
- if (!isMonitoringChild(childUIStateController)) return;
24900
+ if (!isMonitoringChild(childUIStateController)) {
24901
+ return;
24902
+ }
26163
24903
  const childControlType = childUIStateController.controlType;
26164
24904
  debugUIGroup(
26165
24905
  `${controlType}.onChildUIAction("${childControlType}") stateChanged=${stateChanged} -> child state: ${JSON.stringify(
@@ -26181,7 +24921,9 @@ const useUIGroupStateController = (
26181
24921
  delegatedTo.unregisterChild(childUIStateController);
26182
24922
  return;
26183
24923
  }
26184
- if (!isMonitoringChild(childUIStateController)) return;
24924
+ if (!isMonitoringChild(childUIStateController)) {
24925
+ return;
24926
+ }
26185
24927
  const childControlType = childUIStateController.controlType;
26186
24928
  const index = childUIStateControllerArray.indexOf(
26187
24929
  childUIStateController,
@@ -26194,7 +24936,7 @@ const useUIGroupStateController = (
26194
24936
  }
26195
24937
  childUIStateControllerArray.splice(index, 1);
26196
24938
  debugUIGroup(
26197
- `${controlType}.unregisterChild("${childControlType}") -> unregisteed (remaining: ${childUIStateControllerArray.length})`,
24939
+ `${controlType}.unregisterChild("${childControlType}") -> unregistered (remaining: ${childUIStateControllerArray.length})`,
26198
24940
  );
26199
24941
  onChange(new CustomEvent(`${childControlType}_unmount`), {
26200
24942
  notifyExternal: "silent",
@@ -26206,7 +24948,9 @@ const useUIGroupStateController = (
26206
24948
  });
26207
24949
  chainEvent(ev, e);
26208
24950
  for (const c of childUIStateControllerArray) {
26209
- if (shouldPropagateStateToChild(c)) c.resetUIState(ev);
24951
+ if (shouldPropagateStateToChild(c)) {
24952
+ c.resetUIState(ev);
24953
+ }
26210
24954
  }
26211
24955
  onChange(e, { notifyExternal: true });
26212
24956
  },
@@ -26254,13 +24998,17 @@ const useUIGroupStateController = (
26254
24998
  },
26255
24999
  findChildById: (searchId) => {
26256
25000
  for (const c of childUIStateControllerArray) {
26257
- if (c.id === searchId) return c;
25001
+ if (c.id === searchId) {
25002
+ return c;
25003
+ }
26258
25004
  }
26259
25005
  return null;
26260
25006
  },
26261
25007
  getChildControllers: () => childUIStateControllerArray,
26262
25008
  getManagedControls: () => {
26263
- if (!cascadeValidationToChildren) return [];
25009
+ if (!cascadeValidationToChildren) {
25010
+ return [];
25011
+ }
26264
25012
  return childUIStateControllerArray.slice();
26265
25013
  },
26266
25014
  // Group children sit next to the group itself: a busy one really does
@@ -26278,20 +25026,10 @@ const useUIGroupStateController = (
26278
25026
  });
26279
25027
  controller.rules = rules;
26280
25028
 
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
25029
  return {
26284
25030
  controller,
26285
25031
  _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,
25032
+ ...liveValues(),
26295
25033
  };
26296
25034
  },
26297
25035
  // ── update: runs every render after the first ─────────────────────────
@@ -26308,10 +25046,7 @@ const useUIGroupStateController = (
26308
25046
  controller.defaultValue = defaultValue;
26309
25047
  controller.hasValueProp = hasValueProp;
26310
25048
  controller.hasDefaultValueProp = hasDefaultValueProp;
26311
- if (
26312
- hasValueProp &&
26313
- (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
26314
- ) {
25049
+ const placeChildrenFrom = (groupUIState) => {
26315
25050
  const propagateDownEvent = new CustomEvent(
26316
25051
  "propagate_down_set_ui_state",
26317
25052
  { detail: {} },
@@ -26319,11 +25054,17 @@ const useUIGroupStateController = (
26319
25054
  for (const childUIStateController of childUIStateControllerArray) {
26320
25055
  controller.placeChildUIState(
26321
25056
  childUIStateController,
26322
- value,
25057
+ groupUIState,
26323
25058
  propagateDownEvent,
26324
25059
  );
26325
25060
  }
26326
- controller.syncInternalState(value);
25061
+ controller.syncInternalState(groupUIState);
25062
+ };
25063
+ if (
25064
+ hasValueProp &&
25065
+ (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
25066
+ ) {
25067
+ placeChildrenFrom(value);
26327
25068
  }
26328
25069
  if (
26329
25070
  boundSignal &&
@@ -26334,33 +25075,10 @@ const useUIGroupStateController = (
26334
25075
  // again, exactly as they were when they registered. Without this a
26335
25076
  // group would answer a write to its own signal by silently writing its
26336
25077
  // 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);
25078
+ placeChildrenFrom(defaultValue);
26349
25079
  }
26350
25080
 
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
- };
25081
+ return liveValues();
26364
25082
  },
26365
25083
  );
26366
25084
 
@@ -26371,6 +25089,9 @@ const useUIGroupStateController = (
26371
25089
  el.__uiStateController__ = controller;
26372
25090
  }
26373
25091
  return () => {
25092
+ if (el && el.__uiStateController__ === controller) {
25093
+ delete el.__uiStateController__;
25094
+ }
26374
25095
  onUIStateControllerDestroyed(controller);
26375
25096
  };
26376
25097
  }, []);
@@ -26442,6 +25163,7 @@ const EMPTY_OBJECT = {};
26442
25163
  */
26443
25164
  const useUIFacadeStateController = (props, realUIStateController) => {
26444
25165
  const firstChildControllerRef = useRef(null);
25166
+ const namelessChildSetRef = useRef(new Set());
26445
25167
  const updatingRef = useRef(false);
26446
25168
  const debugPopup = useDebugPopup();
26447
25169
  const debugInteraction = useDebugInteraction();
@@ -26457,14 +25179,23 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26457
25179
  // ── init: runs once on mount ───────────────────────────────────────────
26458
25180
  (s) => {
26459
25181
  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;
25182
+ if (childController.controlType === "button") {
25183
+ return false;
25184
+ }
25185
+ if (childController.controlType === "link") {
25186
+ return false;
25187
+ }
25188
+ if (childController.controlType === "facade") {
25189
+ return false;
25190
+ }
25191
+ if (childController.isProxy) {
25192
+ return false;
25193
+ }
26464
25194
  if (childController.allowNameless) {
26465
25195
  // A control saying it is not a field is not the one the picker talks
26466
25196
  // to: the search box above the list, the "select all" switch beside
26467
25197
  // it. It is there to help find the answer, not to be it.
25198
+ namelessChildSetRef.current.add(childController);
26468
25199
  return false;
26469
25200
  }
26470
25201
  if (childController.props["navi-list"]) {
@@ -26487,6 +25218,10 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26487
25218
  }
26488
25219
  const child = firstChildControllerRef.current;
26489
25220
  if (!child) {
25221
+ warnPopupHasNothingButNamelessControls(
25222
+ props,
25223
+ namelessChildSetRef.current,
25224
+ );
26490
25225
  return;
26491
25226
  }
26492
25227
  updatingRef.current = true;
@@ -26635,6 +25370,11 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26635
25370
 
26636
25371
  const describePicker = (props) =>
26637
25372
  `<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
25373
+ const warnPopupHasNothingButNamelessControls = (props, namelessChildSet) => {
25374
+ {
25375
+ return;
25376
+ }
25377
+ };
26638
25378
 
26639
25379
  /**
26640
25380
  * Returns true when `e` should trigger parent notification (child → parent bubbling).
@@ -26911,8 +25651,9 @@ const useControlProps = (props, {
26911
25651
  props.id = props.id || controlId || idDefault;
26912
25652
  const controlName = useContext(ControlNameContext);
26913
25653
  props.name = props.name || controlName;
25654
+ const isCheckable = isCheckableInput(controlType, props.type);
26914
25655
  const toDomProps = newUIState => {
26915
- if (controlType === "input" && (props.type === "radio" || props.type === "checkbox")) {
25656
+ if (isCheckable) {
26916
25657
  const domValue = toDomValue(props.value, {
26917
25658
  controlType,
26918
25659
  id: props.id,
@@ -27291,8 +26032,7 @@ const useControlProps = (props, {
27291
26032
  }
27292
26033
  return keyDownDefault(e);
27293
26034
  };
27294
- const isInputCheckable = controlType === "input" && (props.type === "radio" || props.type === "checkbox");
27295
- if (isInputCheckable) {
26035
+ if (isCheckable) {
27296
26036
  const isRadio = props.type === "radio";
27297
26037
 
27298
26038
  // I've decided that enter on radio/checkbox would not submit form like browser does but
@@ -27307,7 +26047,6 @@ const useControlProps = (props, {
27307
26047
  keyDown: e => {
27308
26048
  if (e.key === "Enter") {
27309
26049
  const inputEl = ref.current;
27310
- const isRadio = props.type === "radio";
27311
26050
  const checked = inputEl.checked;
27312
26051
  const always = () => {
27313
26052
  if (inputEl.form) {
@@ -27330,18 +26069,9 @@ const useControlProps = (props, {
27330
26069
  always
27331
26070
  };
27332
26071
  }
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
26072
  return {
27343
- name: "enter to check checkbox",
27344
- allowed: () => dispatchRequestSetUIState(inputEl, uiStateController.value, {
26073
+ name: checked ? "enter to uncheck checkbox" : "enter to check checkbox",
26074
+ allowed: () => dispatchRequestSetUIState(inputEl, checked ? undefined : uiStateController.value, {
27345
26075
  event: e
27346
26076
  }),
27347
26077
  always
@@ -27476,7 +26206,6 @@ const useControlProps = (props, {
27476
26206
  // Same for radio siblings: when a sibling check unchecks this radio
27477
26207
  // (radio_sibling_uncheck, internal event, no synthetic input), lastActionValueRef
27478
26208
  // 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
26209
  if (!isCheckable) {
27481
26210
  const lastActionValue = lastActionValueRef.current;
27482
26211
  const valueSameAsLastAction = lastActionValue !== NO_ACTION_YET && compareTwoJsValues(currentValue, lastActionValue);
@@ -27548,11 +26277,16 @@ const useControlProps = (props, {
27548
26277
  // a custom concept being combination of "input", "change" and may other events
27549
26278
  // this even if trigerred when value changes and can be controlled by actionDebounce and actionAfterChange
27550
26279
  const hasNaviChangeEventReaction = Boolean(eventReactionDefinitions?.naviChange || defaultEventReactionDefinitions?.naviChange);
26280
+ // The input effect is installed once per element/options while the reaction
26281
+ // closures (boundAction, custom reactions) are per-render: read through a
26282
+ // ref so the effect always fires the current render's reaction.
26283
+ const applyEventReactionRef = useRef();
26284
+ applyEventReactionRef.current = applyEventReaction;
27551
26285
  const refCallback = useCallback(field => {
27552
26286
  if (!hasNaviChangeEventReaction || actionEvent === "custom") {
27553
26287
  return undefined;
27554
26288
  }
27555
- return addInputEffect(field, e => applyEventReaction("naviChange", e), {
26289
+ return addInputEffect(field, e => applyEventReactionRef.current("naviChange", e), {
27556
26290
  waitForChange: actionAfterChange,
27557
26291
  debounce: actionDebounce,
27558
26292
  debugInteraction
@@ -27696,36 +26430,13 @@ const createControlInfo = (props, {
27696
26430
  } else {
27697
26431
  statePropName = "value";
27698
26432
  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;
26433
+ ({
26434
+ hasStateProp,
26435
+ stateInitial,
26436
+ stateFromSignal
26437
+ } = resolveValueState(props, controlType, signal));
26438
+ if (hasStateProp) {
26439
+ value = stateInitial;
27729
26440
  }
27730
26441
  readOnlySupported = INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
27731
26442
  }
@@ -27742,27 +26453,11 @@ const createControlInfo = (props, {
27742
26453
  } else if (controlType === "picker" || controlType === "select") {
27743
26454
  statePropName = "value";
27744
26455
  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
- }
26456
+ ({
26457
+ hasStateProp,
26458
+ stateInitial,
26459
+ stateFromSignal
26460
+ } = resolveValueState(props, controlType, signal));
27766
26461
  disabledSupported = true;
27767
26462
  // A native <select> has no readonly attribute. What says it is read-only is
27768
26463
  // aria-readonly plus a refused interaction — see the select reactions in
@@ -27797,6 +26492,52 @@ const createControlInfo = (props, {
27797
26492
  disabledSupported
27798
26493
  };
27799
26494
  };
26495
+ // Who says what a value-holding control is worth — a bound signal, a `value`,
26496
+ // a `defaultValue` — resolved the same way for every control holding one value
26497
+ // (text input, picker, select). The checkbox/radio branch has its own
26498
+ // resolution: `checked` speaks in booleans and translates to the value.
26499
+ const resolveValueState = (props, controlType, signal) => {
26500
+ if (signal) {
26501
+ if (Object.hasOwn(props, "defaultValue")) {
26502
+ // A bound signal's own default is seeded into `defaultValue` (see
26503
+ // resolveInputProps), so such a control is uncontrolled-with-default;
26504
+ // the signal only receives write-backs (onUIAction).
26505
+ // A signal holding something wins over the default: `defaultValue` is
26506
+ // a suggestion of what to start from (and what a reset goes back to),
26507
+ // not an answer — while the signal's value IS the answer, restored
26508
+ // from the url or set by whoever owns it. Taking the default here
26509
+ // would show a suggestion in place of the value on every reload.
26510
+ const stateInitial = signal.value !== undefined ? signal.value : props.defaultValue;
26511
+ return {
26512
+ hasStateProp: false,
26513
+ stateInitial,
26514
+ stateFromSignal: stateInitial
26515
+ };
26516
+ }
26517
+ // A plain bound signal with no default (e.g. Wheel): its live value
26518
+ // seeds and controls the state.
26519
+ return {
26520
+ hasStateProp: true,
26521
+ stateInitial: signal.value
26522
+ };
26523
+ }
26524
+ if (Object.hasOwn(props, "value")) {
26525
+ return {
26526
+ hasStateProp: true,
26527
+ stateInitial: props.value
26528
+ };
26529
+ }
26530
+ if (Object.hasOwn(props, "defaultValue")) {
26531
+ return {
26532
+ hasStateProp: false,
26533
+ stateInitial: props.defaultValue
26534
+ };
26535
+ }
26536
+ return {
26537
+ hasStateProp: false,
26538
+ stateInitial: undefined
26539
+ };
26540
+ };
27800
26541
  // color, radio, image, file etc do not support readonly
27801
26542
  const INPUT_TYPE_SUPPORTING_READONLY_SET = new Set(["text", "date", "datetime-local", "email", "month", "number", "password", "search", "tel", "time", "url", "week"]);
27802
26543
  // Who, if anyone, is listening to what this control is worth: a handler of its
@@ -27815,6 +26556,7 @@ const useIsControlListenedTo = props => {
27815
26556
  };
27816
26557
  const useReadOnlyUncontrolled = (props, controlInfo) => {
27817
26558
  const listenedTo = useIsControlListenedTo(props);
26559
+ useRef(false);
27818
26560
  if (!controlInfo.hasStateProp) {
27819
26561
  return false;
27820
26562
  }
@@ -27869,6 +26611,7 @@ const useControlgroupProps = (props, {
27869
26611
  // can't change. A form or a group around it IS someone listening — that is
27870
26612
  // what will send the value and hand a new one back.
27871
26613
  const listenedTo = useIsControlListenedTo(props);
26614
+ useRef(false);
27872
26615
  const implicitReadOnly = uiGroupStateController.hasValueProp && !listenedTo;
27873
26616
  if (implicitReadOnly && !props.readOnly) {
27874
26617
  props.readOnly = true;
@@ -27902,9 +26645,9 @@ const useControlgroupProps = (props, {
27902
26645
  return [controlRootProps, {
27903
26646
  ...controlgroupProps,
27904
26647
  "name": undefined,
27905
- // useful to children, not the the group itself
26648
+ // useful to children, not the group itself
27906
26649
  "required": undefined,
27907
- // useful to children, not the the group itself
26650
+ // useful to children, not the group itself
27908
26651
  // How many items the group accepts, read by its controller and by the
27909
26652
  // children asking whether there is still room for them. Not an attribute
27910
26653
  // any element wears: a <fieldset maxlength> means nothing.
@@ -27917,29 +26660,6 @@ const useControlgroupProps = (props, {
27917
26660
  }, controlgroupChildrenWrapperProps];
27918
26661
  };
27919
26662
 
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
26663
  /**
27944
26664
  * What a control holds, read from the control itself.
27945
26665
  *
@@ -27986,6 +26706,30 @@ const useControlUIState = (ref, uiStateInitial) => {
27986
26706
  }, [ref]);
27987
26707
  return uiState;
27988
26708
  };
26709
+
26710
+ /**
26711
+ * Like `useControlProps` but also establishes a 1:1 facade sync between the
26712
+ * picker's hidden input and the first child control inside the picker popup.
26713
+ *
26714
+ * Child → picker input: when the child's UI state changes, the picker input
26715
+ * is updated automatically (no `command="--navi-update"` needed on the child).
26716
+ *
26717
+ * Picker input → child: when the picker input is updated externally (e.g.
26718
+ * via `--navi-update` or `--navi-clear` from outside), the change is
26719
+ * propagated down to the child automatically.
26720
+ *
26721
+ * Returns a 3-tuple `[controlRootProps, controlHostProps, facadeChildrenProps]`.
26722
+ * Use `ControlFacadeChildrenWrapper` with the third element to wrap the popup
26723
+ * children — it resets field contexts and injects the facade controller:
26724
+ *
26725
+ * ```jsx
26726
+ * const [controlRootProps, controlHostProps, facadeChildrenProps] = useControlFacadeProps(props, options);
26727
+ * // …
26728
+ * <ControlFacadeChildrenWrapper {...facadeChildrenProps}>
26729
+ * {children}
26730
+ * </ControlFacadeChildrenWrapper>
26731
+ * ```
26732
+ */
27989
26733
  const useControlFacadeProps = (props, options) => {
27990
26734
  const [controlRootProps, controlHostProps, {
27991
26735
  uiStateController
@@ -28012,21 +26756,9 @@ const useControlFacadeProps = (props, options) => {
28012
26756
  const ControlFacadeChildrenWrapper = ({
28013
26757
  children,
28014
26758
  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
- })
26759
+ }) => jsx(ControlChildrenWrapper, {
26760
+ uiStateController: facadeController,
26761
+ children: children
28030
26762
  });
28031
26763
  const useInteractiveProps = (props, {
28032
26764
  uiStateController,
@@ -28173,7 +26905,7 @@ const useInteractiveProps = (props, {
28173
26905
  }, []);
28174
26906
  }
28175
26907
  {
28176
- const isCheckable = uiStateController.controlType === "input" && (props.type === "radio" || props.type === "checkbox");
26908
+ const isCheckable = isCheckableInput(uiStateController.controlType, props.type);
28177
26909
  Object.assign(controlHostProps, {
28178
26910
  onnavi_clear_ui_state: e => {
28179
26911
  uiStateController.clearUIState(e);
@@ -28406,8 +27138,7 @@ const splitControlProps = props => {
28406
27138
  ref
28407
27139
  };
28408
27140
  const controlRootProps = {};
28409
- const propKeySet = new Set(Object.keys(props));
28410
- for (const key of propKeySet) {
27141
+ for (const key of Object.keys(props)) {
28411
27142
  if (CONTROL_PROP_SET.has(key)) {
28412
27143
  if (CONTROL_ATTRIBUTE_SET.has(key)) {
28413
27144
  controlHostProps[key] = props[key];
@@ -28418,6 +27149,7 @@ const splitControlProps = props => {
28418
27149
  }
28419
27150
  return [controlRootProps, controlHostProps];
28420
27151
  };
27152
+ const isCheckableInput = (controlType, typeProp) => controlType === "input" && (typeProp === "radio" || typeProp === "checkbox");
28421
27153
 
28422
27154
  // The labels the DOM itself can hand over: a wrapping <label>, or a label[for]
28423
27155
  // pointing at a native form element. Everything else — a label[for] on a
@@ -69503,12 +68235,19 @@ const LAST_MINUTE_OF_DAY = 23 * 60 + 59;
69503
68235
  * past.
69504
68236
  * @param {import("ignore:preact").ComponentChildren} [separator] What is written
69505
68237
  * between the hours and the minutes. "h" in French, ":" elsewhere.
68238
+ * @param {string} [placeholder] What the wheels show while the time holds
68239
+ * nothing, as "HH:MM". Wheels have no blank row to land on, so their
68240
+ * placeholder is a position rather than a grey word — shown, but not an
68241
+ * answer: the value stays `undefined` until a wheel is turned or a real value
68242
+ * arrives. `defaultValue` is the other half of the pair — a time that IS the
68243
+ * answer, and where a reset goes back to.
69506
68244
  * @param {object} [wheelProps] Anything a `Wheel` takes, said once for both of
69507
68245
  * them — `visibleCount`, `itemWidth`, `glideSpeed`.
69508
68246
  */
69509
68247
  const TimeWheel = ({
69510
68248
  minuteStep = 1,
69511
68249
  loop = true,
68250
+ placeholder,
69512
68251
  separator = naviI18n("time.hour_separator"),
69513
68252
  hourLabel = naviI18n("time.hour_label"),
69514
68253
  minuteLabel = naviI18n("time.minute_label"),
@@ -69525,8 +68264,12 @@ const TimeWheel = ({
69525
68264
  }
69526
68265
  return minuteList;
69527
68266
  }, [minuteStep]);
68267
+ const {
68268
+ aggregateChildStates
68269
+ } = useAnswered(placeholder, rest, aggregateTime);
68270
+ const placeholderParts = parseTimeParts(placeholder);
69528
68271
  return jsxs(WheelGroup, {
69529
- aggregateChildStates: aggregateTime,
68272
+ aggregateChildStates: aggregateChildStates,
69530
68273
  distributeChildUIState: distributeTime,
69531
68274
  ...rest,
69532
68275
  children: [jsx(Wheel, {
@@ -69535,6 +68278,7 @@ const TimeWheel = ({
69535
68278
  bounded: !loop,
69536
68279
  size: size,
69537
68280
  "aria-label": hourLabel,
68281
+ defaultValue: placeholderParts ? placeholderParts.hour : undefined,
69538
68282
  ...wheelProps,
69539
68283
  children: HOURS.map(hour => jsx(Wheel.Item, {
69540
68284
  value: hour,
@@ -69550,6 +68294,7 @@ const TimeWheel = ({
69550
68294
  bounded: !loop,
69551
68295
  size: size,
69552
68296
  "aria-label": minuteLabel,
68297
+ defaultValue: placeholderParts ? placeholderParts.minute : undefined,
69553
68298
  ...wheelProps,
69554
68299
  children: minutes.map(minute => jsx(Wheel.Item, {
69555
68300
  value: minute,
@@ -69587,6 +68332,12 @@ const TimeWheel = ({
69587
68332
  * one that goes backwards is not. It is what the bounds keep between them as
69588
68333
  * they turn — turn the start into the end and the end moves along, keeping
69589
68334
  * that much room.
68335
+ * @param {{ start?: string, end?: string }} [placeholder] What the two wheels
68336
+ * show while the span holds nothing — a position, since wheels have no blank
68337
+ * row to land on, and not an answer: the value stays `undefined` until one of
68338
+ * them is turned. One turn settles both, the untouched bound included, left
68339
+ * where the placeholder put it. For a span that is optional ("any time of
68340
+ * day") and still has to show hours.
69590
68341
  * @param {object} [timeProps] Anything a `TimeWheel` takes, said once for both
69591
68342
  * of them. `startTimeProps`/`endTimeProps` say it to one of the two, and win
69592
68343
  * over this one.
@@ -69595,6 +68346,7 @@ const TimeRangeWheel = ({
69595
68346
  minuteStep = 1,
69596
68347
  minDuration = 0,
69597
68348
  loop = true,
68349
+ placeholder,
69598
68350
  size,
69599
68351
  startLabel = naviI18n("time_range.from"),
69600
68352
  endLabel = naviI18n("time_range.to"),
@@ -69606,6 +68358,12 @@ const TimeRangeWheel = ({
69606
68358
  const startId = useId();
69607
68359
  const startRef = useRef(null);
69608
68360
  const endRef = useRef(null);
68361
+ // One turn settles the whole span: a start somebody chose makes the end an
68362
+ // answer too, left where the placeholder put it.
68363
+ const {
68364
+ answeredRef,
68365
+ aggregateChildStates
68366
+ } = useAnswered(placeholder, rest, aggregateSpan);
69609
68367
 
69610
68368
  // What the pair does while it is being turned: the bound that just moved is
69611
68369
  // the one the user is holding, so it stays where it was put and the OTHER one
@@ -69642,51 +68400,127 @@ const TimeRangeWheel = ({
69642
68400
  event: e
69643
68401
  });
69644
68402
  };
69645
- return jsxs(ControlGroup, {
68403
+ return jsx(ControlGroup, {
69646
68404
  flex: true,
69647
68405
  alignY: "center",
69648
68406
  spacing: "s",
69649
68407
  size: size,
68408
+ aggregateChildStates: aggregateChildStates,
69650
68409
  ...rest,
69651
- children: [startLabel === null ? null : jsx(Text, {
69652
- size: size,
69653
- children: startLabel
69654
- }), jsx(TimeWheel, {
69655
- id: startId,
69656
- ref: startRef,
69657
- name: "start",
69658
- minuteStep: minuteStep,
69659
- loop: loop,
69660
- size: size,
69661
- uiAction: (value, e) => keepBoundsApart("start", value, e),
69662
- ...timeProps,
69663
- ...startTimeProps
69664
- }), endLabel === null ? null : jsx(Text, {
69665
- size: size,
69666
- children: endLabel
69667
- }), jsx(TimeWheel, {
69668
- ref: endRef,
69669
- name: "end",
69670
- minuteStep: minuteStep,
69671
- loop: loop,
69672
- size: size,
69673
- uiAction: (value, e) => keepBoundsApart("end", value, e)
69674
- // Which time it comes after, and how much room there must be between
69675
- // the two: said on the LATER of the two, so the answer is given where
69676
- // the time one would have to move is (see time_range_constraint.js).
69677
- ,
69678
- "data-time-after": startId,
69679
- "data-time-min-duration": minDuration,
69680
- ...timeProps,
69681
- ...endTimeProps
69682
- })]
68410
+ children: jsxs(AnsweredContext.Provider, {
68411
+ value: answeredRef,
68412
+ children: [startLabel === null ? null : jsx(Text, {
68413
+ size: size,
68414
+ children: startLabel
68415
+ }), jsx(TimeWheel, {
68416
+ id: startId,
68417
+ ref: startRef,
68418
+ name: "start",
68419
+ minuteStep: minuteStep,
68420
+ loop: loop,
68421
+ size: size,
68422
+ placeholder: placeholder ? placeholder.start : undefined,
68423
+ uiAction: (value, e) => keepBoundsApart("start", value, e),
68424
+ ...timeProps,
68425
+ ...startTimeProps
68426
+ }), endLabel === null ? null : jsx(Text, {
68427
+ size: size,
68428
+ children: endLabel
68429
+ }), jsx(TimeWheel, {
68430
+ ref: endRef,
68431
+ name: "end",
68432
+ minuteStep: minuteStep,
68433
+ loop: loop,
68434
+ size: size,
68435
+ placeholder: placeholder ? placeholder.end : undefined,
68436
+ uiAction: (value, e) => keepBoundsApart("end", value, e)
68437
+ // Which time it comes after, and how much room there must be between
68438
+ // the two: said on the LATER of the two, so the answer is given where
68439
+ // the time one would have to move is (see time_range_constraint.js).
68440
+ ,
68441
+ "data-time-after": startId,
68442
+ "data-time-min-duration": minDuration,
68443
+ ...timeProps,
68444
+ ...endTimeProps
68445
+ })]
68446
+ })
69683
68447
  });
69684
68448
  };
68449
+
68450
+ /**
68451
+ * Wheels always show something — there is no blank row to land on — so a pair of
68452
+ * them cannot say "nothing set" by looking empty. Their `placeholder` is
68453
+ * therefore a position rather than a grey word: shown like a value, and not one.
68454
+ * The value stays `undefined` until a wheel is turned, which is what tells "any
68455
+ * time of day" from a span somebody chose. `defaultValue` remains what it is
68456
+ * everywhere else — a time that IS the answer.
68457
+ *
68458
+ * What counts as turned is read from the value itself rather than from a
68459
+ * gesture: while nothing has moved off the placeholder, nothing is set; the
68460
+ * moment it differs, it is an answer and stays one, even turned back onto the
68461
+ * placeholder. A wheel's own `uiAction` runs after its group has aggregated, so
68462
+ * a flag set from there would always be one turn late.
68463
+ *
68464
+ * The flag is a ref rather than state because the aggregate a group is created
68465
+ * with is the one it keeps: swapping the function on a later render changes
68466
+ * nothing (see useUIGroupStateController). One stable function reading one ref.
68467
+ *
68468
+ * A pair shares ONE flag through `AnsweredContext`: turning the start settles
68469
+ * the end too, left where the placeholder put it. Each of the two times gating
68470
+ * on its own would answer half a span — and would leave the pair nothing to
68471
+ * compare its own placeholder against.
68472
+ */
68473
+ const AnsweredContext = createContext(null);
68474
+ const useAnswered = (placeholder, props, aggregateWhenAnswered) => {
68475
+ const answeredFromPair = useContext(AnsweredContext);
68476
+ const ownAnsweredRef = useRef(false);
68477
+ const answeredRef = answeredFromPair || ownAnsweredRef;
68478
+ if (!placeholder || isAnswerGivenByProps(props)) {
68479
+ answeredRef.current = true;
68480
+ }
68481
+ // Inside a pair, only the pair decides: a time that gated on its own would
68482
+ // hand the pair nothing to compare, and half a span cannot be read.
68483
+ const gates = !answeredFromPair;
68484
+ const placeholderRef = useRef(placeholder);
68485
+ placeholderRef.current = placeholder;
68486
+ const aggregateRef = useRef(null);
68487
+ if (!aggregateRef.current) {
68488
+ aggregateRef.current = children => {
68489
+ const aggregated = aggregateWhenAnswered(children);
68490
+ if (answeredRef.current) {
68491
+ return aggregated;
68492
+ }
68493
+ if (compareTwoJsValues(aggregated, placeholderRef.current)) {
68494
+ return undefined;
68495
+ }
68496
+ // It moved: from here on this is an answer, and stays one even when it is
68497
+ // turned back onto the placeholder — somebody chose that time.
68498
+ answeredRef.current = true;
68499
+ return aggregated;
68500
+ };
68501
+ }
68502
+ return {
68503
+ answeredRef,
68504
+ aggregateChildStates: gates ? aggregateRef.current : aggregateWhenAnswered
68505
+ };
68506
+ };
68507
+ const isAnswerGivenByProps = props => props.value !== undefined || props.defaultValue !== undefined || props.signal && props.signal.value !== undefined;
69685
68508
  const HOURS = Array.from({
69686
68509
  length: HOUR_COUNT
69687
68510
  }, (_, hour) => hour);
69688
68511
  const padTwo = value => String(value).padStart(2, "0");
69689
68512
 
68513
+ // The two times as one span, { start, end } — the shape a pair carries.
68514
+ const aggregateSpan = childUIStateControllers => {
68515
+ const span = {};
68516
+ for (const child of childUIStateControllers) {
68517
+ if (child.name === "start" || child.name === "end") {
68518
+ span[child.name] = child.uiState;
68519
+ }
68520
+ }
68521
+ return span;
68522
+ };
68523
+
69690
68524
  // The two wheels as one value, "HH:MM".
69691
68525
  const aggregateTime = childUIStateControllers => {
69692
68526
  let hour = "";