@jsenv/navi 0.29.171 → 0.29.172

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.
@@ -3301,15 +3301,23 @@ const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
3301
3301
  * resolves priorities between the four operation sets (reset > rerun > run >
3302
3302
  * prerun) and performs them.
3303
3303
  *
3304
- * Memory design (the surprising part): nothing here keeps actions alive.
3305
- * Child actions are held through ephemerons (createJsValueWeakMap) so a child
3306
- * and its params are garbage-collected together, running actions live in
3307
- * iterable *weak* sets, and property/signal mirroring uses weakEffect. Two
3304
+ * Memory design (the surprising part): by default nothing here keeps actions
3305
+ * alive. Child actions are held through ephemerons (createJsValueWeakMap) so a
3306
+ * child and its params are garbage-collected together, running actions live in
3307
+ * iterable *weak* sets, and property/signal mirroring uses weakEffect. Three
3308
3308
  * consequences to be aware of:
3309
3309
  * - an action can exist in several places only if everyone shares the same
3310
3310
  * instance (the caches above are what makes lookups return it);
3311
3311
  * - prerun actions may have no other reference yet, so
3312
- * prerunProtectionRegistry pins them for a few minutes.
3312
+ * prerunProtectionRegistry pins them until they answer;
3313
+ * - an answer outlives the screen that asked for it only where the question
3314
+ * declared `keep` (createKeptAnswerRegistry); everything else is asked again.
3315
+ *
3316
+ * What makes that design fragile: reading an action's signals from inside an
3317
+ * effect subscribes that effect to them, and the root action never dies — so
3318
+ * the effect, and everything its closure holds, never dies either. Stringifying
3319
+ * an action for a debug message is the easy way to do it by accident, which is
3320
+ * why toString peeks.
3313
3321
  *
3314
3322
  * A failing run rejects, and writes its error into errorSignal as well. The
3315
3323
  * callers that cannot take a rejection — a run started from a signal effect, a
@@ -3424,6 +3432,58 @@ const prerunProtectionRegistry = (() => {
3424
3432
  };
3425
3433
  })();
3426
3434
 
3435
+ /**
3436
+ * The answers a root action keeps once the screen that asked for them is gone.
3437
+ *
3438
+ * An action holds its answer for exactly as long as something references it, so
3439
+ * without this the lifetime of an answer is whatever the garbage collector
3440
+ * decides: coming back to a screen re-asks the same question, or does not,
3441
+ * depending on when a collection happened to run.
3442
+ *
3443
+ * Age is read when someone looks an answer up, never on a timer. A screen
3444
+ * nobody is on must not talk to the network, and an answer expiring under a
3445
+ * screen someone IS on would empty it mid-read.
3446
+ */
3447
+ const createKeptAnswerRegistry = ({ ms, max }) => {
3448
+ // insertion order is recency order: looking an answer up re-inserts it, so
3449
+ // the first key is always the least recently used one
3450
+ const answeredAtMap = new Map();
3451
+
3452
+ return {
3453
+ keep: (action) => {
3454
+ answeredAtMap.delete(action);
3455
+ answeredAtMap.set(action, Date.now());
3456
+ if (max === undefined) {
3457
+ return;
3458
+ }
3459
+ while (answeredAtMap.size > max) {
3460
+ const [leastRecentlyUsed] = answeredAtMap.keys();
3461
+ answeredAtMap.delete(leastRecentlyUsed);
3462
+ }
3463
+ },
3464
+
3465
+ drop: (action) => {
3466
+ answeredAtMap.delete(action);
3467
+ },
3468
+
3469
+ touch: (action) => {
3470
+ const answeredAt = answeredAtMap.get(action);
3471
+ if (answeredAt === undefined) {
3472
+ return;
3473
+ }
3474
+ answeredAtMap.delete(action);
3475
+ if (ms !== undefined && Date.now() - answeredAt > ms) {
3476
+ // reset rather than drop the instance: equal params must keep giving
3477
+ // the same action, otherwise whoever still holds this one would be
3478
+ // reading a different instance than whoever asks for it next.
3479
+ action.reset({ reason: "kept answer expired" });
3480
+ return;
3481
+ }
3482
+ answeredAtMap.set(action, answeredAt);
3483
+ },
3484
+ };
3485
+ };
3486
+
3427
3487
  const formatActionSet = (actionSet, prefix = "") => {
3428
3488
  let message = prefix;
3429
3489
  for (const action of actionSet) {
@@ -3823,12 +3883,38 @@ const mergeActionParams = (currentParams, newParams) => {
3823
3883
  };
3824
3884
 
3825
3885
  const actionWeakMap = new WeakMap();
3886
+ /**
3887
+ * Wraps an async callback into an action: a question, its answer, and the
3888
+ * signals saying where that answer is.
3889
+ *
3890
+ * @param {Function} callback
3891
+ * @param {object} [rootOptions]
3892
+ * @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
3893
+ * answer stays good once it has landed. Without it an answer lives exactly as
3894
+ * long as something references it: the screen that asked goes away, and
3895
+ * coming back asks the same question again at a moment nothing in the code
3896
+ * decides.
3897
+ *
3898
+ * `ms` counts from the moment the answer landed; `max` caps how many answers
3899
+ * are held at once, the least recently looked-at going first. Age is read
3900
+ * when someone looks, never on a timer — a screen nobody is on must not talk
3901
+ * to the network — and an expired answer is `reset()`, so the question goes
3902
+ * back out through whoever asks for it next.
3903
+ *
3904
+ * It is declared here rather than on each binding because the answer to "how
3905
+ * long is this still true?" belongs to the question: a court list is good for
3906
+ * the session, a count of people watching grows on its own and is worth a few
3907
+ * minutes, a payment status is worth nothing at all.
3908
+ */
3826
3909
  const createAction = (callback, rootOptions = {}) => {
3827
3910
  const existing = actionWeakMap.get(callback);
3828
3911
  if (existing) {
3829
3912
  return existing;
3830
3913
  }
3831
3914
 
3915
+ const keptAnswerRegistry = rootOptions.keep
3916
+ ? createKeptAnswerRegistry(rootOptions.keep)
3917
+ : null;
3832
3918
  let rootAction;
3833
3919
 
3834
3920
  const createActionCore = (options, { parentAction } = {}) => {
@@ -4060,6 +4146,9 @@ const createAction = (callback, rootOptions = {}) => {
4060
4146
  }
4061
4147
  const existingChildAction = childActionWeakMap.get(newParamsOrSignal);
4062
4148
  if (existingChildAction) {
4149
+ if (keptAnswerRegistry) {
4150
+ keptAnswerRegistry.touch(existingChildAction);
4151
+ }
4063
4152
  return existingChildAction;
4064
4153
  }
4065
4154
  const childAction = _bindParams(
@@ -4198,7 +4287,10 @@ const createAction = (callback, rootOptions = {}) => {
4198
4287
  paramsSignal.value = nextParams;
4199
4288
  return true;
4200
4289
  },
4201
- toString: () => action.callSource,
4290
+ // peek: an action gets stringified into debug messages built inside
4291
+ // effects, and subscribing there would keep those effects — and everything
4292
+ // their closures hold — alive for as long as the action itself.
4293
+ toString: () => actionCallSourceSignal.peek(),
4202
4294
  meta,
4203
4295
  debug: (...args) => {
4204
4296
  if (!meta.debug && !DEBUG$2) {
@@ -4362,6 +4454,9 @@ const createAction = (callback, rootOptions = {}) => {
4362
4454
  onComplete?.(data, action);
4363
4455
  completeSideEffectCleanup = completeSideEffect?.(action);
4364
4456
  });
4457
+ if (keptAnswerRegistry) {
4458
+ keptAnswerRegistry.keep(action);
4459
+ }
4365
4460
  if (DEBUG$2) {
4366
4461
  console.log(`"${action}": completed`);
4367
4462
  }
@@ -4477,6 +4572,9 @@ const createAction = (callback, rootOptions = {}) => {
4477
4572
  }
4478
4573
 
4479
4574
  prerunProtectionRegistry.unprotect(action);
4575
+ if (keptAnswerRegistry) {
4576
+ keptAnswerRegistry.drop(action);
4577
+ }
4480
4578
 
4481
4579
  if (sideEffectCleanup) {
4482
4580
  sideEffectCleanup(reason);
@@ -4726,7 +4824,7 @@ const createActionProxyFromSignal = (
4726
4824
  );
4727
4825
  },
4728
4826
  replaceParams: null, // Will be set below
4729
- toString: () => actionProxy.callSource,
4827
+ toString: () => callSourceSignal.peek(), // peek: see the action's toString
4730
4828
  meta: action.meta,
4731
4829
 
4732
4830
  paramsSignal: proxyParamsSignal,
@@ -7268,6 +7366,33 @@ const openCallout = (message, {
7268
7366
  preventScroll: true
7269
7367
  });
7270
7368
  }
7369
+ close_on_escape_from_anywhere: {
7370
+ // Escape closes a callout by reaching its anchor (above), which works while
7371
+ // the anchor holds the keyboard. Some do not: a callout opened with
7372
+ // `skipFocus` leaves the focus where it was, and a press on a blocked list
7373
+ // row leaves it nowhere at all — the row refuses the press, focus included.
7374
+ // The callout is then unreachable by keyboard, and "click somewhere else to
7375
+ // make it go" is not an answer for someone not using a pointer.
7376
+ //
7377
+ // Only for the ones that cannot be heard otherwise, and only while they are
7378
+ // open: a callout the anchor can hear about is left to the anchor, so
7379
+ // Escape goes on meaning what it means where the user is.
7380
+ if (anchorElement && anchorElement.contains(document.activeElement)) {
7381
+ break close_on_escape_from_anywhere;
7382
+ }
7383
+ const onDocumentKeydown = e => {
7384
+ if (e.key !== "Escape") {
7385
+ return;
7386
+ }
7387
+ requestClose(e, "escape_key");
7388
+ e.stopPropagation();
7389
+ e.preventDefault();
7390
+ };
7391
+ document.addEventListener("keydown", onDocumentKeydown, true);
7392
+ addTeardown(() => {
7393
+ document.removeEventListener("keydown", onDocumentKeydown, true);
7394
+ });
7395
+ }
7271
7396
  {
7272
7397
  const handleCustomCloseEvent = e => {
7273
7398
  onRequestClose(e);
@@ -56989,6 +57114,15 @@ const PopupClose = ({
56989
57114
  command: "--navi-close",
56990
57115
  icon: true,
56991
57116
  variant: "discrete"
57117
+ // Dismissing writes nothing to whatever control the popup belongs to, so
57118
+ // that control's read-only and disabled are not about this cross: a
57119
+ // read-only picker still opens to be read (see readonly_constraint.js),
57120
+ // and what is opened has to be closable. Whether closing is allowed at
57121
+ // this instant is the popup's own question, answered by the popup — a
57122
+ // dialog holds the close while an action inside it runs (see
57123
+ // findBusyElementInside in dialog.jsx).
57124
+ ,
57125
+ whenSelfInteractionsBlocked: "ignore"
56992
57126
  // The cross is drawn at the control size, which is a few millimetres
56993
57127
  // wide; the padding is what makes it a target a thumb can hit.
56994
57128
  ,
@@ -66067,8 +66201,25 @@ const ListItemReal = props => {
66067
66201
  if (event.button !== 0 || isOverlaidOnRow(event)) {
66068
66202
  return;
66069
66203
  }
66070
- blockInteraction(event);
66071
66204
  pressStartedHereRef.current = true;
66205
+ // A row that IS a control has one already able to answer this, in the same
66206
+ // words it uses for the keyboard, and it is the one the callout would be
66207
+ // anchored on either way — two answers would be two callouts on one anchor,
66208
+ // toggling each other off (see `reopen` in openCallout).
66209
+ //
66210
+ // Asked before the press is blocked, not after: a gate handed an event
66211
+ // already prevented reads it as "someone else took this one" and steps
66212
+ // back without a word (see onRequestInteraction).
66213
+ const controlHost = isControlRoot(event.currentTarget) ? findControlHost(event.currentTarget) : null;
66214
+ if (controlHost) {
66215
+ dispatchRequestInteraction(controlHost, {
66216
+ event,
66217
+ name: "press on a blocked row"
66218
+ });
66219
+ blockInteraction(event);
66220
+ return;
66221
+ }
66222
+ blockInteraction(event);
66072
66223
  // One at a time, and not the one that just dismissed it (see the refs).
66073
66224
  if (calloutRef.current && calloutRef.current.opened) {
66074
66225
  return;