@jsenv/navi 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { installImportMetaCss } from "./jsenv_navi_side_effects.js";
2
- import { isValidElement, createContext, toChildArray, render, createRef, cloneElement } from "preact";
2
+ import { isValidElement, h, createContext, toChildArray, render, createRef, cloneElement } from "preact";
3
3
  import { useErrorBoundary, useLayoutEffect, useEffect, useMemo, useRef, useState, useCallback, useContext, useImperativeHandle, useId } from "preact/hooks";
4
4
  import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
5
5
  import { signal, effect, computed, batch, useSignal } from "@preact/signals";
@@ -7,7 +7,7 @@ import { createIterableWeakSet, mergeOneStyle, stringifyStyle, createPubSub, mer
7
7
  export { contrastColor } from "@jsenv/dom";
8
8
  import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize";
9
9
  import { createValidity } from "@jsenv/validity";
10
- import { createPortal, forwardRef } from "preact/compat";
10
+ import { Suspense, createPortal, forwardRef } from "preact/compat";
11
11
 
12
12
  const actionPrivatePropertiesWeakMap = new WeakMap();
13
13
  const getActionPrivateProperties = (action) => {
@@ -1113,7 +1113,7 @@ const updateActions = ({
1113
1113
  const { runningSet, settledSet } = getActivationInfo();
1114
1114
 
1115
1115
  if (DEBUG$3) {
1116
- let argSource = `reason: \`${reason}\``;
1116
+ let argSource = `reason: ${JSON.stringify(reason)}`;
1117
1117
  if (isReplace) {
1118
1118
  argSource += `, isReplace: true`;
1119
1119
  }
@@ -1868,13 +1868,17 @@ const createAction = (callback, rootOptions = {}) => {
1868
1868
  });
1869
1869
 
1870
1870
  if (ui.hasRenderers || onError) {
1871
+ // When inside suspense this console.error is redundant with the error thrown by preact debug at
1872
+ // https://github.com/preactjs/preact/blob/21dd6d04c1a9a43e5b60976bb5eb7d856253195b/debug/src/debug.js#L109
1871
1873
  console.error(e);
1874
+
1872
1875
  // For UI-bound actions: error is properly handled by logging + UI display
1873
1876
  // Return error instead of throwing to signal it's handled and prevent:
1874
1877
  // - jsenv error overlay from appearing
1875
1878
  // - error being treated as unhandled by runtime
1876
1879
  return e;
1877
1880
  }
1881
+ e.action = action;
1878
1882
  throw e;
1879
1883
  };
1880
1884
 
@@ -2143,6 +2147,7 @@ const createActionProxyFromSignal = (
2143
2147
  };
2144
2148
 
2145
2149
  Object.assign(actionProxy, {
2150
+ isAction: true,
2146
2151
  isProxy: true,
2147
2152
  callback: undefined,
2148
2153
  params: undefined,
@@ -2435,14 +2440,6 @@ const actionRunEffect = (
2435
2440
  return actionRunnedByThisEffect;
2436
2441
  };
2437
2442
 
2438
- const useActionData = (action) => {
2439
- if (!action) {
2440
- return undefined;
2441
- }
2442
- const data = action.dataSignal.value;
2443
- return data;
2444
- };
2445
-
2446
2443
  const useRunOnMount = (action, Component) => {
2447
2444
  useEffect(() => {
2448
2445
  action.run({
@@ -2725,8 +2722,9 @@ const arraySignalStore = (
2725
2722
  });
2726
2723
 
2727
2724
  const itemPropertiesObserverSet = new Set();
2728
- const observeItemProperties = (itemSignal, callback) => {
2729
- const observer = { itemSignal, callback };
2725
+ const observeItemProperties = (itemSignal, callback, { properties } = {}) => {
2726
+ const propertiesSet = properties ? new Set(properties) : null;
2727
+ const observer = { itemSignal, callback, propertiesSet };
2730
2728
  itemPropertiesObserverSet.add(observer);
2731
2729
  return () => {
2732
2730
  itemPropertiesObserverSet.delete(observer);
@@ -2734,10 +2732,12 @@ const arraySignalStore = (
2734
2732
  };
2735
2733
 
2736
2734
  const propertiesObserverSet = new Set();
2737
- const observeProperties = (callback) => {
2738
- propertiesObserverSet.add(callback);
2735
+ const observeProperties = (callback, { properties } = {}) => {
2736
+ const propertiesSet = properties ? new Set(properties) : null;
2737
+ const observer = { callback, propertiesSet };
2738
+ propertiesObserverSet.add(observer);
2739
2739
  return () => {
2740
- propertiesObserverSet.delete(callback);
2740
+ propertiesObserverSet.delete(observer);
2741
2741
  };
2742
2742
  };
2743
2743
 
@@ -2887,24 +2887,50 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
2887
2887
  const upsert = (...args) => {
2888
2888
  const mutationsMap = new Map(); // Map<itemId, propertyMutations>
2889
2889
  const triggerPropertyMutations = () => {
2890
- // we call at the end so that itemWithProps and arraySignal.value was set too
2891
2890
  for (const itemPropertiesObserver of itemPropertiesObserverSet) {
2892
- const { itemSignal, callback } = itemPropertiesObserver;
2891
+ const { itemSignal, callback, propertiesSet } = itemPropertiesObserver;
2893
2892
  const watchedItem = itemSignal.peek();
2894
2893
  if (!watchedItem) {
2895
2894
  continue;
2896
2895
  }
2897
-
2898
- // Check if this item has mutations
2899
2896
  const itemMutations = mutationsMap.get(watchedItem[idKey]);
2900
2897
  if (itemMutations) {
2901
- callback(itemMutations);
2898
+ if (propertiesSet) {
2899
+ let hasRelevantMutation = false;
2900
+ for (const p of propertiesSet) {
2901
+ if (Object.hasOwn(itemMutations, p)) {
2902
+ hasRelevantMutation = true;
2903
+ break;
2904
+ }
2905
+ }
2906
+ if (hasRelevantMutation) {
2907
+ callback(itemMutations);
2908
+ }
2909
+ } else {
2910
+ callback(itemMutations);
2911
+ }
2902
2912
  }
2903
2913
  }
2904
2914
  if (propertiesObserverSet.size) {
2905
- const mutations = Array.from(mutationsMap.values());
2915
+ const allMutations = Array.from(mutationsMap.values());
2906
2916
  for (const propertiesObserver of propertiesObserverSet) {
2907
- propertiesObserver(mutations);
2917
+ const { callback, propertiesSet } = propertiesObserver;
2918
+ if (propertiesSet) {
2919
+ const filteredMutations = [];
2920
+ for (const propertyMutations of allMutations) {
2921
+ for (const p of propertiesSet) {
2922
+ if (Object.hasOwn(propertyMutations, p)) {
2923
+ filteredMutations.push(propertyMutations);
2924
+ break;
2925
+ }
2926
+ }
2927
+ }
2928
+ if (filteredMutations.length > 0) {
2929
+ callback(filteredMutations);
2930
+ }
2931
+ } else {
2932
+ callback(allMutations);
2933
+ }
2908
2934
  }
2909
2935
  }
2910
2936
  };
@@ -2948,7 +2974,7 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
2948
2974
  return item;
2949
2975
  }
2950
2976
 
2951
- // Store mutations for this specific item
2977
+ // Store mutations keyed by old id
2952
2978
  mutationsMap.set(item[idKey], propertyMutations);
2953
2979
  return itemWithProps;
2954
2980
  };
@@ -3163,7 +3189,18 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
3163
3189
  return null;
3164
3190
  };
3165
3191
 
3166
- const signalForUniqueKey = (uniqueKey, uniqueKeyValueSignal) => {
3192
+ const signalForKey = (key, keyValueSignal) => {
3193
+ if (key === idKey) {
3194
+ return _signalForIdKey(keyValueSignal);
3195
+ }
3196
+ if (uniqueKeys.includes(key)) {
3197
+ return _signalForUniqueKey(key, keyValueSignal);
3198
+ }
3199
+ throw new Error(
3200
+ `signalForKey: "${key}" is not the idKey or a uniqueKey of this store (idKey: ${idKey}, uniqueKeys: ${uniqueKeys.join(", ")})`,
3201
+ );
3202
+ };
3203
+ const _signalForUniqueKey = (uniqueKey, uniqueKeyValueSignal) => {
3167
3204
  const itemIdSignal = signal(null);
3168
3205
  const check = (value) => {
3169
3206
  const item = select(uniqueKey, value);
@@ -3173,7 +3210,7 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
3173
3210
  itemIdSignal.value = item[idKey];
3174
3211
  return true;
3175
3212
  };
3176
- if (!check()) {
3213
+ if (!check(uniqueKeyValueSignal.peek())) {
3177
3214
  effect(function () {
3178
3215
  const uniqueKeyValue = uniqueKeyValueSignal.value;
3179
3216
  if (check(uniqueKeyValue)) {
@@ -3181,7 +3218,44 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
3181
3218
  }
3182
3219
  });
3183
3220
  }
3221
+ return computed(() => {
3222
+ return select(itemIdSignal.value);
3223
+ });
3224
+ };
3184
3225
 
3226
+ const _signalForIdKey = (idValueSignal) => {
3227
+ const itemIdSignal = signal(null);
3228
+ const check = (value) => {
3229
+ const item = select(idKey, value);
3230
+ if (!item) {
3231
+ return false;
3232
+ }
3233
+ itemIdSignal.value = item[idKey];
3234
+ return true;
3235
+ };
3236
+ if (!check(idValueSignal.peek())) {
3237
+ effect(function () {
3238
+ const idValue = idValueSignal.value;
3239
+ if (check(idValue)) {
3240
+ this.dispose();
3241
+ }
3242
+ });
3243
+ }
3244
+ // When the id itself is renamed, keep itemIdSignal in sync.
3245
+ observeProperties(
3246
+ (mutationsArray) => {
3247
+ const currentId = itemIdSignal.peek();
3248
+ if (currentId === null) return;
3249
+ for (const mutations of mutationsArray) {
3250
+ const mutation = mutations[idKey];
3251
+ if (mutation.oldValue === currentId) {
3252
+ itemIdSignal.value = mutation.newValue;
3253
+ break;
3254
+ }
3255
+ }
3256
+ },
3257
+ { properties: [idKey] },
3258
+ );
3185
3259
  return computed(() => {
3186
3260
  return select(itemIdSignal.value);
3187
3261
  });
@@ -3195,6 +3269,7 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
3195
3269
  };
3196
3270
 
3197
3271
  Object.assign(store, {
3272
+ idKey,
3198
3273
  uniqueKeys,
3199
3274
  arraySignal,
3200
3275
  select,
@@ -3207,11 +3282,63 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
3207
3282
  observeRemovals,
3208
3283
  observeIdChanges,
3209
3284
  registerItemMatchLifecycle,
3210
- signalForUniqueKey,
3285
+ signalForKey,
3211
3286
  });
3212
3287
  return store;
3213
3288
  };
3214
3289
 
3290
+ const syncStoreToSignals = (store, propertyToSignalMap) => {
3291
+ const { idKey } = store;
3292
+ const cleanupCallbackSet = new Set();
3293
+ for (const [propertyName, targetSignal] of Object.entries(
3294
+ propertyToSignalMap,
3295
+ )) {
3296
+ if (propertyName === idKey) {
3297
+ const unsubscribe = store.observeProperties(
3298
+ (mutationsArray) => {
3299
+ for (const mutations of mutationsArray) {
3300
+ const mutation = mutations[idKey];
3301
+ if (mutation.oldValue === targetSignal.peek()) {
3302
+ targetSignal.value = mutation.newValue;
3303
+ break;
3304
+ }
3305
+ }
3306
+ },
3307
+ { properties: [idKey] },
3308
+ );
3309
+ cleanupCallbackSet.add(unsubscribe);
3310
+ continue;
3311
+ }
3312
+ const itemSignal = store.signalForKey(propertyName, targetSignal);
3313
+ const unsubscribe = store.observeItemProperties(
3314
+ itemSignal,
3315
+ (propertyMutations) => {
3316
+ const mutation = propertyMutations[propertyName];
3317
+ targetSignal.value = mutation.newValue;
3318
+ },
3319
+ { properties: [propertyName] },
3320
+ );
3321
+ cleanupCallbackSet.add(unsubscribe);
3322
+ }
3323
+ return () => {
3324
+ for (const cleanup of cleanupCallbackSet) {
3325
+ cleanup();
3326
+ }
3327
+ cleanupCallbackSet.clear();
3328
+ };
3329
+ };
3330
+
3331
+ // WeakMap<action, Set<string>> — tracks which top-level properties were present in
3332
+ // the GET response for a given action instance. Used by scoped_many_effect to check
3333
+ // whether the parent GET embedded the child sub-resource.
3334
+ const actionResultPropertiesMap = new WeakMap();
3335
+ const recordGetResultProperties = (action, resultKeys) => {
3336
+ actionResultPropertiesMap.set(action, new Set(resultKeys));
3337
+ };
3338
+ const getActionResultProperties = (action) => {
3339
+ return actionResultPropertiesMap.get(action);
3340
+ };
3341
+
3215
3342
  /*
3216
3343
  * Default autorerun behavior explanation:
3217
3344
  * GET: false (RECOMMENDED)
@@ -3248,7 +3375,8 @@ const defaultRerunOn = {
3248
3375
  // This handles ALL resource lifecycle logic (rerun/reset) across all resources
3249
3376
  const createResourceLifecycleManager = () => {
3250
3377
  const registeredResources = new Map(); // Map<resourceInstance, lifecycleConfig>
3251
- const resourceDependencies = new Map(); // Map<resourceInstance, Set<dependentResources>>
3378
+ const resourceDependencies = new Map(); // Map<resourceInstance, Set<dependentResources>> — user-configured
3379
+ const scopedManyParents = new Map(); // Map<childResource, Set<{resource, propertyName}>> — auto from scopedMany
3252
3380
 
3253
3381
  const registerResource = (resourceScope, config) => {
3254
3382
  const {
@@ -3307,11 +3435,17 @@ const createResourceLifecycleManager = () => {
3307
3435
  triggerVerb === "PATCH") &&
3308
3436
  config.uniqueKeys.length > 0;
3309
3437
 
3438
+ const isKnownDependency =
3439
+ triggerResourceScope !== null &&
3440
+ triggerResourceScope !== undefined &&
3441
+ resourceDependencies.get(triggerResourceScope)?.has(resourceScope);
3442
+
3310
3443
  if (
3311
3444
  !shouldRerunGetMany &&
3312
3445
  !shouldRerunGet &&
3313
3446
  triggerVerb !== "DELETE" &&
3314
- !hasUniqueKeyAutorerun
3447
+ !hasUniqueKeyAutorerun &&
3448
+ !isKnownDependency
3315
3449
  ) {
3316
3450
  continue;
3317
3451
  }
@@ -3431,22 +3565,63 @@ const createResourceLifecycleManager = () => {
3431
3565
  }
3432
3566
  }
3433
3567
 
3434
- // Cross-resource dependency effects: rerun dependent GET_MANY
3568
+ // Cross-resource dependency effects: rerun dependent GET / GET_MANY
3569
+ // Fires on any mutating verb — user-configured dependencies express
3570
+ // "this resource depends on another resource's data", so any mutation
3571
+ // (POST, PUT, PATCH, DELETE) on the dependency should trigger a rerun.
3435
3572
  {
3436
3573
  if (
3437
3574
  triggerResourceScope &&
3438
3575
  resourceDependencies
3439
3576
  .get(triggerResourceScope)
3440
3577
  ?.has(resourceScope) &&
3441
- triggerVerb !== "GET" &&
3442
- candidateVerb === "GET" &&
3443
- candidateIsPlural
3578
+ (triggerVerb === "POST" ||
3579
+ triggerVerb === "PUT" ||
3580
+ triggerVerb === "PATCH" ||
3581
+ triggerVerb === "DELETE") &&
3582
+ candidateVerb === "GET"
3444
3583
  ) {
3445
3584
  actionsToRerun.add(actionCandidate);
3446
3585
  reasonSet.add("dependency autorerun");
3447
3586
  continue;
3448
3587
  }
3449
3588
  }
3589
+
3590
+ // scopedMany auto-dependency: only rerun parent singular GET on child POST,
3591
+ // and only when the parent GET previously returned the sub-resource embedded
3592
+ // inside its response (detected via action._resultProperties).
3593
+ // GET_MANY is excluded — a list of parents is not stale just because one
3594
+ // child item was added to one of them.
3595
+ scoped_many_effect: {
3596
+ if (
3597
+ triggerResourceScope &&
3598
+ triggerVerb === "POST" &&
3599
+ candidateVerb === "GET" &&
3600
+ !candidateIsPlural
3601
+ ) {
3602
+ const parentEntries = scopedManyParents.get(triggerResourceScope);
3603
+ if (!parentEntries) {
3604
+ break scoped_many_effect;
3605
+ }
3606
+ for (const {
3607
+ resource: parentResource,
3608
+ propertyName,
3609
+ } of parentEntries) {
3610
+ if (parentResource !== resourceScope) {
3611
+ continue;
3612
+ }
3613
+ // Only rerun if the last GET response included the embedded sub-resource.
3614
+ if (
3615
+ !getActionResultProperties(actionCandidate)?.has(propertyName)
3616
+ ) {
3617
+ break scoped_many_effect;
3618
+ }
3619
+ actionsToRerun.add(actionCandidate);
3620
+ reasonSet.add("scopedMany parent autorerun");
3621
+ continue;
3622
+ }
3623
+ }
3624
+ }
3450
3625
  }
3451
3626
  }
3452
3627
  }
@@ -3478,6 +3653,16 @@ const createResourceLifecycleManager = () => {
3478
3653
  registerResource,
3479
3654
  registerAction,
3480
3655
  onActionComplete,
3656
+ // Registers: when `triggerResource` fires, rerun `dependentResource`'s actions.
3657
+ // Used by scopedMany to make the parent GET rerun when a child mutation completes.
3658
+ addDependency: (triggerResource, dependentResource, propertyName) => {
3659
+ if (!scopedManyParents.has(triggerResource)) {
3660
+ scopedManyParents.set(triggerResource, new Set());
3661
+ }
3662
+ scopedManyParents
3663
+ .get(triggerResource)
3664
+ .add({ resource: dependentResource, propertyName });
3665
+ },
3481
3666
  };
3482
3667
  };
3483
3668
 
@@ -4265,7 +4450,14 @@ ${originalActionName} source location: ${locationInfo}`,
4265
4450
  `${childActionName} callback must return [ownerId, props], received ${result}`,
4266
4451
  );
4267
4452
  }
4268
- const [ownerId, props] = result;
4453
+ const [rawOwnerId, props] = result;
4454
+ const ownerId = resolveOwnerId(
4455
+ rawOwnerId,
4456
+ store,
4457
+ idKey,
4458
+ uniqueKeys,
4459
+ childActionName,
4460
+ );
4269
4461
  const childItem = scopedItemMap.get(ownerId);
4270
4462
  if (!childItem) {
4271
4463
  throw new Error(
@@ -4340,21 +4532,56 @@ ${originalActionName} source location: ${locationInfo}`,
4340
4532
  const scopedIdArraySignalMap = new Map(); // ownerId → childItemIdArraySignal
4341
4533
  addItemSetup((item) => {
4342
4534
  const ownerId = item[idKey];
4343
- const childStore = arraySignalStore([], childIdKey, {
4344
- name: `${childName}#${ownerId} store`,
4345
- createItem: (props) => {
4346
- const childItem = {};
4347
- Object.assign(childItem, props);
4348
- for (const childSetup of childSetupCallbackSet) {
4349
- childSetup(childItem);
4535
+
4536
+ // Reuse an existing scoped store if one was already created via a uniqueKey
4537
+ // (e.g. rows were fetched by tablename before the full table was loaded).
4538
+ let childStore = scopedStoreMap.get(ownerId);
4539
+ let childItemIdArraySignal = scopedIdArraySignalMap.get(ownerId);
4540
+ if (!childStore) {
4541
+ for (const uniqueKey of uniqueKeys) {
4542
+ const uniqueKeyValue = item[uniqueKey];
4543
+ if (uniqueKeyValue !== undefined) {
4544
+ const existing = scopedStoreMap.get(uniqueKeyValue);
4545
+ if (existing) {
4546
+ childStore = existing;
4547
+ childItemIdArraySignal =
4548
+ scopedIdArraySignalMap.get(uniqueKeyValue);
4549
+ break;
4550
+ }
4350
4551
  }
4351
- return childItem;
4352
- },
4353
- });
4552
+ }
4553
+ }
4554
+ if (!childStore) {
4555
+ childStore = arraySignalStore([], childIdKey, {
4556
+ name: `${childName}#${ownerId} store`,
4557
+ createItem: (props) => {
4558
+ const childItem = {};
4559
+ Object.assign(childItem, props);
4560
+ for (const childSetup of childSetupCallbackSet) {
4561
+ childSetup(childItem);
4562
+ }
4563
+ return childItem;
4564
+ },
4565
+ });
4566
+ childItemIdArraySignal = signal([]);
4567
+ }
4354
4568
  scopedStoreMap.set(ownerId, childStore);
4569
+ // Also register by each uniqueKey value so that resolveOwnerId works
4570
+ // when a callback returns { [uniqueKey]: value } before the full item is loaded.
4571
+ for (const uniqueKey of uniqueKeys) {
4572
+ const uniqueKeyValue = item[uniqueKey];
4573
+ if (uniqueKeyValue !== undefined) {
4574
+ scopedStoreMap.set(uniqueKeyValue, childStore);
4575
+ }
4576
+ }
4355
4577
 
4356
- const childItemIdArraySignal = signal([]);
4357
4578
  scopedIdArraySignalMap.set(ownerId, childItemIdArraySignal);
4579
+ for (const uniqueKey of uniqueKeys) {
4580
+ const uniqueKeyValue = item[uniqueKey];
4581
+ if (uniqueKeyValue !== undefined) {
4582
+ scopedIdArraySignalMap.set(uniqueKeyValue, childItemIdArraySignal);
4583
+ }
4584
+ }
4358
4585
 
4359
4586
  const updateChildItemIdArray = (valueArray) => {
4360
4587
  const currentIdArray = childItemIdArraySignal.peek();
@@ -4431,12 +4658,32 @@ ${originalActionName} source location: ${locationInfo}`,
4431
4658
  `${childActionName} callback must return [ownerId, ...] array, received ${result}`,
4432
4659
  );
4433
4660
  }
4434
- const [ownerId, ...rest] = result;
4435
- const childStore = scopedStoreMap.get(ownerId);
4661
+ const [rawOwnerId, ...rest] = result;
4662
+ const ownerId = resolveOwnerId(
4663
+ rawOwnerId,
4664
+ store,
4665
+ idKey,
4666
+ uniqueKeys,
4667
+ childActionName,
4668
+ );
4669
+ let childStore = scopedStoreMap.get(ownerId);
4436
4670
  if (!childStore) {
4437
- throw new Error(
4438
- `${childActionName}: no store found for scope id "${ownerId}"`,
4439
- );
4671
+ // Owner not yet in store — lazily create scoped store so actions can run
4672
+ // before the parent item has been fully loaded (e.g. rows fetched before table).
4673
+ childStore = arraySignalStore([], childIdKey, {
4674
+ name: `${childName}#${ownerId} store`,
4675
+ createItem: (props) => {
4676
+ const childItem = {};
4677
+ Object.assign(childItem, props);
4678
+ for (const childSetup of childSetupCallbackSet) {
4679
+ childSetup(childItem);
4680
+ }
4681
+ return childItem;
4682
+ },
4683
+ });
4684
+ scopedStoreMap.set(ownerId, childStore);
4685
+ const newIdArraySignal = signal([]);
4686
+ scopedIdArraySignalMap.set(ownerId, newIdArraySignal);
4440
4687
  }
4441
4688
  const childItemIdArraySignal = scopedIdArraySignalMap.get(ownerId);
4442
4689
 
@@ -4471,6 +4718,14 @@ ${originalActionName} source location: ${locationInfo}`,
4471
4718
  : childStore.upsert(rest[0]);
4472
4719
  return [ownerId, childItem[childIdKey]];
4473
4720
  },
4721
+ valueToData: (value) => {
4722
+ if (!value) return isMany ? [] : undefined;
4723
+ const [ownerId, idOrIdArray] = value;
4724
+ const childStore = scopedStoreMap.get(ownerId);
4725
+ if (!childStore) return isMany ? [] : undefined;
4726
+ if (isMany) return childStore.selectAll(idOrIdArray);
4727
+ return childStore.select(idOrIdArray);
4728
+ },
4474
4729
  completeSideEffect: (actionCompleted) => {
4475
4730
  lifecycleCtx.onComplete(actionCompleted);
4476
4731
  },
@@ -4478,6 +4733,11 @@ ${originalActionName} source location: ${locationInfo}`,
4478
4733
  return childAction;
4479
4734
  };
4480
4735
 
4736
+ // When a child (scopedMany) item is mutated via POST, the parent GET must
4737
+ // re-fetch because the parent embeds the child array and we cannot know the
4738
+ // new ordering without asking the backend again.
4739
+ // (scopedOne does NOT need this: the mutation result contains the updated
4740
+ // item directly, so no parent re-fetch is necessary.)
4481
4741
  const childResource = createResource(childName, {
4482
4742
  idKey: childIdKey,
4483
4743
  restCallbacks: {
@@ -4499,6 +4759,13 @@ ${originalActionName} source location: ${locationInfo}`,
4499
4759
  rerunOn: scopedManyRerunOn ?? rerunOn,
4500
4760
  dependencies: scopedManyDependencies ?? dependencies,
4501
4761
  });
4762
+ // Register: when childResource fires, rerun parent (stateFacade) GETs.
4763
+ resourceLifecycleManager.addDependency(
4764
+ childResource,
4765
+ stateFacade,
4766
+ propertyName,
4767
+ );
4768
+ childResource.getChildStore = (ownerKey) => scopedStoreMap.get(ownerKey);
4502
4769
  return childResource;
4503
4770
  };
4504
4771
 
@@ -4612,6 +4879,11 @@ ${originalActionName} source location: ${locationInfo}`,
4612
4879
  ${originalActionName} source location: ${locationInfo}`,
4613
4880
  );
4614
4881
  }
4882
+ // Track which top-level properties the GET response contained so that
4883
+ // lifecycle rules can detect whether sub-resources were embedded.
4884
+ if (verb === "GET") {
4885
+ recordGetResultProperties(action, Object.keys(result));
4886
+ }
4615
4887
  return applyResultToValue(result);
4616
4888
  },
4617
4889
  valueToData: (itemId) => store.select(itemId),
@@ -4702,6 +4974,110 @@ const isProps = (value) => {
4702
4974
  return value !== null && typeof value === "object";
4703
4975
  };
4704
4976
 
4977
+ const resolveOwnerId = (rawOwnerId, store, idKey, uniqueKeys, actionName) => {
4978
+ if (!isProps(rawOwnerId)) {
4979
+ // Already a primitive — use as-is.
4980
+ return rawOwnerId;
4981
+ }
4982
+
4983
+ const keys = Object.keys(rawOwnerId);
4984
+
4985
+ if (keys.length === 1) {
4986
+ const [propName] = keys;
4987
+ const propValue = rawOwnerId[propName];
4988
+
4989
+ if (propName === idKey) {
4990
+ return propValue;
4991
+ }
4992
+ if (uniqueKeys.includes(propName)) {
4993
+ const item = store.select(propName, propValue);
4994
+ if (!item) {
4995
+ // Owner not yet in store — the scoped maps may still be keyed by uniqueKey value
4996
+ // (registered during addItemSetup). Return the propValue as the owner key directly.
4997
+ return propValue;
4998
+ }
4999
+ return item[idKey];
5000
+ }
5001
+ throw new TypeError(
5002
+ `${actionName}: the first element of the returned array is { ${propName}: "${propValue}" } but "${propName}" is neither the idKey ("${idKey}") nor a declared uniqueKey (${uniqueKeys.length ? uniqueKeys.join(", ") : "none"}).
5003
+ Return a primitive id or a single-property object whose key is the idKey or a uniqueKey.`,
5004
+ );
5005
+ }
5006
+
5007
+ // More than one property — try to recover via idKey, warn if successful.
5008
+ if (idKey in rawOwnerId) {
5009
+ const resolvedId = rawOwnerId[idKey];
5010
+ console.warn(
5011
+ `${actionName}: the first element of the returned array is an object with multiple properties.
5012
+ Only "${idKey}" is needed. Consider returning a primitive id or { ${idKey}: value } instead.`,
5013
+ );
5014
+ return resolvedId;
5015
+ }
5016
+
5017
+ throw new TypeError(
5018
+ `${actionName}: the first element of the returned array must be a primitive id or a single-property object equal to { [idKey]: value } or { [uniqueKey]: value }.
5019
+ Received an object with keys: ${keys.join(", ")}.`,
5020
+ );
5021
+ };
5022
+
5023
+ /** so that when a tracked property changes
5024
+ * on an item the corresponding signal is updated automatically.
5025
+ *
5026
+ * Since signals are typically connected to route parameters via the route template
5027
+ * syntax, this keeps the URL in sync when a store item's mutable key is renamed.
5028
+ *
5029
+ * @example
5030
+ * const usernameSignal = stateSignal();
5031
+ * const USER_ROUTE = route(`/users/:username=${usernameSignal}/`);
5032
+ *
5033
+ * const USER = resource("user", {
5034
+ * idKey: "id",
5035
+ * uniqueKeys: ["username"],
5036
+ * PUT: async ({ id, username }) => ({ id, username }),
5037
+ * });
5038
+ *
5039
+ * syncResourceToSignals(USER, { username: usernameSignal });
5040
+ * // Now when a user item's username is updated via USER.PUT,
5041
+ * // usernameSignal.value is set to the new username,
5042
+ * // which in turn triggers the route Signal->URL sync and updates the browser URL.
5043
+ */
5044
+ const syncResourceToSignals = (resource, propertyToSignalMap) => {
5045
+ if (resource.getChildStore) {
5046
+ throw new Error(
5047
+ `syncResourceToSignals: "${resource.name}" is a scoped resource (scopedMany/scopedOne). Use syncOwnedResourceToSignals instead.`,
5048
+ );
5049
+ }
5050
+ syncStoreToSignals(resource.store, propertyToSignalMap);
5051
+ };
5052
+
5053
+ const syncOwnedResourceToSignals = (
5054
+ resource,
5055
+ ownerSignal,
5056
+ propertyToSignalMap,
5057
+ ) => {
5058
+ if (!resource.getChildStore) {
5059
+ throw new Error(
5060
+ `syncOwnedResourceToSignals: "${resource.name}" is not a scoped resource (scopedMany/scopedOne). Use syncResourceToSignals instead.`,
5061
+ );
5062
+ }
5063
+ effect(() => {
5064
+ // Always subscribe to the parent store so the effect re-runs when a new
5065
+ // owner item is added (which creates the child store).
5066
+ // eslint-disable-next-line no-unused-expressions
5067
+ resource.store.arraySignal.value;
5068
+ const ownerKey = ownerSignal.value;
5069
+ if (ownerKey === null || ownerKey === undefined) {
5070
+ return null;
5071
+ }
5072
+ const childStore = resource.getChildStore(ownerKey);
5073
+ if (!childStore) {
5074
+ return null;
5075
+ }
5076
+ const cleanup = syncStoreToSignals(childStore, propertyToSignalMap);
5077
+ return cleanup;
5078
+ });
5079
+ };
5080
+
4705
5081
  const valueInLocalStorage = (key, { type = "any" } = {}) => {
4706
5082
  const converter = TYPE_CONVERTERS[type];
4707
5083
 
@@ -5401,6 +5777,298 @@ const useStateArray = (
5401
5777
  return [array, add, remove, reset];
5402
5778
  };
5403
5779
 
5780
+ const promiseStateWeakMap = new WeakMap();
5781
+ const usePromiseAsyncData = (
5782
+ promise,
5783
+ { loadingEffect, errorEffect },
5784
+ ) => {
5785
+ const forceRender = useForceRender();
5786
+
5787
+ let promiseState = promiseStateWeakMap.get(promise);
5788
+ if (!promiseState) {
5789
+ promiseState = {
5790
+ data: undefined,
5791
+ error: undefined,
5792
+ settled: false,
5793
+ };
5794
+ promiseStateWeakMap.set(promise, promiseState);
5795
+ promise.then(
5796
+ (data) => {
5797
+ promiseState.data = data;
5798
+ promiseState.settled = true;
5799
+ forceRender();
5800
+ },
5801
+ (error) => {
5802
+ promiseState.error = error;
5803
+ promiseState.settled = true;
5804
+ forceRender();
5805
+ },
5806
+ );
5807
+ }
5808
+ if (!promiseState.settled) {
5809
+ if (loadingEffect === "use") {
5810
+ return [promiseState.data, true, undefined];
5811
+ }
5812
+ throw promise;
5813
+ }
5814
+ if (promiseState.error) {
5815
+ if (errorEffect === "use") {
5816
+ return [promiseState.data, false, promiseState.error];
5817
+ }
5818
+ throw promiseState.error;
5819
+ }
5820
+ return [promiseState.data, false, undefined];
5821
+ };
5822
+
5823
+ const useForceRender = () => {
5824
+ const [, setState] = useState(null);
5825
+ return () => {
5826
+ setState({});
5827
+ };
5828
+ };
5829
+
5830
+ // https://github.com/preactjs/preact/issues/4756
5831
+
5832
+ const useAsyncData = (promiseOrAction, {
5833
+ loading = "delegate",
5834
+ error = "delegate"
5835
+ } = {}) => {
5836
+ const isAction = Boolean(promiseOrAction && promiseOrAction.isAction);
5837
+ if (loading === true) {
5838
+ loading = "use";
5839
+ }
5840
+ if (error === true) {
5841
+ error = "use";
5842
+ }
5843
+ if (isAction) {
5844
+ return useActionAsyncData(promiseOrAction, {
5845
+ loadingEffect: loading,
5846
+ errorEffect: error
5847
+ });
5848
+ }
5849
+ return usePromiseAsyncData(promiseOrAction, {
5850
+ loadingEffect: loading,
5851
+ errorEffect: error
5852
+ });
5853
+ };
5854
+
5855
+ // ─── useAction ────────────────────────────────────────────────────────────────
5856
+
5857
+ const LoadingContext$1 = createContext(null);
5858
+ const actionPendingPromiseWeakMap = new WeakMap();
5859
+ const dismissedActionWeakSet = new WeakSet();
5860
+ const dismissedActionPendingPromiseWeakMap = new WeakMap();
5861
+ const useActionAsyncData = (action, {
5862
+ loadingEffect,
5863
+ errorEffect
5864
+ }) => {
5865
+ const loadingRef = useContext(LoadingContext$1);
5866
+ if (!loadingRef) {
5867
+ throw new Error("Missing <Loading>");
5868
+ }
5869
+
5870
+ // Use peek() instead of .value to avoid subscribing this component to the signal.
5871
+ // Reading .value would make Preact re-render the component reactively when the state
5872
+ // changes. When the action fails while Suspense is still holding the detached stale
5873
+ // DOM, this reactive re-render causes Suspense to move that stale DOM permanently
5874
+ // back into the document — the stale content then coexists with the error fallback
5875
+ // and never goes away. Manual subscription via useEffect + useState ensures
5876
+ // re-renders only happen after the pending promise resolves, at which point Suspense
5877
+ // has already processed the settlement and the detached DOM is discarded.
5878
+ const runningState = action.runningStateSignal.peek();
5879
+ const [, setTick] = useState(0);
5880
+ useEffect(() => {
5881
+ return action.runningStateSignal.subscribe(state => {
5882
+ if (state === RUNNING) {
5883
+ dismissedActionWeakSet.delete(action);
5884
+ }
5885
+ setTick(n => n + 1);
5886
+ });
5887
+ }, []);
5888
+ if (runningState === COMPLETED) {
5889
+ return [action.dataSignal.peek(), false, undefined];
5890
+ }
5891
+ if (runningState === FAILED) {
5892
+ if (dismissedActionWeakSet.has(action)) {
5893
+ const staleData = action.dataSignal.peek();
5894
+ if (staleData !== undefined) {
5895
+ // Dismissed with stale data — return it so children render normally
5896
+ return [staleData, false, undefined];
5897
+ }
5898
+ // Dismissed with no data — suspend until the action re-runs.
5899
+ // A never-resolving promise would leave the component stuck forever,
5900
+ // so we use an action-specific promise that resolves on RUNNING,
5901
+ // which lets the component re-render and go through the normal loading path.
5902
+ let dismissedPromise = dismissedActionPendingPromiseWeakMap.get(action);
5903
+ if (!dismissedPromise) {
5904
+ dismissedPromise = new Promise(resolve => {
5905
+ const unsubscribe = action.runningStateSignal.subscribe(state => {
5906
+ if (state === RUNNING) {
5907
+ dismissedActionPendingPromiseWeakMap.delete(action);
5908
+ unsubscribe();
5909
+ resolve();
5910
+ }
5911
+ });
5912
+ });
5913
+ dismissedActionPendingPromiseWeakMap.set(action, dismissedPromise);
5914
+ }
5915
+ throw dismissedPromise;
5916
+ }
5917
+ const actionError = action.errorSignal.peek();
5918
+ if (errorEffect === "use") {
5919
+ const dismissError = () => {
5920
+ dismissedActionWeakSet.add(action);
5921
+ setTick(n => n + 1);
5922
+ };
5923
+ return [undefined, false, actionError, dismissError];
5924
+ }
5925
+ actionError.action = action;
5926
+ throw actionError;
5927
+ }
5928
+
5929
+ // RUNNING with loadingEffect: "use" — return stale data + loading flag, no suspend
5930
+ if (loadingEffect === "use" && runningState === RUNNING) {
5931
+ const staleData = action.dataSignal.peek();
5932
+ return [staleData, true, undefined];
5933
+ }
5934
+
5935
+ // IDLE or RUNNING with loadingEffect: "delegate" — suspend
5936
+ const reason = runningState === RUNNING ? "loading" : "idle";
5937
+ loadingRef.current = {
5938
+ reason,
5939
+ action
5940
+ };
5941
+ let pendingPromise = actionPendingPromiseWeakMap.get(action);
5942
+ if (!pendingPromise) {
5943
+ pendingPromise = new Promise(resolve => {
5944
+ const unsubscribe = action.runningStateSignal.subscribe(state => {
5945
+ if (state === COMPLETED || state === FAILED) {
5946
+ actionPendingPromiseWeakMap.delete(action);
5947
+ unsubscribe();
5948
+ resolve();
5949
+ } else if (reason === "idle" && state === RUNNING) {
5950
+ // idle→running: unblock so loadingRef reason updates to "loading"
5951
+ actionPendingPromiseWeakMap.delete(action);
5952
+ unsubscribe();
5953
+ resolve();
5954
+ }
5955
+ });
5956
+ });
5957
+ actionPendingPromiseWeakMap.set(action, pendingPromise);
5958
+ }
5959
+ throw pendingPromise;
5960
+ };
5961
+
5962
+ // ─── Loading ──────────────────────────────────────────────────────────────────
5963
+ // Wraps Suspense. Provides LoadingContext so useAction can write the suspension
5964
+ // reason. LoadingFallback reads that reason and subscribes to the action so it
5965
+ // only shows the spinner when actually loading (not in the initial idle state).
5966
+ const Loading = ({
5967
+ children,
5968
+ fallback
5969
+ }) => {
5970
+ const loadingRef = useRef({
5971
+ reason: "idle",
5972
+ action: null
5973
+ });
5974
+ return jsx(LoadingContext$1.Provider, {
5975
+ value: loadingRef,
5976
+ children: jsx(Suspense, {
5977
+ fallback: jsx(LoadingFallback, {
5978
+ loadingRef: loadingRef,
5979
+ fallback: fallback
5980
+ }),
5981
+ children: children
5982
+ })
5983
+ });
5984
+ };
5985
+ const LoadingFallback = ({
5986
+ loadingRef,
5987
+ fallback
5988
+ }) => {
5989
+ const [, setTick] = useState(0);
5990
+ const {
5991
+ action
5992
+ } = loadingRef.current;
5993
+ useEffect(() => {
5994
+ if (!action) {
5995
+ return undefined;
5996
+ }
5997
+ return action.runningStateSignal.subscribe(() => {
5998
+ setTick(n => n + 1);
5999
+ });
6000
+ }, [action]);
6001
+ if (loadingRef.current.reason !== "loading") {
6002
+ return null;
6003
+ }
6004
+ if (typeof fallback === "function") {
6005
+ return h(fallback);
6006
+ }
6007
+ return fallback;
6008
+ };
6009
+
6010
+ // ─── ErrorBoundary ────────────────────────────────────────────────────────────
6011
+ // Catches errors thrown by useAction. Subscribes to error.action so it
6012
+ // auto-resets when the action runs again.
6013
+ const ErrorBoundary = ({
6014
+ children,
6015
+ fallback,
6016
+ onReset
6017
+ }) => {
6018
+ const [error, resetError] = useErrorBoundary();
6019
+ const [dismissed, setDismissed] = useState(false);
6020
+ const cleanupRef = useRef();
6021
+ useEffect(() => {
6022
+ return () => {
6023
+ cleanupRef.current?.();
6024
+ };
6025
+ }, []);
6026
+ if (error) {
6027
+ error.__handled_by__ = "<ErrorBoundary>"; // prevent jsenv from displaying it
6028
+
6029
+ const action = error.action;
6030
+ if (action) {
6031
+ cleanupRef.current?.();
6032
+ cleanupRef.current = action.runningStateSignal.subscribe(state => {
6033
+ if (state === RUNNING) {
6034
+ dismissedActionWeakSet.delete(action);
6035
+ setDismissed(false);
6036
+ resetError();
6037
+ }
6038
+ });
6039
+ const hasStaleData = action && action.dataSignal.peek() !== undefined;
6040
+ if (dismissed) {
6041
+ if (hasStaleData) {
6042
+ // Has stale data — children will render (useAction returns stale value)
6043
+ return children;
6044
+ }
6045
+ }
6046
+ } else if (dismissed) {
6047
+ // stop rendering the error
6048
+ return null;
6049
+ }
6050
+ const dismiss = () => {
6051
+ if (action) {
6052
+ dismissedActionWeakSet.add(action);
6053
+ }
6054
+ onReset?.();
6055
+ setDismissed(true);
6056
+ resetError();
6057
+ };
6058
+ if (!fallback) {
6059
+ return null;
6060
+ }
6061
+ if (typeof fallback === "function") {
6062
+ return h(fallback, {
6063
+ error,
6064
+ resetError: dismiss
6065
+ });
6066
+ }
6067
+ return fallback;
6068
+ }
6069
+ return children;
6070
+ };
6071
+
5404
6072
  /**
5405
6073
  * Creates a function that generates abort signals, automatically cancelling previous requests.
5406
6074
  *
@@ -7084,7 +7752,9 @@ const Box = props => {
7084
7752
  }
7085
7753
  const propCssVar = propsCSSVars[name];
7086
7754
  if (propCssVar) {
7087
- addCSSVar(value, propCssVar, boxStylesTarget);
7755
+ if (value !== undefined) {
7756
+ addCSSVar(value, propCssVar, boxStylesTarget);
7757
+ }
7088
7758
  return;
7089
7759
  }
7090
7760
  const isPseudoStyle = styleOrigin === "pseudo_style";
@@ -10326,9 +10996,63 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
10326
10996
  );
10327
10997
  };
10328
10998
 
10329
- // Pattern object with unified data and methods
10330
- const patternObject = {
10331
- // Pattern data properties (formerly patternData)
10999
+ // Returns the pathname that this route's own literal prefix resolves to.
11000
+ // For route "/": "/"
11001
+ // For route "/profile/": "/profile/"
11002
+ // For route "/map/isochrone/compare": "/map/isochrone/compare"
11003
+ // For route "/map/isochrone/:tab=/": "/map/isochrone/" (literal prefix before first param)
11004
+ const getOwnBasePathname = () => {
11005
+ const segments = parsedPattern.segments;
11006
+ if (segments.length === 0) {
11007
+ return new URL(resolveRouteUrl("/")).pathname;
11008
+ }
11009
+ const literalSegments = [];
11010
+ for (const seg of segments) {
11011
+ if (seg.type !== "literal") {
11012
+ break;
11013
+ }
11014
+ literalSegments.push(seg.value);
11015
+ }
11016
+ if (literalSegments.length === 0) {
11017
+ return new URL(resolveRouteUrl("/")).pathname;
11018
+ }
11019
+ const prefix = `/${literalSegments.join("/")}/`;
11020
+ return new URL(resolveRouteUrl(prefix)).pathname;
11021
+ };
11022
+
11023
+ // Like buildMostPreciseUrl but takes the actual current browser URL into account.
11024
+ // When buildMostPreciseUrl returns the route's own base URL (catch-all matching an
11025
+ // unrepresentable path like "/404") the current pathname is preserved and only
11026
+ // search params are updated. When buildMostPreciseUrl performs an ancestor
11027
+ // optimisation (e.g. "/map/isochrone/compare" → "/map/isochrone") it is trusted
11028
+ // as-is because the built pathname will differ from the route's own base pathname.
11029
+ const buildUrlPreservingPath = (currentUrl, params = {}) => {
11030
+ const relativeBuiltUrl = buildMostPreciseUrl(params);
11031
+ if (!currentUrl) {
11032
+ return resolveRouteUrl(relativeBuiltUrl);
11033
+ }
11034
+ const absoluteBuiltUrl = resolveRouteUrl(relativeBuiltUrl);
11035
+ const builtPathname = new URL(absoluteBuiltUrl).pathname;
11036
+ const currentPathname = new URL(currentUrl).pathname;
11037
+ if (builtPathname === currentPathname) {
11038
+ return absoluteBuiltUrl;
11039
+ }
11040
+ const ownBasePathname = getOwnBasePathname();
11041
+ if (builtPathname === ownBasePathname) {
11042
+ // Catch-all: the route resolved to its own base pathname but the current URL
11043
+ // sits on a different path that this trailing-slash route caught. Keep the
11044
+ // current pathname and only update the search string.
11045
+ const correctedUrl = new URL(currentUrl);
11046
+ correctedUrl.search = new URL(absoluteBuiltUrl).search;
11047
+ return correctedUrl.href;
11048
+ }
11049
+ // Ancestor optimisation or descendant selection — trust buildMostPreciseUrl.
11050
+ return absoluteBuiltUrl;
11051
+ };
11052
+
11053
+ // Pattern object with unified data and methods
11054
+ const patternObject = {
11055
+ // Pattern data properties (formerly patternData)
10332
11056
  urlPatternRaw: pattern,
10333
11057
  cleanPattern,
10334
11058
  connections,
@@ -10345,6 +11069,7 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
10345
11069
  pattern: parsedPattern,
10346
11070
  applyOn,
10347
11071
  buildMostPreciseUrl,
11072
+ buildUrlPreservingPath,
10348
11073
  resolveParams,
10349
11074
  };
10350
11075
 
@@ -11591,7 +12316,7 @@ const route = (pattern, { searchParams } = {}) => {
11591
12316
  route.setupCalled = true;
11592
12317
  });
11593
12318
  // methods
11594
- registerSetup(({ routeSet }) => {
12319
+ registerSetup(({ routeSet, getUrl }) => {
11595
12320
  route.buildRelativeUrl = (params) => {
11596
12321
  // buildMostPreciseUrl now handles parameter resolution internally
11597
12322
  return routePattern.buildMostPreciseUrl(params);
@@ -11660,7 +12385,20 @@ const route = (pattern, { searchParams } = {}) => {
11660
12385
  callReason: `replaceParams delegation from ${route} to ${mostSpecificRoute} (original reason: ${callReason})`,
11661
12386
  });
11662
12387
  }
11663
- return route.redirectTo(newParams, {
12388
+
12389
+ // This route is the most specific — compute the target URL.
12390
+ // buildUrlPreservingPath handles the catch-all case where this trailing-slash
12391
+ // route matched a path it cannot represent (e.g. "/" on "/404") without
12392
+ // corrupting the current URL. Ancestor optimisation is trusted as-is.
12393
+ if (!integration) {
12394
+ return Promise.resolve();
12395
+ }
12396
+ const targetUrl = routePattern.buildUrlPreservingPath(
12397
+ getUrl(),
12398
+ newParams,
12399
+ );
12400
+ return integration.navTo(targetUrl, {
12401
+ replace: true,
11664
12402
  callReason,
11665
12403
  });
11666
12404
  };
@@ -11835,6 +12573,8 @@ This prevents cross-test pollution and ensures clean state.`,
11835
12573
  setupRoutesCalled = true;
11836
12574
 
11837
12575
  const routeSet = new Set();
12576
+ let currentUrl = null;
12577
+ const getUrl = () => currentUrl;
11838
12578
  // PHASE 1: Setup patterns with unified objects (includes all relationships and signal connections)
11839
12579
  const routePatterns = [];
11840
12580
  for (const route of routes) {
@@ -11847,7 +12587,7 @@ This prevents cross-test pollution and ensures clean state.`,
11847
12587
  // Setup routes now that patterns are correctly initialized
11848
12588
  for (const route of routeSet) {
11849
12589
  const { setup } = getRoutePrivateProperties(route);
11850
- setup({ routeSet });
12590
+ setup({ routeSet, getUrl });
11851
12591
  }
11852
12592
 
11853
12593
  // Store previous route states to detect changes
@@ -11859,6 +12599,7 @@ This prevents cross-test pollution and ensures clean state.`,
11859
12599
  // state
11860
12600
  } = {},
11861
12601
  ) => {
12602
+ currentUrl = url;
11862
12603
  const returnValue = {};
11863
12604
  const routeMatchInfoSet = new Set();
11864
12605
  for (const route of routeSet) {
@@ -12850,22 +13591,67 @@ const Head = ({
12850
13591
  };
12851
13592
 
12852
13593
  /**
13594
+ * Route is the single primitive for URL-based rendering.
13595
+ *
13596
+ * ## Layout pattern
13597
+ * Use this when multiple routes share a common layout but have no shared URL prefix,
13598
+ * making it impossible to set a guard route on the parent container.
13599
+ * For example, `/profile` and `/settings` both live inside `AuthLayout` but there
13600
+ * is no `/auth/` prefix to match on. A container Route wraps them: the active
13601
+ * child's element is injected as `children` into the layout element.
13602
+ * If a page needs state owned by the layout, the layout must expose it via context.
13603
+ *
13604
+ * ```jsx
13605
+ * const PROFILE_ROUTE = route("/profile");
13606
+ * const SETTINGS_ROUTE = route("/settings");
13607
+ *
13608
+ * <Route element={AuthLayout}>
13609
+ * <Route route={PROFILE_ROUTE} element={ProfilePage} />
13610
+ * <Route route={SETTINGS_ROUTE} element={SettingsPage} />
13611
+ * <Route fallback element={AuthNotFoundPage} />
13612
+ * </Route>
13613
+ * ```
13614
+ *
13615
+ * ## Self-contained section pattern
13616
+ * Use this when routes share a common URL prefix (e.g. `/dashboard/`).
13617
+ * A single leaf Route in the top-level router matches the prefix; the component
13618
+ * it renders owns its sub-router and all related routes internally.
13619
+ * Everything about the section — routes, structure, sub-pages — is co-located.
13620
+ * The component is not a reusable layout; it is the section.
12853
13621
  *
12854
- * . Refactor les actions pour qu'elles utilisent use. Ce qui va ouvrir la voie pour
12855
- * Suspense et ErrorBoundary sur tous les composants utilisant des actions
13622
+ * Compared to the layout pattern, a dedicated section component is more powerful:
13623
+ * - Wrapper elements (chrome, nav, containers) are part of the component's render,
13624
+ * not injected via `children`, so a layout is not needed.
13625
+ * - Local state can be passed directly via `elementProps` to sub-pages. With the
13626
+ * layout pattern, sub-pages receive state only through `children` or context.
12856
13627
  *
12857
- * . Tester le code splitting avec .lazy + import dynamique
12858
- * pour les elements des routes
13628
+ * The layout pattern remains necessary when a shared prefix does not exist
13629
+ * (see "Layout pattern" above).
12859
13630
  *
12860
- * 3. Ajouter la possibilite d'avoir des
12861
- * sur les routes
12862
- * Tester juste les data pour commencer
12863
- * On aura ptet besoin d'un useRouteData au lieu de passer par un element qui est une fonction
12864
- * pour que react ne re-render pas tout
13631
+ * ```jsx
13632
+ * const DASHBOARD_SECTION_ROUTE = route("/dashboard/");
13633
+ * const DASHBOARD_HOME_ROUTE = route("/dashboard");
13634
+ * const DASHBOARD_POSTS_ROUTE = route("/dashboard/posts");
12865
13635
  *
12866
- * 4. Utiliser use() pour compar Suspense et ErrorBoundary lorsque route action se produit.
13636
+ * // top-level router only knows about the prefix
13637
+ * <Route route={DASHBOARD_SECTION_ROUTE} element={DashboardSection} />
12867
13638
  *
13639
+ * // Dashboard owns the rest
13640
+ * const DashboardSection = () => {
13641
+ * const [sidebarOpen, setSidebarOpen] = useState(false);
12868
13642
  *
13643
+ * return <div
13644
+ * style="background: lightblue; padding: 10px;"
13645
+ * onClick={() => setSidebarOpen(o => !o)}
13646
+ * >
13647
+ * <Route>
13648
+ * <Route route={DASHBOARD_HOME_ROUTE} element={DashboardHomePage} elementProps={{ sidebarOpen}} />
13649
+ * <Route route={DASHBOARD_POSTS_ROUTE} element={DashboardPostsPage} elementProps={{ sidebarOpen }} />
13650
+ * <Route fallback element={DashboardNotFound} />
13651
+ * </Route>
13652
+ * </div>;
13653
+ * }
13654
+ * ```
12869
13655
  */
12870
13656
 
12871
13657
  const debug$1 = (...args) => {
@@ -12974,11 +13760,7 @@ const RouteContainer = ({
12974
13760
  return null;
12975
13761
  }
12976
13762
  if (element) {
12977
- const Element = element;
12978
- return jsx(Element, {
12979
- ...elementProps,
12980
- children: content
12981
- });
13763
+ return h(element, elementProps, content);
12982
13764
  }
12983
13765
  return content;
12984
13766
  };
@@ -13007,17 +13789,12 @@ const RouteLeafFallback = props => {
13007
13789
  };
13008
13790
  const RouteActive = ({
13009
13791
  element,
13010
- elementProps,
13011
- action
13792
+ elementProps
13012
13793
  }) => {
13013
- const Element = element;
13014
- const renderedElement = action ? jsx(ActionRenderer, {
13015
- action: action,
13016
- children: element
13017
- }) : typeof element === "function" ? jsx(Element, {
13018
- ...elementProps
13019
- }) : element;
13020
- return renderedElement;
13794
+ if (typeof element === "function") {
13795
+ return h(element, elementProps);
13796
+ }
13797
+ return element;
13021
13798
  };
13022
13799
 
13023
13800
  const routeAction = (
@@ -13039,48 +13816,6 @@ const routeAction = (
13039
13816
  options,
13040
13817
  );
13041
13818
 
13042
- // If the action is related to a store of items
13043
- // we want to keep the url in sync with the item id of the store when it changes
13044
- // This way whenever an item with a mutable id is updated, the url is also updated
13045
- // (renaming a user while being on the user page)
13046
- // In case the route does not use this param, then this code won't have an effect
13047
- // To work the route params MUST use the same name (case sensitive) as the mutable id key
13048
- // so "/users/:id/" with mutableIdKey "id" will work but "/users/:userId/" with mutableIdKey "id" won't work
13049
- sync_url_and_item_id: {
13050
- const { store } = actionBoundToRoute.meta;
13051
- if (!store) {
13052
- break sync_url_and_item_id;
13053
- }
13054
- const { uniqueKeys } = store;
13055
- const [firstUniqueKey] = uniqueKeys;
13056
- if (!firstUniqueKey) {
13057
- break sync_url_and_item_id;
13058
- }
13059
- const uniqueValueSignal = computed(() => {
13060
- const params = route.paramsSignal.value;
13061
- const uniqueKeyValue = params[firstUniqueKey];
13062
- return uniqueKeyValue;
13063
- });
13064
- const routeItemSignal = store.signalForUniqueKey(
13065
- firstUniqueKey,
13066
- uniqueValueSignal,
13067
- );
13068
- store.observeItemProperties(routeItemSignal, (propertyMutations) => {
13069
- const uniquePropertyMutation = propertyMutations[firstUniqueKey];
13070
- if (!uniquePropertyMutation) {
13071
- return;
13072
- }
13073
- route.replaceParams(
13074
- {
13075
- [firstUniqueKey]: uniquePropertyMutation.newValue,
13076
- },
13077
- {
13078
- callReason: `store item ${firstUniqueKey} change on ${route}`,
13079
- },
13080
- );
13081
- });
13082
- }
13083
-
13084
13819
  return actionBoundToRoute;
13085
13820
  };
13086
13821
 
@@ -15184,6 +15919,10 @@ const openCallout = (
15184
15919
  `anchor element is not visually visible (${anchorVisuallyVisibleInfo.reason}) -> will be anchored to first visually visible ancestor`,
15185
15920
  );
15186
15921
  anchorElement = getFirstVisuallyVisibleAncestor(anchorElement);
15922
+ if (!anchorElement) {
15923
+ // anchorElement is not in the DOM anymore, fallback to body
15924
+ anchorElement = document.body;
15925
+ }
15187
15926
  }
15188
15927
 
15189
15928
  allowWheelThrough(calloutElement, anchorElement);
@@ -17377,8 +18116,15 @@ const installCustomConstraintValidation = (
17377
18116
  closeElementValidationMessage("cleanup");
17378
18117
  });
17379
18118
 
17380
- const anchorElement =
17381
- failedConstraintInfo.target || elementReceivingValidationMessage;
18119
+ const anchorElement = (() => {
18120
+ const base =
18121
+ failedConstraintInfo.target || elementReceivingValidationMessage;
18122
+ const renderedBy = base.getAttribute("data-rendered-by");
18123
+ if (renderedBy) {
18124
+ return base.closest(renderedBy) || base;
18125
+ }
18126
+ return base;
18127
+ })();
17382
18128
  validationInterface.validationMessage = openCallout(
17383
18129
  failedConstraintInfo.message,
17384
18130
  {
@@ -19442,7 +20188,6 @@ const useExecuteAction = (
19442
20188
  const resetErrorBoundary = useResetErrorBoundary();
19443
20189
  useLayoutEffect(() => {
19444
20190
  if (error) {
19445
- error.__handled__ = true; // prevent jsenv from displaying it
19446
20191
  throw error;
19447
20192
  }
19448
20193
  }, [error]);
@@ -20656,6 +21401,10 @@ installImportMetaCss(import.meta);import.meta.css = /* css */`
20656
21401
  border-radius: inherit;
20657
21402
  pointer-events: none;
20658
21403
  }
21404
+
21405
+ & > img {
21406
+ border-radius: inherit;
21407
+ }
20659
21408
  }
20660
21409
 
20661
21410
  &[data-reveal-on-interaction] {
@@ -20746,13 +21495,10 @@ installImportMetaCss(import.meta);import.meta.css = /* css */`
20746
21495
  --x-button-border-color: transparent;
20747
21496
  }
20748
21497
  }
20749
- }
20750
- /* Callout (info, warning, error) */
20751
- .navi_button[data-callout] {
20752
- --x-button-border-color: var(--callout-color);
20753
- }
20754
- .navi_button > img {
20755
- border-radius: inherit;
21498
+ /* Callout (info, warning, error) */
21499
+ &[data-callout] {
21500
+ --x-button-border-color: var(--callout-color);
21501
+ }
20756
21502
  }
20757
21503
  `;
20758
21504
  const Button = props => {
@@ -24585,6 +25331,7 @@ installImportMetaCss(import.meta);import.meta.css = /* css */`
24585
25331
  /* Callout (info, warning, error) */
24586
25332
  &[data-callout] {
24587
25333
  --x-border-color: var(--callout-color);
25334
+ --x-outline-color: var(--callout-color);
24588
25335
  }
24589
25336
  }
24590
25337
 
@@ -26001,219 +26748,6 @@ const filterTableSelection = (selection, predicate) => {
26001
26748
  return matching;
26002
26749
  };
26003
26750
 
26004
- // https://github.com/reach/reach-ui/tree/b3d94d22811db6b5c0f272b9a7e2e3c1bb4699ae/packages/descendants
26005
- // https://github.com/pacocoursey/use-descendants/tree/master
26006
-
26007
- const createIsolatedItemTracker = () => {
26008
- // Producer contexts (ref-based, no re-renders)
26009
- const ProducerTrackerContext = createContext();
26010
- const ProducerItemCountRefContext = createContext();
26011
- const ProducerListRenderIdContext = createContext();
26012
-
26013
- // Consumer contexts (state-based, re-renders)
26014
- const ConsumerItemsContext = createContext();
26015
- const useIsolatedItemTrackerProvider = () => {
26016
- const itemsRef = useRef([]);
26017
- const items = itemsRef.current;
26018
- const itemCountRef = useRef();
26019
- const pendingFlushRef = useRef(false);
26020
- const producerIsRenderingRef = useRef(false);
26021
- const itemTracker = useMemo(() => {
26022
- const registerItem = (index, value) => {
26023
- const hasValue = index in items;
26024
- if (hasValue) {
26025
- const currentValue = items[index];
26026
- if (compareTwoJsValues(currentValue, value)) {
26027
- return;
26028
- }
26029
- }
26030
- items[index] = value;
26031
- if (producerIsRenderingRef.current) {
26032
- // Consumer will sync after producer render completes
26033
- return;
26034
- }
26035
- pendingFlushRef.current = true;
26036
- };
26037
- const getProducerItem = itemIndex => {
26038
- return items[itemIndex];
26039
- };
26040
- const ItemProducerProvider = ({
26041
- children
26042
- }) => {
26043
- items.length = 0;
26044
- itemCountRef.current = 0;
26045
- pendingFlushRef.current = false;
26046
- producerIsRenderingRef.current = true;
26047
- const listRenderId = {};
26048
- useLayoutEffect(() => {
26049
- producerIsRenderingRef.current = false;
26050
- });
26051
-
26052
- // CRITICAL: Sync consumer state on subsequent renders
26053
- const renderedOnce = useRef(false);
26054
- useLayoutEffect(() => {
26055
- if (!renderedOnce.current) {
26056
- renderedOnce.current = true;
26057
- return;
26058
- }
26059
- pendingFlushRef.current = true;
26060
- itemTracker.flushToConsumers();
26061
- }, [listRenderId]);
26062
- return jsx(ProducerItemCountRefContext.Provider, {
26063
- value: itemCountRef,
26064
- children: jsx(ProducerListRenderIdContext.Provider, {
26065
- value: listRenderId,
26066
- children: jsx(ProducerTrackerContext.Provider, {
26067
- value: itemTracker,
26068
- children: children
26069
- })
26070
- })
26071
- });
26072
- };
26073
- const ItemConsumerProvider = ({
26074
- children
26075
- }) => {
26076
- const [consumerItems, setConsumerItems] = useState(items);
26077
- const flushToConsumers = () => {
26078
- if (!pendingFlushRef.current) {
26079
- return;
26080
- }
26081
- const itemsCopy = [...items];
26082
- pendingFlushRef.current = false;
26083
- setConsumerItems(itemsCopy);
26084
- };
26085
- itemTracker.flushToConsumers = flushToConsumers;
26086
- useLayoutEffect(() => {
26087
- flushToConsumers();
26088
- });
26089
- return jsx(ConsumerItemsContext.Provider, {
26090
- value: consumerItems,
26091
- children: children
26092
- });
26093
- };
26094
- return {
26095
- pendingFlushRef,
26096
- registerItem,
26097
- getProducerItem,
26098
- ItemProducerProvider,
26099
- ItemConsumerProvider
26100
- };
26101
- }, []);
26102
- const {
26103
- ItemProducerProvider,
26104
- ItemConsumerProvider
26105
- } = itemTracker;
26106
- return [ItemProducerProvider, ItemConsumerProvider, items];
26107
- };
26108
-
26109
- // Hook for producers to register items (ref-based, no re-renders)
26110
- const useTrackIsolatedItem = data => {
26111
- const listRenderId = useContext(ProducerListRenderIdContext);
26112
- const itemCountRef = useContext(ProducerItemCountRefContext);
26113
- const itemTracker = useContext(ProducerTrackerContext);
26114
- const listRenderIdRef = useRef();
26115
- const itemIndexRef = useRef();
26116
- const dataRef = useRef();
26117
- const prevListRenderId = listRenderIdRef.current;
26118
- useLayoutEffect(() => {
26119
- if (itemTracker.pendingFlushRef.current) {
26120
- itemTracker.flushToConsumers();
26121
- }
26122
- });
26123
- if (prevListRenderId === listRenderId) {
26124
- const itemIndex = itemIndexRef.current;
26125
- itemTracker.registerItem(itemIndex, data);
26126
- dataRef.current = data;
26127
- return itemIndex;
26128
- }
26129
- listRenderIdRef.current = listRenderId;
26130
- const itemCount = itemCountRef.current;
26131
- const itemIndex = itemCount;
26132
- itemCountRef.current = itemIndex + 1;
26133
- itemIndexRef.current = itemIndex;
26134
- dataRef.current = data;
26135
- itemTracker.registerItem(itemIndex, data);
26136
- return itemIndex;
26137
- };
26138
- const useTrackedIsolatedItem = itemIndex => {
26139
- const items = useTrackedIsolatedItems();
26140
- const item = items[itemIndex];
26141
- return item;
26142
- };
26143
-
26144
- // Hooks for consumers to read items (state-based, re-renders)
26145
- const useTrackedIsolatedItems = () => {
26146
- const consumerItems = useContext(ConsumerItemsContext);
26147
- if (!consumerItems) {
26148
- throw new Error("useTrackedIsolatedItems must be used within <ItemConsumerProvider />");
26149
- }
26150
- return consumerItems;
26151
- };
26152
- return [useIsolatedItemTrackerProvider, useTrackIsolatedItem, useTrackedIsolatedItem, useTrackedIsolatedItems];
26153
- };
26154
-
26155
- const createItemTracker = () => {
26156
- const ItemTrackerContext = createContext();
26157
- const useItemTrackerProvider = () => {
26158
- const itemsRef = useRef([]);
26159
- const items = itemsRef.current;
26160
- const itemCountRef = useRef(0);
26161
- const tracker = useMemo(() => {
26162
- const ItemTrackerProvider = ({
26163
- children
26164
- }) => {
26165
- // Reset on each render to start fresh
26166
- tracker.reset();
26167
- return jsx(ItemTrackerContext.Provider, {
26168
- value: tracker,
26169
- children: children
26170
- });
26171
- };
26172
- ItemTrackerProvider.items = items;
26173
- return {
26174
- ItemTrackerProvider,
26175
- items,
26176
- registerItem: data => {
26177
- const index = itemCountRef.current++;
26178
- items[index] = data;
26179
- return index;
26180
- },
26181
- getItem: index => {
26182
- return items[index];
26183
- },
26184
- getAllItems: () => {
26185
- return items;
26186
- },
26187
- reset: () => {
26188
- items.length = 0;
26189
- itemCountRef.current = 0;
26190
- }
26191
- };
26192
- }, []);
26193
- return tracker.ItemTrackerProvider;
26194
- };
26195
- const useTrackItem = data => {
26196
- const tracker = useContext(ItemTrackerContext);
26197
- if (!tracker) {
26198
- throw new Error("useTrackItem must be used within SimpleItemTrackerProvider");
26199
- }
26200
- return tracker.registerItem(data);
26201
- };
26202
- const useTrackedItem = index => {
26203
- const trackedItems = useTrackedItems();
26204
- const item = trackedItems[index];
26205
- return item;
26206
- };
26207
- const useTrackedItems = () => {
26208
- const tracker = useContext(ItemTrackerContext);
26209
- if (!tracker) {
26210
- throw new Error("useTrackedItems must be used within SimpleItemTrackerProvider");
26211
- }
26212
- return tracker.items;
26213
- };
26214
- return [useItemTrackerProvider, useTrackItem, useTrackedItem, useTrackedItems];
26215
- };
26216
-
26217
26751
  const Z_INDEX_EDITING = 1; /* To go above neighbours, but should not be too big to stay under the sticky cells */
26218
26752
 
26219
26753
  /* needed because cell uses position:relative, sticky must win even if before in DOM order */
@@ -26669,18 +27203,205 @@ const createTableAttributeSync = (table, tableClone) => {
26669
27203
  return observer;
26670
27204
  };
26671
27205
 
26672
- const TableSizeContext = createContext();
27206
+ // https://github.com/reach/reach-ui/tree/b3d94d22811db6b5c0f272b9a7e2e3c1bb4699ae/packages/descendants
27207
+ // https://github.com/pacocoursey/use-descendants/tree/master
26673
27208
 
26674
- const useTableSizeContextValue = ({
26675
- onColumnSizeChange,
26676
- onRowSizeChange,
26677
- columns,
26678
- rows,
26679
- columnResizerRef,
26680
- rowResizerRef,
26681
- }) => {
26682
- onColumnSizeChange = useStableCallback(onColumnSizeChange);
26683
- onRowSizeChange = useStableCallback(onRowSizeChange);
27209
+ const createIsolatedItemTracker = () => {
27210
+ // Producer contexts (ref-based, no re-renders)
27211
+ const ProducerTrackerContext = createContext();
27212
+ const ProducerItemCountRefContext = createContext();
27213
+ const ProducerListRenderIdContext = createContext();
27214
+
27215
+ // Consumer contexts (state-based, re-renders)
27216
+ const ConsumerItemsContext = createContext();
27217
+ const useIsolatedItemTrackerProvider = () => {
27218
+ const itemsRef = useRef([]);
27219
+ const items = itemsRef.current;
27220
+ const itemCountRef = useRef();
27221
+ const itemTracker = useMemo(() => {
27222
+ // Snapshot taken by FlushSentinel after all producer children rendered.
27223
+ // Consumers read from this — always up-to-date within the same render pass.
27224
+ const itemsSnapshotRef = {
27225
+ current: items
27226
+ };
27227
+ const registerItem = (index, value) => {
27228
+ const hasValue = index in items;
27229
+ if (hasValue) {
27230
+ const currentValue = items[index];
27231
+ if (compareTwoJsValues(currentValue, value)) {
27232
+ return;
27233
+ }
27234
+ }
27235
+ items[index] = value;
27236
+ };
27237
+ const getProducerItem = itemIndex => {
27238
+ return items[itemIndex];
27239
+ };
27240
+ const ItemProducerProvider = ({
27241
+ children
27242
+ }) => {
27243
+ items.length = 0;
27244
+ itemCountRef.current = 0;
27245
+ const listRenderId = {};
27246
+ return jsx(ProducerItemCountRefContext.Provider, {
27247
+ value: itemCountRef,
27248
+ children: jsx(ProducerListRenderIdContext.Provider, {
27249
+ value: listRenderId,
27250
+ children: jsxs(ProducerTrackerContext.Provider, {
27251
+ value: itemTracker,
27252
+ children: [children, jsx(FlushSentinel, {})]
27253
+ })
27254
+ })
27255
+ });
27256
+ };
27257
+
27258
+ // Renders after all producer children (e.g. <Col>) have registered their
27259
+ // items. Taking a snapshot here guarantees the consumer sees the correct
27260
+ // item list within the same render pass, without any heuristic.
27261
+ const FlushSentinel = () => {
27262
+ itemsSnapshotRef.current = items;
27263
+ return null;
27264
+ };
27265
+ const ItemConsumerProvider = ({
27266
+ children
27267
+ }) => {
27268
+ // FlushSentinel (last child of ItemProducerProvider) already set
27269
+ // itemsSnapshotRef.current to the up-to-date items array before any
27270
+ // consumer rendered. Reading from the snapshot is always correct.
27271
+ return jsx(ConsumerItemsContext.Provider, {
27272
+ value: itemsSnapshotRef.current,
27273
+ children: children
27274
+ });
27275
+ };
27276
+ return {
27277
+ registerItem,
27278
+ getProducerItem,
27279
+ ItemProducerProvider,
27280
+ ItemConsumerProvider
27281
+ };
27282
+ }, []);
27283
+ const {
27284
+ ItemProducerProvider,
27285
+ ItemConsumerProvider
27286
+ } = itemTracker;
27287
+ return [ItemProducerProvider, ItemConsumerProvider, items];
27288
+ };
27289
+
27290
+ // Hook for producers to register items (ref-based, no re-renders)
27291
+ const useTrackIsolatedItem = data => {
27292
+ const listRenderId = useContext(ProducerListRenderIdContext);
27293
+ const itemCountRef = useContext(ProducerItemCountRefContext);
27294
+ const itemTracker = useContext(ProducerTrackerContext);
27295
+ const listRenderIdRef = useRef();
27296
+ const itemIndexRef = useRef();
27297
+ const dataRef = useRef();
27298
+ const prevListRenderId = listRenderIdRef.current;
27299
+ if (prevListRenderId === listRenderId) {
27300
+ const itemIndex = itemIndexRef.current;
27301
+ itemTracker.registerItem(itemIndex, data);
27302
+ dataRef.current = data;
27303
+ return itemIndex;
27304
+ }
27305
+ listRenderIdRef.current = listRenderId;
27306
+ const itemCount = itemCountRef.current;
27307
+ const itemIndex = itemCount;
27308
+ itemCountRef.current = itemIndex + 1;
27309
+ itemIndexRef.current = itemIndex;
27310
+ dataRef.current = data;
27311
+ itemTracker.registerItem(itemIndex, data);
27312
+ return itemIndex;
27313
+ };
27314
+ const useTrackedIsolatedItem = itemIndex => {
27315
+ const items = useTrackedIsolatedItems();
27316
+ const item = items[itemIndex];
27317
+ return item;
27318
+ };
27319
+
27320
+ // Hooks for consumers to read items (state-based, re-renders)
27321
+ const useTrackedIsolatedItems = () => {
27322
+ const consumerItems = useContext(ConsumerItemsContext);
27323
+ if (!consumerItems) {
27324
+ throw new Error("useTrackedIsolatedItems must be used within <ItemConsumerProvider />");
27325
+ }
27326
+ return consumerItems;
27327
+ };
27328
+ return [useIsolatedItemTrackerProvider, useTrackIsolatedItem, useTrackedIsolatedItem, useTrackedIsolatedItems];
27329
+ };
27330
+
27331
+ const createItemTracker = () => {
27332
+ const ItemTrackerContext = createContext();
27333
+ const useItemTrackerProvider = () => {
27334
+ const itemsRef = useRef([]);
27335
+ const items = itemsRef.current;
27336
+ const itemCountRef = useRef(0);
27337
+ const tracker = useMemo(() => {
27338
+ const ItemTrackerProvider = ({
27339
+ children
27340
+ }) => {
27341
+ // Reset on each render to start fresh
27342
+ tracker.reset();
27343
+ return jsx(ItemTrackerContext.Provider, {
27344
+ value: tracker,
27345
+ children: children
27346
+ });
27347
+ };
27348
+ ItemTrackerProvider.items = items;
27349
+ return {
27350
+ ItemTrackerProvider,
27351
+ items,
27352
+ registerItem: data => {
27353
+ const index = itemCountRef.current++;
27354
+ items[index] = data;
27355
+ return index;
27356
+ },
27357
+ getItem: index => {
27358
+ return items[index];
27359
+ },
27360
+ getAllItems: () => {
27361
+ return items;
27362
+ },
27363
+ reset: () => {
27364
+ items.length = 0;
27365
+ itemCountRef.current = 0;
27366
+ }
27367
+ };
27368
+ }, []);
27369
+ return tracker.ItemTrackerProvider;
27370
+ };
27371
+ const useTrackItem = data => {
27372
+ const tracker = useContext(ItemTrackerContext);
27373
+ if (!tracker) {
27374
+ throw new Error("useTrackItem must be used within SimpleItemTrackerProvider");
27375
+ }
27376
+ return tracker.registerItem(data);
27377
+ };
27378
+ const useTrackedItem = index => {
27379
+ const trackedItems = useTrackedItems();
27380
+ const item = trackedItems[index];
27381
+ return item;
27382
+ };
27383
+ const useTrackedItems = () => {
27384
+ const tracker = useContext(ItemTrackerContext);
27385
+ if (!tracker) {
27386
+ throw new Error("useTrackedItems must be used within SimpleItemTrackerProvider");
27387
+ }
27388
+ return tracker.items;
27389
+ };
27390
+ return [useItemTrackerProvider, useTrackItem, useTrackedItem, useTrackedItems];
27391
+ };
27392
+
27393
+ const TableSizeContext = createContext();
27394
+
27395
+ const useTableSizeContextValue = ({
27396
+ onColumnSizeChange,
27397
+ onRowSizeChange,
27398
+ columns,
27399
+ rows,
27400
+ columnResizerRef,
27401
+ rowResizerRef,
27402
+ }) => {
27403
+ onColumnSizeChange = useStableCallback(onColumnSizeChange);
27404
+ onRowSizeChange = useStableCallback(onRowSizeChange);
26684
27405
 
26685
27406
  const tableSizeContextValue = useMemo(() => {
26686
27407
  const onColumnSizeChangeWithColumn = onColumnSizeChange
@@ -28824,6 +29545,8 @@ const TableCell = props => {
28824
29545
  onClick,
28825
29546
  action,
28826
29547
  name,
29548
+ children,
29549
+ value = children,
28827
29550
  valueSignal,
28828
29551
  // appeareance
28829
29552
  style,
@@ -28831,8 +29554,7 @@ const TableCell = props => {
28831
29554
  bold,
28832
29555
  selfAlignX = column.selfAlignX,
28833
29556
  selfAlignY = column.selfAlignY,
28834
- backgroundColor = column.backgroundColor || row.backgroundColor,
28835
- children
29557
+ backgroundColor = column.backgroundColor || row.backgroundColor
28836
29558
  } = props;
28837
29559
  const ref = props.ref || cellDefaultRef;
28838
29560
  const isFirstRow = rowIndex === 0;
@@ -29005,9 +29727,9 @@ const TableCell = props => {
29005
29727
  children: [editable ? jsx(Editable, {
29006
29728
  editing: editing,
29007
29729
  onEditEnd: stopEditing,
29008
- value: children,
29009
29730
  action: action,
29010
29731
  name: name,
29732
+ value: value,
29011
29733
  valueSignal: valueSignal,
29012
29734
  height: "100%",
29013
29735
  width: "100%",
@@ -29178,6 +29900,9 @@ const createColumnOrdering = (columnIdKey, setOrderedColumnIds) => {
29178
29900
  const stableId = stableIdByExternalIdMap.get(id);
29179
29901
  stableIdByExternalIdMap.delete(id);
29180
29902
  externalIdByStableIdMap.delete(stableId);
29903
+ currentOrderedColumnIds = currentOrderedColumnIds.filter(
29904
+ (orderedId) => orderedId !== id,
29905
+ );
29181
29906
  }
29182
29907
  for (const id of purelyAdded) {
29183
29908
  const stableId = nextStableId++;
@@ -30642,6 +31367,39 @@ const Paragraph = props => {
30642
31367
  });
30643
31368
  };
30644
31369
 
31370
+ installImportMetaCss(import.meta);import.meta.css = /* css */`
31371
+ .navi_text_placeholder {
31372
+ display: inline-block;
31373
+ width: 100%;
31374
+ height: 1em;
31375
+ background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
31376
+ background-size: 200% 100%;
31377
+ border-radius: 4px;
31378
+
31379
+ &[data-loading] {
31380
+ animation: shimmer 1.2s infinite;
31381
+ }
31382
+ }
31383
+ @keyframes shimmer {
31384
+ 0% {
31385
+ background-position: 200% 0;
31386
+ }
31387
+ 100% {
31388
+ background-position: -200% 0;
31389
+ }
31390
+ }
31391
+ `;
31392
+ const TextPlaceholder = ({
31393
+ loading,
31394
+ ...props
31395
+ }) => {
31396
+ return jsx(Box, {
31397
+ ...props,
31398
+ baseClassName: "navi_text_placeholder",
31399
+ "data-loading": loading ? "" : undefined
31400
+ });
31401
+ };
31402
+
30645
31403
  const Image = props => {
30646
31404
  return jsx(Box, {
30647
31405
  ...props,
@@ -30917,6 +31675,200 @@ const ViewportLayout = props => {
30917
31675
  });
30918
31676
  };
30919
31677
 
31678
+ installImportMetaCss(import.meta);import.meta.css = /* css */`
31679
+ @layer navi {
31680
+ .navi_side_panel {
31681
+ --side-panel-width: 400px;
31682
+ --side-panel-background: white;
31683
+ --side-panel-shadow: -4px 0 24px rgba(0, 0, 0, 0.18);
31684
+ --side-panel-animation-duration: 250ms;
31685
+ }
31686
+ }
31687
+
31688
+ .navi_side_panel {
31689
+ position: fixed;
31690
+ top: 0;
31691
+ right: 0;
31692
+ bottom: 0;
31693
+ z-index: 1000;
31694
+ pointer-events: none;
31695
+
31696
+ .navi_side_panel_overlay {
31697
+ position: fixed;
31698
+ inset: 0;
31699
+ background: rgba(0, 0, 0, 0.3);
31700
+ pointer-events: auto;
31701
+ }
31702
+
31703
+ .navi_side_panel_dialog {
31704
+ position: absolute;
31705
+ top: 0;
31706
+ right: 0;
31707
+ bottom: 0;
31708
+ width: var(--side-panel-width);
31709
+ background: var(--side-panel-background);
31710
+ outline: none;
31711
+ box-shadow: var(--side-panel-shadow);
31712
+ animation-duration: var(--side-panel-animation-duration);
31713
+ animation-timing-function: ease-out;
31714
+ animation-fill-mode: both;
31715
+ pointer-events: auto;
31716
+ overflow-y: auto;
31717
+ }
31718
+
31719
+ &[data-opening] {
31720
+ .navi_side_panel_dialog {
31721
+ animation-name: navi_side_panel_slide_in;
31722
+ }
31723
+ }
31724
+
31725
+ &[data-closing] {
31726
+ .navi_side_panel_dialog {
31727
+ animation-name: navi_side_panel_slide_out;
31728
+ }
31729
+ }
31730
+ }
31731
+
31732
+ .navi_side_panel_close_button {
31733
+ position: absolute;
31734
+ top: 12px;
31735
+ right: 12px;
31736
+
31737
+ z-index: 1; /* For some reason required to interact properly with the button */
31738
+ display: flex;
31739
+ width: 28px;
31740
+ height: 28px;
31741
+ padding: 0;
31742
+ align-items: center;
31743
+ justify-content: center;
31744
+ color: #6c757d;
31745
+ font-size: 18px;
31746
+ line-height: 1;
31747
+ background: transparent;
31748
+ border: none;
31749
+ border-radius: 4px;
31750
+ cursor: pointer;
31751
+
31752
+ &:hover {
31753
+ color: #212529;
31754
+ background: #f0f0f0;
31755
+ }
31756
+ }
31757
+
31758
+ @keyframes navi_side_panel_slide_in {
31759
+ from {
31760
+ transform: translateX(100%);
31761
+ }
31762
+ to {
31763
+ transform: translateX(0);
31764
+ }
31765
+ }
31766
+
31767
+ @keyframes navi_side_panel_slide_out {
31768
+ from {
31769
+ transform: translateX(0);
31770
+ }
31771
+ to {
31772
+ transform: translateX(100%);
31773
+ }
31774
+ }
31775
+ `;
31776
+ const SidePanelCloseContext = createContext(null);
31777
+ const useSidePanelClose = () => useContext(SidePanelCloseContext);
31778
+ const SidePanelStyleCSSVars = {
31779
+ width: "--side-panel-width"
31780
+ };
31781
+ const SidePanel = ({
31782
+ isOpen,
31783
+ onClose,
31784
+ children,
31785
+ closeOnClickOutside = false,
31786
+ hideCloseButton = false,
31787
+ width,
31788
+ ...rest
31789
+ }) => {
31790
+ onClose = useStableCallback(onClose);
31791
+ const panelDialogRef = useRef(null);
31792
+ const [phase, setPhase] = useState(isOpen ? "open" : "closed");
31793
+ const previousFocusRef = useRef(null);
31794
+ const isMountedRef = useRef(false);
31795
+ useLayoutEffect(() => {
31796
+ if (!isMountedRef.current) {
31797
+ isMountedRef.current = true;
31798
+ return;
31799
+ }
31800
+ if (isOpen) {
31801
+ setPhase("opening");
31802
+ } else if (phase !== "closed") {
31803
+ setPhase("closing");
31804
+ }
31805
+ }, [isOpen]);
31806
+ useLayoutEffect(() => {
31807
+ if (phase === "opening" && panelDialogRef.current) {
31808
+ previousFocusRef.current = document.activeElement;
31809
+ panelDialogRef.current.focus();
31810
+ }
31811
+ }, [phase]);
31812
+ useKeyboardShortcuts(panelDialogRef, [{
31813
+ key: "escape",
31814
+ handler: () => {
31815
+ onClose();
31816
+ return true;
31817
+ }
31818
+ }]);
31819
+ if (phase === "closed") {
31820
+ return null;
31821
+ }
31822
+ const onAnimationEnd = () => {
31823
+ if (phase === "opening") {
31824
+ setPhase("open");
31825
+ } else if (phase === "closing") {
31826
+ setPhase("closed");
31827
+ const prev = previousFocusRef.current;
31828
+ if (prev && document.contains(prev)) {
31829
+ prev.focus({
31830
+ preventScroll: true
31831
+ });
31832
+ }
31833
+ previousFocusRef.current = null;
31834
+ }
31835
+ };
31836
+ return createPortal(jsx(SidePanelCloseContext.Provider, {
31837
+ value: onClose,
31838
+ children: jsxs(Box, {
31839
+ baseClassName: "navi_side_panel",
31840
+ propsCSSVars: SidePanelStyleCSSVars,
31841
+ width: width,
31842
+ "data-opening": phase === "opening" ? "" : undefined,
31843
+ "data-closing": phase === "closing" ? "" : undefined,
31844
+ ...rest,
31845
+ children: [closeOnClickOutside && jsx("div", {
31846
+ className: "navi_side_panel_overlay",
31847
+ onClick: e => {
31848
+ onClose(e);
31849
+ }
31850
+ }), jsxs(Box, {
31851
+ ref: panelDialogRef,
31852
+ baseClassName: "navi_side_panel_dialog",
31853
+ tabIndex: -1,
31854
+ role: closeOnClickOutside ? "dialog" : "complementary",
31855
+ "aria-modal": closeOnClickOutside ? "true" : undefined,
31856
+ onAnimationEnd: onAnimationEnd,
31857
+ children: [!hideCloseButton && jsx(NaviSidePanelCloseButton, {}), children]
31858
+ })]
31859
+ })
31860
+ }), document.body);
31861
+ };
31862
+ const NaviSidePanelCloseButton = () => {
31863
+ const sidePanelClose = useSidePanelClose();
31864
+ return jsx("button", {
31865
+ className: "navi_side_panel_close_button",
31866
+ "aria-label": "Close panel",
31867
+ onClick: sidePanelClose,
31868
+ children: "\xD7"
31869
+ });
31870
+ };
31871
+
30920
31872
  /*
30921
31873
  * - Usage
30922
31874
  * useEffect(() => {
@@ -31056,5 +32008,5 @@ const UserSvg = () => jsx("svg", {
31056
32008
  })
31057
32009
  });
31058
32010
 
31059
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, BadgeCount, Box, Button, ButtonCopyToClipboard, Caption, CheckSvg, Checkbox, CheckboxList, Code, Col, Colgroup, ConstructionSvg, Details, DialogLayout, Editable, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, MessageBox, Meter, Nav, Paragraph, Quantity, QuantityIntl, Radio, RadioList, Route, RowNumberCol, RowNumberTableCell, SINGLE_SPACE_CONSTRAINT, SVGMaskOverlay, SearchSvg, Select, SelectionContext, Separator, SettingsSvg, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, Thead, Title, Tr, UITransition, UserSvg, ViewportLayout, actionIntegratedVia, actionRunEffect, addCustomMessage, arraySignalMembership, compareTwoJsValues, createAction, createAvailableConstraint, createIntl, createRequestCanceller, createSelectionKeyboardShortcuts, enableDebugActions, enableDebugOnDocumentLoading, filterTableSelection, forwardActionRequested, installCustomConstraintValidation, isCellSelected, isColumnSelected, isRowSelected, localStorageSignal, navBack, navForward, navTo, openCallout, rawUrlPart, reload, removeCustomMessage, requestAction, rerunActions, resource, route, routeAction, setBaseUrl, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, updateActions, useActionData, useActionStatus, useArraySignalMembership, useCalloutClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDarkBackgroundAttribute, useDependenciesDiff, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useNavState$1 as useNavState, useOrderedColumns, useRouteStatus, useRunOnMount, useSelectableElement, useSelectionController, useSignalSync, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage };
32011
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, BadgeCount, Box, Button, ButtonCopyToClipboard, Caption, CheckSvg, Checkbox, CheckboxList, Code, Col, Colgroup, ConstructionSvg, Details, DialogLayout, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, Loading, MessageBox, Meter, Nav, Paragraph, Quantity, QuantityIntl, Radio, RadioList, Route, RowNumberCol, RowNumberTableCell, SINGLE_SPACE_CONSTRAINT, SVGMaskOverlay, SearchSvg, Select, SelectionContext, Separator, SettingsSvg, SidePanel, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextPlaceholder, Thead, Title, Tr, UITransition, UserSvg, ViewportLayout, actionIntegratedVia, actionRunEffect, addCustomMessage, arraySignalMembership, compareTwoJsValues, createAction, createAvailableConstraint, createIntl, createRequestCanceller, createSelectionKeyboardShortcuts, enableDebugActions, enableDebugOnDocumentLoading, filterTableSelection, forwardActionRequested, installCustomConstraintValidation, isCellSelected, isColumnSelected, isRowSelected, localStorageSignal, navBack, navForward, navTo, openCallout, rawUrlPart, reload, removeCustomMessage, requestAction, rerunActions, resource, route, routeAction, setBaseUrl, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDarkBackgroundAttribute, useDependenciesDiff, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useKeyboardShortcuts, useNavState$1 as useNavState, useOrderedColumns, useRouteStatus, useRunOnMount, useSelectableElement, useSelectionController, useSidePanelClose, useSignalSync, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage };
31060
32012
  //# sourceMappingURL=jsenv_navi.js.map