@jsenv/navi 0.29.436 → 0.29.437

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.
@@ -3548,8 +3548,11 @@ const actionWeakMap = new WeakMap();
3548
3548
  * than a first load (data_states.md). Called at the start of every run whose
3549
3549
  * value is undefined; the answer replaces it as usual, and `undefined` means
3550
3550
  * "nothing known". A resource `GET` uses it to draw the row its store holds.
3551
- * An action declaring it is asked for it by a binding moving to new params,
3552
- * rather than handed the previous params' answer (see inheritData).
3551
+ * @param {boolean} [rootOptions.inheritData=true] - `false`: a binding moving
3552
+ * to new params starts the new instance without the previous params' answer
3553
+ * (see createActionProxyFromSignal). For an answer that would say something
3554
+ * false about the new params (a `GET` drawing another row), not for one that
3555
+ * stands in while the next one comes (a list of search results).
3553
3556
  * @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
3554
3557
  * answer stays good once it has landed. Without it an answer lives exactly as
3555
3558
  * long as something references it: the screen that asked goes away, and
@@ -3592,6 +3595,7 @@ const createAction = (callback, rootOptions = {}) => {
3592
3595
  resultToValue,
3593
3596
  valueToData,
3594
3597
  provisionalValue,
3598
+ inheritData = true,
3595
3599
  dataDefault,
3596
3600
  data = dataDefault,
3597
3601
 
@@ -4255,6 +4259,7 @@ const createAction = (callback, rootOptions = {}) => {
4255
4259
  const privateProperties = {
4256
4260
  valueInitial,
4257
4261
  provisionalValue,
4262
+ inheritData,
4258
4263
 
4259
4264
  performRun,
4260
4265
  performReset,
@@ -4287,7 +4292,7 @@ const createAction = (callback, rootOptions = {}) => {
4287
4292
  * a list of filters changes, providing real-time results without user interaction.
4288
4293
  * @param {boolean} options.inheritData - When false, each new target action starts fresh with no inherited state.
4289
4294
  * By default (true), the proxy carries over the previous target's value and error into the new action —
4290
- * unless the action declares a `provisionalValue`, which then says what the new one holds.
4295
+ * unless the action itself declares `inheritData: false` (createAction), which a resource `GET` does.
4291
4296
  * This keeps the facade in sync with the latest known data: `action.dataSignal.value` only changes when a
4292
4297
  * new action completes, not when it starts loading. Code that needs to distinguish loading state can still
4293
4298
  * check `action.runningState`, while code that just reads `action.data` always sees the most recent
@@ -4334,12 +4339,12 @@ const createActionProxyFromSignal = (
4334
4339
  let currentActionPrivateProperties = getActionPrivateProperties(action);
4335
4340
  let actionTargetPreviousWeakRef = null;
4336
4341
 
4337
- // An action saying for itself what it holds for params it has not answered
4338
- // (provisionalValue: a resource GET draws the row its store holds) is not
4339
- // handed the previous params' answer — an answer about another row. What it
4340
- // does not know stays unknown: a skeleton, never a different row.
4342
+ // An action whose answer for other params would be false for these (a
4343
+ // resource GET: another row) says so itself, and is not handed it. What it
4344
+ // does not know stays unknown: a skeleton, or its provisionalValue, never a
4345
+ // different row.
4341
4346
  const inheritsAnswer =
4342
- inheritData && !getActionPrivateProperties(action).provisionalValue;
4347
+ inheritData && getActionPrivateProperties(action).inheritData;
4343
4348
  const createTarget = (params) => {
4344
4349
  if (inheritsAnswer) {
4345
4350
  const previousActionTarget = actionTargetPreviousWeakRef?.deref();
@@ -10242,6 +10247,9 @@ const paramsTargetCanPlace = (redirectRoute, urlParams) => {
10242
10247
 
10243
10248
  let setupRoutesCalled = false;
10244
10249
  let activeRouteSet = null;
10250
+ // True once the routes have been matched against an address. Before that, no
10251
+ // route matching says nothing about the page: nothing has been asked yet.
10252
+ const routedSignal = signal(false);
10245
10253
 
10246
10254
  const routePreloadMap = new WeakMap();
10247
10255
  const registerRoutePreload = (route, preload) => {
@@ -10666,6 +10674,7 @@ This prevents cross-test pollution and ensures clean state.`,
10666
10674
  }
10667
10675
  // Reset flag after URL -> Signal synchronization is complete
10668
10676
  isUpdatingRoutesFromUrl = false;
10677
+ routedSignal.value = true;
10669
10678
  Object.assign(returnValue, { matchingRouteSet });
10670
10679
  for (const [route, repairedParams] of repairedParamsByRoute) {
10671
10680
  route.replaceParams(repairedParams, {
@@ -10715,6 +10724,7 @@ This prevents cross-test pollution and ensures clean state.`,
10715
10724
  droppingRouteSet = null;
10716
10725
  setupRoutesCalled = false;
10717
10726
  activeRouteSet = null;
10727
+ routedSignal.value = false;
10718
10728
  };
10719
10729
  return {
10720
10730
  updateRoutes,
@@ -15033,6 +15043,13 @@ const createOpenController = (
15033
15043
  change();
15034
15044
  };
15035
15045
  const runChange = (change, { opened, event }) => {
15046
+ // What the controller answers about itself is the decision, written before
15047
+ // the change runs, whatever runs it. Whoever asks meanwhile reads it: the
15048
+ // render an opening forces (onOpen, mountContent) reads the open state
15049
+ // the caller holds, and an onOpen writing the popup's own `signal` has
15050
+ // useOpenPropsEffectOnOpenController asking for this very opening — a
15051
+ // second one, nested in the first, if `opened` still said closed.
15052
+ controller.opened = opened;
15036
15053
  const applyChange = () => {
15037
15054
  // Recorded with the change itself: what the DOM shows is what a render
15038
15055
  // landing between the ask and the picture must draw (see `openedInDom`).
@@ -15048,12 +15065,8 @@ const createOpenController = (
15048
15065
  applyChange();
15049
15066
  return;
15050
15067
  }
15051
- // What the controller answers about itself does not wait for the picture:
15052
- // whoever just asked reads `opened` on the spot (see
15053
- // useOpenPropsEffectOnOpenController, which writes it back into the
15054
- // caller's own signal), and a request arriving before the change lands
15055
- // runs it first rather than reading a DOM that disagrees.
15056
- controller.opened = opened;
15068
+ // A request arriving before the change lands runs it first rather than
15069
+ // reading a DOM that disagrees.
15057
15070
  changeAwaitingTransition = applyChange;
15058
15071
  transitionChange(
15059
15072
  () => {
@@ -15066,7 +15079,6 @@ const createOpenController = (
15066
15079
  };
15067
15080
 
15068
15081
  const performClose = (closeEvent) => {
15069
- controller.opened = false;
15070
15082
  // Read before any close effect touches the DOM: closing a native <dialog>
15071
15083
  // hands the focus back to whatever held it at showModal() time, so by the
15072
15084
  // time the close cleanup runs, the popup's content has already lost the
@@ -15334,7 +15346,6 @@ const createOpenController = (
15334
15346
  // told it opened right after (see popup_content_mount.js and
15335
15347
  // use_displayed_layout_effect.js).
15336
15348
  controller.openedInDom = true;
15337
- controller.opened = true;
15338
15349
  // Which press it opened during, so the release of that press is not
15339
15350
  // read as somebody dismissing it (see openedDuringThisPress).
15340
15351
  controller.pressCountAtOpen = pressCount;
@@ -15761,8 +15772,10 @@ const useOpenPropsEffectOnOpenController = (
15761
15772
  return undefined;
15762
15773
  }
15763
15774
  // Skip when the controller is already in the desired state.
15764
- // openController.opened tracks actual open/close (updated by onopen/onclose,
15765
- // not by renders) so it is the authoritative check against feedback loops.
15775
+ // openController.opened is what the controller decided, written before an
15776
+ // opening renders anything (see runChange), so it is the authoritative
15777
+ // check against feedback loops — the popup's own onOpen writing its
15778
+ // `signal` included.
15766
15779
  if (open === openController.opened) {
15767
15780
  return undefined;
15768
15781
  }
@@ -25828,6 +25841,171 @@ const isOwnerReadBy = (childResource, childAction, ownerGetAction) => {
25828
25841
  return childStore !== undefined && childStore === getChildStore(ownerId);
25829
25842
  };
25830
25843
 
25844
+ /*
25845
+ * What a previous document kept of the page it was on (see keepPageOnScreen in
25846
+ * nav/page_kept.js), waiting for the reads that draw it again, and what those
25847
+ * reads are made of on the way out: rows with their relations inline.
25848
+ *
25849
+ * A kept read is found by the read's name and the params it was asked with
25850
+ * (getKeptReadKey). It is handed out once: the first run of the read draws it
25851
+ * while its request is out, and from then on the store holds the rows. Handed
25852
+ * out again, after a reset say, it would write the previous document's rows
25853
+ * over a fresher answer.
25854
+ *
25855
+ * This module is the half the resources see: `resource()` and the range reader
25856
+ * take from it, and say which of their reads can be kept at all. Which reads
25857
+ * are on the page on screen, and what the slot is written into, is
25858
+ * page_kept.js.
25859
+ */
25860
+
25861
+ // key → what the read answered in the previous document, not handed out yet
25862
+ const offeredReadMap = new Map();
25863
+ const offerKeptReads = (entryMap) => {
25864
+ offeredReadMap.clear();
25865
+ for (const [key, entry] of entryMap) {
25866
+ offeredReadMap.set(key, entry);
25867
+ }
25868
+ };
25869
+ const withdrawKeptReads = () => {
25870
+ offeredReadMap.clear();
25871
+ };
25872
+ const takeKeptRead = (key) => {
25873
+ const entry = offeredReadMap.get(key);
25874
+ if (entry === undefined) {
25875
+ return undefined;
25876
+ }
25877
+ offeredReadMap.delete(key);
25878
+ return entry;
25879
+ };
25880
+
25881
+ // The root actions whose answer is rows of a store, written and read back the
25882
+ // same way: a resource's GET and GET_MANY. Anything else a route runs (a page's
25883
+ // code, a computation) has nothing a later document could draw.
25884
+ const keepableReadWeakSet = new WeakSet();
25885
+ const markKeepableRead = (rootAction) => {
25886
+ keepableReadWeakSet.add(rootAction);
25887
+ };
25888
+ const isKeepableRead = (action) => {
25889
+ const rootAction = action.rootAction || action;
25890
+ return keepableReadWeakSet.has(rootAction);
25891
+ };
25892
+
25893
+ // The range readers a <List.Items> on screen reads through, each with the number
25894
+ // of lists reading it: the reads of the page a route action does not cover.
25895
+ const shownRangeReadersSignal = signal(new Map());
25896
+ const showRangeReader = (reader) => {
25897
+ const shownMap = new Map(shownRangeReadersSignal.peek());
25898
+ shownMap.set(reader, (shownMap.get(reader) || 0) + 1);
25899
+ shownRangeReadersSignal.value = shownMap;
25900
+ return () => {
25901
+ const shownMapNow = new Map(shownRangeReadersSignal.peek());
25902
+ const count = shownMapNow.get(reader);
25903
+ if (count === 1) {
25904
+ shownMapNow.delete(reader);
25905
+ } else {
25906
+ shownMapNow.set(reader, count - 1);
25907
+ }
25908
+ shownRangeReadersSignal.value = shownMapNow;
25909
+ };
25910
+ };
25911
+
25912
+ // A row already in the store is the fresher one: the kept copy only stands in
25913
+ // for rows nothing has answered yet. Its relations are written as they were
25914
+ // kept, which a real answer corrects like any other.
25915
+ const upsertKeptItem = (store, keptItem) => {
25916
+ const id = keptItem[store.idKey];
25917
+ if (id !== undefined) {
25918
+ const existing = store.select(id);
25919
+ if (existing) {
25920
+ return existing;
25921
+ }
25922
+ }
25923
+ return store.upsert(keptItem);
25924
+ };
25925
+
25926
+ const getKeptReadKey = (name, params) => {
25927
+ return `${name}|${getParamsKey(params)}`;
25928
+ };
25929
+ // `true` is what a route action without params is bound to (see routeAction),
25930
+ // and a key holding undefined says nothing a missing one does not.
25931
+ const getParamsKey = (params) => {
25932
+ if (params === undefined || params === NO_PARAMS || params === true) {
25933
+ return "";
25934
+ }
25935
+ if (isPlainParams(params)) {
25936
+ const keys = Object.keys(params).filter((key) => params[key] !== undefined);
25937
+ if (keys.length === 0) {
25938
+ return "";
25939
+ }
25940
+ }
25941
+ return stableStringify(params);
25942
+ };
25943
+ const isPlainParams = (value) => {
25944
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25945
+ };
25946
+ const stableStringify = (value) => {
25947
+ if (value === null || typeof value !== "object") {
25948
+ return JSON.stringify(value);
25949
+ }
25950
+ if (Array.isArray(value)) {
25951
+ return `[${value.map(stableStringify).join(",")}]`;
25952
+ }
25953
+ const keys = Object.keys(value)
25954
+ .filter((key) => value[key] !== undefined)
25955
+ .sort();
25956
+ const parts = [];
25957
+ for (const key of keys) {
25958
+ parts.push(`${JSON.stringify(key)}:${stableStringify(value[key])}`);
25959
+ }
25960
+ return `{${parts.join(",")}}`;
25961
+ };
25962
+
25963
+ // What a read's data is made of, ready to be written: a row, or the rows of a
25964
+ // list, each with its relations inline.
25965
+ const serializeData = (data) => {
25966
+ if (Array.isArray(data)) {
25967
+ return data.map((item) => serializeItem(item, new Set()));
25968
+ }
25969
+ return serializeItem(data, new Set());
25970
+ };
25971
+ // The item with its relations inline: a relation value stands for what its
25972
+ // signal holds (the child row, the child rows, or nothing), a row reached
25973
+ // twice on the same path is written as its id — the setters accept both.
25974
+ const serializeItem = (item, ancestorSet) => {
25975
+ ancestorSet.add(item);
25976
+ const serialized = {};
25977
+ for (const key of Object.keys(item)) {
25978
+ serialized[key] = serializeValue(item[key], ancestorSet);
25979
+ }
25980
+ ancestorSet.delete(item);
25981
+ return serialized;
25982
+ };
25983
+ const serializeValue = (value, ancestorSet) => {
25984
+ if (value === null || typeof value !== "object") {
25985
+ return value;
25986
+ }
25987
+ if (Array.isArray(value)) {
25988
+ return value.map((entry) => serializeValue(entry, ancestorSet));
25989
+ }
25990
+ const objectSignal = value[SYMBOL_OBJECT_SIGNAL];
25991
+ if (objectSignal) {
25992
+ const held = objectSignal.value;
25993
+ if (held === undefined || held === null) {
25994
+ return null;
25995
+ }
25996
+ return serializeValue(held, ancestorSet);
25997
+ }
25998
+ if (typeof value.toJSON === "function") {
25999
+ return value;
26000
+ }
26001
+ if (ancestorSet.has(value)) {
26002
+ return Object.hasOwn(value, SYMBOL_IDENTITY)
26003
+ ? value[SYMBOL_IDENTITY]
26004
+ : null;
26005
+ }
26006
+ return serializeItem(value, ancestorSet);
26007
+ };
26008
+
25831
26009
  const paramScopeWeakSet = createIterableWeakSet();
25832
26010
  let paramScopeIdCounter = 0;
25833
26011
  const getParamScope = (params) => {
@@ -25998,66 +26176,6 @@ const createResourcePersistence = (
25998
26176
  };
25999
26177
  };
26000
26178
 
26001
- const getParamsKey = (params) => {
26002
- if (params === undefined || params === NO_PARAMS) {
26003
- return "";
26004
- }
26005
- return stableStringify(params);
26006
- };
26007
-
26008
- const stableStringify = (value) => {
26009
- if (value === null || typeof value !== "object") {
26010
- return JSON.stringify(value);
26011
- }
26012
- if (Array.isArray(value)) {
26013
- return `[${value.map(stableStringify).join(",")}]`;
26014
- }
26015
- const keys = Object.keys(value).sort();
26016
- const parts = [];
26017
- for (const key of keys) {
26018
- parts.push(`${JSON.stringify(key)}:${stableStringify(value[key])}`);
26019
- }
26020
- return `{${parts.join(",")}}`;
26021
- };
26022
-
26023
- // The item with its relations inline: a relation value stands for what its
26024
- // signal holds (the child row, the child rows, or nothing), a row reached
26025
- // twice on the same path is written as its id — the setters accept both.
26026
- const serializeItem = (item, ancestorSet) => {
26027
- ancestorSet.add(item);
26028
- const serialized = {};
26029
- for (const key of Object.keys(item)) {
26030
- serialized[key] = serializeValue(item[key], ancestorSet);
26031
- }
26032
- ancestorSet.delete(item);
26033
- return serialized;
26034
- };
26035
- const serializeValue = (value, ancestorSet) => {
26036
- if (value === null || typeof value !== "object") {
26037
- return value;
26038
- }
26039
- if (Array.isArray(value)) {
26040
- return value.map((entry) => serializeValue(entry, ancestorSet));
26041
- }
26042
- const objectSignal = value[SYMBOL_OBJECT_SIGNAL];
26043
- if (objectSignal) {
26044
- const held = objectSignal.value;
26045
- if (held === undefined || held === null) {
26046
- return null;
26047
- }
26048
- return serializeValue(held, ancestorSet);
26049
- }
26050
- if (typeof value.toJSON === "function") {
26051
- return value;
26052
- }
26053
- if (ancestorSet.has(value)) {
26054
- return Object.hasOwn(value, SYMBOL_IDENTITY)
26055
- ? value[SYMBOL_IDENTITY]
26056
- : null;
26057
- }
26058
- return serializeItem(value, ancestorSet);
26059
- };
26060
-
26061
26179
  /*
26062
26180
  * GET_RANGE: reading a resource one slice at a time, for a list that draws its
26063
26181
  * rows as it goes (`<List.Items itemsAction>`).
@@ -26094,9 +26212,23 @@ const serializeValue = (value, ancestorSet) => {
26094
26212
  * of what a rerun is for an action. Every reader made by `bindParams` shares
26095
26213
  * the signal and the compositions of the one it comes from: the params say
26096
26214
  * which slices are read, not which collection.
26215
+ *
26216
+ * A composition also outlives the document when a list on the page on screen
26217
+ * reads it (see keepPageOnScreen): the ranks around the window it draws are
26218
+ * written with their rows, and the first read of the next document finds them
26219
+ * as a composition left by a list that went away — the same return, after a
26220
+ * longer absence.
26097
26221
  */
26098
26222
 
26099
26223
 
26224
+ // The ranks kept of a composition for the next document: the window a list
26225
+ // draws, widened by at least one step on each side, and snapped to steps so a
26226
+ // scroll within one draws the same ranks and writes nothing.
26227
+ const KEPT_RANK_STEP = 20;
26228
+ // Counts the answers every composition took, so that one landing can be told
26229
+ // from one seen before (see readKept).
26230
+ let answerSequence = 0;
26231
+
26100
26232
  const createRangeReader = (
26101
26233
  actionName,
26102
26234
  callback,
@@ -26105,6 +26237,9 @@ const createRangeReader = (
26105
26237
  params: boundParams,
26106
26238
  invalidationSignal = signal(0),
26107
26239
  compositionSet = new Set(),
26240
+ // Bumped whenever what is kept of a composition for the next document
26241
+ // changes (see readKept); shared like the compositions themselves.
26242
+ keptVersionSignal = signal(0),
26108
26243
  },
26109
26244
  ) => {
26110
26245
  // Which composition this reader is about: the values its params hold, not its
@@ -26121,6 +26256,40 @@ const createRangeReader = (
26121
26256
  }
26122
26257
  return null;
26123
26258
  };
26259
+ const keptKeyOf = (params) => getKeptReadKey(actionName, params);
26260
+ const bumpKeptVersion = () => {
26261
+ keptVersionSignal.value = keptVersionSignal.peek() + 1;
26262
+ };
26263
+ // The composition the previous document kept for these params, entering the
26264
+ // store and the compositions as if a list had just left it.
26265
+ const restoreKeptComposition = (params) => {
26266
+ const kept = takeKeptRead(keptKeyOf(params));
26267
+ if (!kept || typeof kept.count !== "number" || !kept.items) {
26268
+ return null;
26269
+ }
26270
+ const composition = {
26271
+ params,
26272
+ idByIndex: new Map(),
26273
+ count: kept.count,
26274
+ keptFrom: undefined,
26275
+ keptTo: undefined,
26276
+ answeredAt: 0,
26277
+ };
26278
+ for (const rank of Object.keys(kept.items)) {
26279
+ const index = Number(rank);
26280
+ const item = upsertKeptItem(store, kept.items[rank]);
26281
+ composition.idByIndex.set(index, item[store.idKey]);
26282
+ if (composition.keptFrom === undefined || index < composition.keptFrom) {
26283
+ composition.keptFrom = index;
26284
+ }
26285
+ if (composition.keptTo === undefined || index + 1 > composition.keptTo) {
26286
+ composition.keptTo = index + 1;
26287
+ }
26288
+ }
26289
+ compositionSet.add(composition);
26290
+ bumpKeptVersion();
26291
+ return composition;
26292
+ };
26124
26293
  const readRange = async (range = {}) => {
26125
26294
  const { signal, ...rangeParams } = range;
26126
26295
  const paramsResolved = { ...resolveParams(boundParams), ...rangeParams };
@@ -26154,11 +26323,15 @@ const createRangeReader = (
26154
26323
  readRange.invalidate = () => {
26155
26324
  compositionSet.clear();
26156
26325
  invalidationSignal.value = invalidationSignal.peek() + 1;
26326
+ bumpKeptVersion();
26157
26327
  };
26158
26328
  // The rows of a composition, drawn from the store: a rank whose row is gone
26159
26329
  // from the store resolves to nothing and is asked for again.
26160
26330
  readRange.readComposition = () => {
26161
- const composition = findComposition(currentParams());
26331
+ const params = currentParams();
26332
+ const composition = untracked(
26333
+ () => findComposition(params) || restoreKeptComposition(params),
26334
+ );
26162
26335
  if (!composition) {
26163
26336
  return null;
26164
26337
  }
@@ -26182,18 +26355,85 @@ const createRangeReader = (
26182
26355
  const params = currentParams();
26183
26356
  let composition = findComposition(params);
26184
26357
  if (!composition) {
26185
- composition = { params, idByIndex: new Map(), count };
26358
+ composition = {
26359
+ params,
26360
+ idByIndex: new Map(),
26361
+ count,
26362
+ keptFrom: undefined,
26363
+ keptTo: undefined,
26364
+ answeredAt: 0,
26365
+ };
26186
26366
  compositionSet.add(composition);
26187
26367
  } else if (replace) {
26188
26368
  composition.idByIndex = new Map();
26189
26369
  }
26190
26370
  composition.count = count;
26371
+ answerSequence++;
26372
+ composition.answeredAt = answerSequence;
26191
26373
  for (const [index, item] of byIndex) {
26192
26374
  const id = item ? item[store.idKey] : undefined;
26193
26375
  if (id !== undefined) {
26194
26376
  composition.idByIndex.set(index, id);
26195
26377
  }
26196
26378
  }
26379
+ bumpKeptVersion();
26380
+ };
26381
+ // The ranks [windowFrom, windowTo) a list draws of this composition: what is
26382
+ // kept of it for the next document is around them (see readKept).
26383
+ readRange.frameComposition = (windowFrom, windowTo) => {
26384
+ const composition = findComposition(currentParams());
26385
+ if (!composition) {
26386
+ return;
26387
+ }
26388
+ let keptFrom =
26389
+ Math.floor((windowFrom - KEPT_RANK_STEP) / KEPT_RANK_STEP) *
26390
+ KEPT_RANK_STEP;
26391
+ if (keptFrom < 0) {
26392
+ keptFrom = 0;
26393
+ }
26394
+ const keptTo =
26395
+ Math.ceil((windowTo + KEPT_RANK_STEP) / KEPT_RANK_STEP) * KEPT_RANK_STEP;
26396
+ if (composition.keptFrom === keptFrom && composition.keptTo === keptTo) {
26397
+ return;
26398
+ }
26399
+ composition.keptFrom = keptFrom;
26400
+ composition.keptTo = keptTo;
26401
+ bumpKeptVersion();
26402
+ };
26403
+ // What a document keeps of the composition these params read, for the
26404
+ // next one (see keepPageOnScreen): the count, and the ranks around the
26405
+ // window last drawn with their rows inline. Read inside an effect, it
26406
+ // follows the composition and the rows. `answeredAt` grows with every answer
26407
+ // the composition takes (0: none in this document, restored from a kept one).
26408
+ readRange.readKept = () => {
26409
+ // eslint-disable-next-line no-unused-expressions
26410
+ keptVersionSignal.value;
26411
+ const params = resolveParams(boundParams);
26412
+ const key = keptKeyOf(params);
26413
+ const composition = findComposition(params);
26414
+ if (!composition || composition.count === undefined) {
26415
+ return { key, answeredAt: 0, readEntry: () => undefined };
26416
+ }
26417
+ const readEntry = () => {
26418
+ const items = {};
26419
+ for (const [index, id] of composition.idByIndex) {
26420
+ if (
26421
+ composition.keptFrom !== undefined &&
26422
+ index < composition.keptFrom
26423
+ ) {
26424
+ continue;
26425
+ }
26426
+ if (composition.keptTo !== undefined && index >= composition.keptTo) {
26427
+ continue;
26428
+ }
26429
+ const item = store.select(id);
26430
+ if (item) {
26431
+ items[index] = serializeItem(item, new Set());
26432
+ }
26433
+ }
26434
+ return { count: composition.count, items };
26435
+ };
26436
+ return { key, answeredAt: composition.answeredAt, readEntry };
26197
26437
  };
26198
26438
  // The same trade a list makes with the rows it holds, applied to what is kept
26199
26439
  // for the next mount. Two lists on one collection each trim by their own
@@ -26204,11 +26444,16 @@ const createRangeReader = (
26204
26444
  if (!composition || composition.idByIndex.size <= budget) {
26205
26445
  return;
26206
26446
  }
26447
+ let trimmed = false;
26207
26448
  for (const index of composition.idByIndex.keys()) {
26208
26449
  if (index < keepFrom || index > keepTo) {
26209
26450
  composition.idByIndex.delete(index);
26451
+ trimmed = true;
26210
26452
  }
26211
26453
  }
26454
+ if (trimmed) {
26455
+ bumpKeptVersion();
26456
+ }
26212
26457
  };
26213
26458
  // Memoized for the reasons an action's bindParams is (see actions.js): params
26214
26459
  // and the reader they make have synchronized lifetimes, and params equal in
@@ -26224,6 +26469,7 @@ const createRangeReader = (
26224
26469
  params: boundParams ? { ...boundParams, ...paramsToBind } : paramsToBind,
26225
26470
  invalidationSignal,
26226
26471
  compositionSet,
26472
+ keptVersionSignal,
26227
26473
  });
26228
26474
  readerByParams.set(paramsToBind, reader);
26229
26475
  return reader;
@@ -27658,7 +27904,7 @@ const createRestActionFactoryForRoot = (
27658
27904
  `${name}.${verb}`,
27659
27905
  declarationSite,
27660
27906
  );
27661
- return createAction(callback, {
27907
+ const action = createAction(callback, {
27662
27908
  name: `${name}.${verb}`,
27663
27909
  meta: { verb, isMany: false, paramScope },
27664
27910
  resultToValue: (result, action) => {
@@ -27694,17 +27940,33 @@ const createRestActionFactoryForRoot = (
27694
27940
  },
27695
27941
  valueToData: (itemId) => store.select(itemId),
27696
27942
  // While the request is out, the row the store already holds for these
27697
- // params is drawn: the action starts on the "refresh over a known
27943
+ // params is drawn, or the row the page kept from the previous document
27944
+ // (kept_reads.js): the action starts on the "refresh over a known
27698
27945
  // answer" line of data_states.md rather than the first-load one.
27699
27946
  provisionalValue:
27700
27947
  verb === "GET"
27701
- ? (params) => {
27702
- const item = untracked(() => findItemForGet(params));
27948
+ ? (params, action) => {
27949
+ const item = untracked(() => {
27950
+ const itemForGet = findItemForGet(params);
27951
+ if (itemForGet) {
27952
+ return itemForGet;
27953
+ }
27954
+ const keptItem = takeKeptRead(
27955
+ getKeptReadKey(action.name, params),
27956
+ );
27957
+ return keptItem ? upsertKeptItem(store, keptItem) : null;
27958
+ });
27703
27959
  return item ? item[idKey] : undefined;
27704
27960
  }
27705
27961
  : undefined,
27962
+ // Another params' answer is another row.
27963
+ inheritData: verb !== "GET",
27706
27964
  completeSideEffect: onActionComplete,
27707
27965
  });
27966
+ if (verb === "GET") {
27967
+ markKeepableRead(action);
27968
+ }
27969
+ return action;
27708
27970
  };
27709
27971
  const createActionAffectingManyItems = (
27710
27972
  verb,
@@ -27719,7 +27981,7 @@ const createRestActionFactoryForRoot = (
27719
27981
  return itemArray.map((item) => item[idKey]);
27720
27982
  };
27721
27983
 
27722
- return createAction(callback, {
27984
+ const action = createAction(callback, {
27723
27985
  meta: { verb, isMany: true, paramScope },
27724
27986
  name: `${name}.${verb}_MANY`,
27725
27987
  resultToValue: applyResultToValue,
@@ -27727,6 +27989,25 @@ const createRestActionFactoryForRoot = (
27727
27989
  // undefined so a screen can tell "not asked yet" from "answered: none".
27728
27990
  valueToData: (idArray) =>
27729
27991
  idArray === undefined ? undefined : store.selectAll(idArray),
27992
+ // The rows the page kept from the previous document (kept_reads.js),
27993
+ // drawn while the request is out. A binding moving to new params still
27994
+ // hands over the previous answer when there are none: a list standing
27995
+ // in for the next one beats a skeleton.
27996
+ provisionalValue:
27997
+ verb === "GET"
27998
+ ? (params, action) =>
27999
+ untracked(() => {
28000
+ const keptItems = takeKeptRead(
28001
+ getKeptReadKey(action.name, params),
28002
+ );
28003
+ if (!Array.isArray(keptItems)) {
28004
+ return undefined;
28005
+ }
28006
+ return keptItems.map(
28007
+ (keptItem) => upsertKeptItem(store, keptItem)[idKey],
28008
+ );
28009
+ })
28010
+ : undefined,
27730
28011
  completeSideEffect: (actionCompleted) => {
27731
28012
  onActionComplete(actionCompleted);
27732
28013
  if (
@@ -27742,6 +28023,10 @@ const createRestActionFactoryForRoot = (
27742
28023
  return syncIdArrayOnRename(store, idKey, actionCompleted.valueSignal);
27743
28024
  },
27744
28025
  });
28026
+ if (verb === "GET") {
28027
+ markKeepableRead(action);
28028
+ }
28029
+ return action;
27745
28030
  };
27746
28031
 
27747
28032
  return createActionForRoot;
@@ -27752,10 +28037,12 @@ const createRestActionFactoryForRoot = (
27752
28037
  // NetworkPolicyError carrying the policy's reason. A read is called or not depending
27753
28038
  // on where the policy answers reads from. Answered from the store, a GET of a
27754
28039
  // root resource completes with the row the store holds for it (see
27755
- // findItemInStore) — handing the item back is an upsert without effect, so the
27756
- // action completes with what it had and nothing is asked; a relationship GET
27757
- // has no row of its own to answer with and settles with the same error. A
27758
- // completed GET asked to rerun never gets here (actions.js holds it).
28040
+ // findItemInStore), and a root GET or GET_MANY holding rows for these params
28041
+ // (the kept page, drawn while the request is out) with those — handing them
28042
+ // back is an upsert without effect, so the action completes with what it had
28043
+ // and nothing is asked. A relationship GET has no row of its own to answer
28044
+ // with and settles with the same error. A completed GET asked to rerun never
28045
+ // gets here (actions.js holds it).
27759
28046
  const applyNetworkPolicy = (
27760
28047
  restCallback,
27761
28048
  { verb, isMany, findItemInStore },
@@ -27769,6 +28056,12 @@ const applyNetworkPolicy = (
27769
28056
  if (!policy.readsFromStore) {
27770
28057
  return restCallback(params, context);
27771
28058
  }
28059
+ if (isKeepableRead(context.action)) {
28060
+ const dataHeld = context.action.dataSignal.peek();
28061
+ if (dataHeld !== undefined) {
28062
+ return dataHeld;
28063
+ }
28064
+ }
27772
28065
  if (!isMany && findItemInStore) {
27773
28066
  const item = findItemInStore(params, context.action);
27774
28067
  if (item) {
@@ -37919,6 +38212,37 @@ const writeCover = (edge, element, size) => {
37919
38212
  style.setProperty(property, value);
37920
38213
  };
37921
38214
 
38215
+ // Every route action, with whether it asks something of the address at the
38216
+ // moment: its route matches and its params getter returns something.
38217
+ const routeActionEntriesSignal = signal([]);
38218
+ let activeRouteActionsPrevious = new Set();
38219
+ /**
38220
+ * The route actions asking something for the page on screen: their route
38221
+ * matches and their params getter returns something — the running ones, not
38222
+ * every matching one, so an action bound to every page that asks only when a
38223
+ * popup is open over it counts while it is open. Read to rerun what the page
38224
+ * reads (the app back from the background), and by `keepPageOnScreen`.
38225
+ * The set is handed out again only when its members change.
38226
+ *
38227
+ * A page's code is declared as a route action too; `action.meta.verb` says
38228
+ * which ones read data.
38229
+ *
38230
+ * @type {import("@preact/signals").ReadonlySignal<Set<object>>}
38231
+ */
38232
+ const activeRouteActionsSignal = computed(() => {
38233
+ const active = new Set();
38234
+ for (const { routeAction, askingSignal } of routeActionEntriesSignal.value) {
38235
+ if (askingSignal.value) {
38236
+ active.add(routeAction);
38237
+ }
38238
+ }
38239
+ if (haveSameMembers(active, activeRouteActionsPrevious)) {
38240
+ return activeRouteActionsPrevious;
38241
+ }
38242
+ activeRouteActionsPrevious = active;
38243
+ return active;
38244
+ });
38245
+
37922
38246
  /**
37923
38247
  * Binds an action to a route: it runs when the route matches, with the params
37924
38248
  * the effect reads off the address, runs again on a `reload()`, and is aborted
@@ -37976,6 +38300,13 @@ const routeAction = (
37976
38300
  return params;
37977
38301
  };
37978
38302
  const actionBoundToRoute = actionRunEffect(action, readParamsAsked, options);
38303
+ routeActionEntriesSignal.value = [
38304
+ ...routeActionEntriesSignal.peek(),
38305
+ {
38306
+ routeAction: actionBoundToRoute,
38307
+ askingSignal: computed(() => Boolean(readParamsAsked())),
38308
+ },
38309
+ ];
37979
38310
  for (const route of routes) {
37980
38311
  // Asked of the route's params as they are, like the effect asks them.
37981
38312
  registerRouteReload(route, () =>
@@ -38059,6 +38390,343 @@ const anyMatchingRouteSignal = (routes) => {
38059
38390
  return anyMatchingSignal;
38060
38391
  };
38061
38392
 
38393
+ const haveSameMembers = (setA, setB) => {
38394
+ if (setA.size !== setB.size) {
38395
+ return false;
38396
+ }
38397
+ for (const value of setA) {
38398
+ if (!setB.has(value)) {
38399
+ return false;
38400
+ }
38401
+ }
38402
+ return true;
38403
+ };
38404
+
38405
+ /*
38406
+ * The page on screen, kept for the next document: what its reads answered,
38407
+ * written into a signal the app hands over, and drawn again by the same reads
38408
+ * in a document opening on the same page (a reload, a tab the system
38409
+ * discarded). The read side is state/rest/kept_reads.js; this is what decides
38410
+ * which reads are the page, and when the slot is written.
38411
+ *
38412
+ * The unit is the page, not the resource. One resource is read by several
38413
+ * screens — a search, someone's games, a game opened from anywhere — and kept
38414
+ * per resource it would keep every row ever opened. The page's reads are what
38415
+ * navi already runs for it: the route actions asking something for the page
38416
+ * (activeRouteActionsSignal) and the compositions the lists on screen read
38417
+ * (shownRangeReadersSignal). A search typed on the page is neither, and is not
38418
+ * kept.
38419
+ *
38420
+ * A read is written once it has answered in this document. Before that it
38421
+ * keeps what the previous document kept for it, never what it shows meanwhile:
38422
+ * an answer handed over from other params (a list standing in for the next
38423
+ * one) says nothing about these.
38424
+ *
38425
+ * The write waits for the page to settle and is flushed when the document is
38426
+ * hidden. A navigation goes through pages half there — the route matched, its
38427
+ * list not mounted yet, the page left still drawn under a transition — and
38428
+ * each would otherwise be written out in full. Hidden is the moment the system
38429
+ * may discard the document, so nothing is lost by waiting until then.
38430
+ *
38431
+ * The signal turning `undefined` from outside (sign-out) forgets the page, and
38432
+ * nothing is written until a read lands again: written at once, the page still
38433
+ * on screen would put back the account just left.
38434
+ */
38435
+
38436
+ const WRITE_DELAY = 1000;
38437
+ const NOTHING_PENDING = {};
38438
+ let stopKeepingCurrent = null;
38439
+
38440
+ /**
38441
+ * Keeps what the page on screen reads, so that a new document opening on it —
38442
+ * a reload, a pull-to-refresh, a tab the system discarded in the background —
38443
+ * draws it again while its reads go out, rather than skeletons.
38444
+ *
38445
+ * What the page reads is what navi runs for it: the route actions asking
38446
+ * something for it (see `activeRouteActionsSignal`) whose action is a resource
38447
+ * `GET` or `GET_MANY`, and the `GET_RANGE` compositions the `<List.Items>` on
38448
+ * screen read, around the window they draw. A navigation replaces the slot with
38449
+ * the next page's reads, and the page left is dropped. In the next document,
38450
+ * the first run of the same read with the same params draws the kept answer as
38451
+ * its provisional value: `data` set while `loading` is `true`, the answer
38452
+ * replacing it. Under a network policy answering reads from the store, the
38453
+ * kept answer answers the read. The copy follows the store: a `PUT` on a row
38454
+ * rewrites it.
38455
+ *
38456
+ * Call it before the routes start: the first runs are the ones that look.
38457
+ *
38458
+ * @param {object} options
38459
+ * @param {import("@preact/signals").Signal} options.signal - where the slot is
38460
+ * written: a `stateSignal` with `persists: true` and `type: "object"`, its
38461
+ * `id` holding whatever makes a kept page unusable (the deployed version).
38462
+ * Writing `undefined` into it (sign-out) forgets the page, and nothing is
38463
+ * written again until a read lands.
38464
+ * @param {() => boolean} [options.when] - read whenever the slot is about to be
38465
+ * read or written: `false` reads nothing, writes nothing, and empties the
38466
+ * signal (a tab viewing as someone else). A function, so it can read a signal.
38467
+ * @param {object[]} [options.always] - routes whose reads stay kept once the
38468
+ * page is left, until it is visited again: the page an app opens on when it
38469
+ * is launched rather than reloaded.
38470
+ * @returns {() => void} stops keeping the page.
38471
+ * @see docs/resource.md — what is kept and what is not, what stays the app's
38472
+ */
38473
+ const keepPageOnScreen = ({
38474
+ signal: keptSignal,
38475
+ when,
38476
+ always = [],
38477
+ } = {}) => {
38478
+ if (!isSignal(keptSignal)) {
38479
+ throw new TypeError(
38480
+ `keepPageOnScreen() needs a signal to write the page into, received ${keptSignal}`,
38481
+ );
38482
+ }
38483
+ if (stopKeepingCurrent) {
38484
+ stopKeepingCurrent();
38485
+ }
38486
+ const isAllowed = () => {
38487
+ return when ? Boolean(when()) : true;
38488
+ };
38489
+ const alwaysMatchingSignal = anyMatchingRouteSignal(always);
38490
+
38491
+ // What the signal held when it was read: the previous document's page.
38492
+ let loadedEntryMap = new Map();
38493
+ let loadedAlwaysKeys = [];
38494
+ // Which reads answered in this document (since the last forget): only
38495
+ // those write what they show.
38496
+ let answeredActionWeakSet = new WeakSet();
38497
+ let answeredRangeKeySet = new Set();
38498
+ // The state each action was last seen in, and the last answer seen of each
38499
+ // range reader: a read lands when they move, and not because the slot looks
38500
+ // again at something that had answered before a forget.
38501
+ const stateSeenWeakMap = new WeakMap();
38502
+ const answeredAtSeenWeakMap = new WeakMap();
38503
+ // Forgotten: nothing is written until a read lands.
38504
+ let paused = false;
38505
+ // The reads of the always-kept page, as they were the last time it was on
38506
+ // screen in this document; null until then.
38507
+ let alwaysReads = null;
38508
+
38509
+ const forget = () => {
38510
+ loadedEntryMap = new Map();
38511
+ loadedAlwaysKeys = [];
38512
+ answeredActionWeakSet = new WeakSet();
38513
+ answeredRangeKeySet = new Set();
38514
+ alwaysReads = null;
38515
+ paused = true;
38516
+ withdrawKeptReads();
38517
+ };
38518
+
38519
+ let lastWritten;
38520
+ let pendingWrite = NOTHING_PENDING;
38521
+ let writeTimeout;
38522
+ const flush = () => {
38523
+ clearTimeout(writeTimeout);
38524
+ if (pendingWrite === NOTHING_PENDING) {
38525
+ return;
38526
+ }
38527
+ const value = pendingWrite;
38528
+ pendingWrite = NOTHING_PENDING;
38529
+ if (compareTwoJsValues(value, lastWritten)) {
38530
+ return;
38531
+ }
38532
+ lastWritten = value;
38533
+ keptSignal.value = value;
38534
+ };
38535
+ const scheduleWrite = (value) => {
38536
+ pendingWrite = value;
38537
+ clearTimeout(writeTimeout);
38538
+ writeTimeout = setTimeout(flush, WRITE_DELAY);
38539
+ };
38540
+ const onVisibilityChange = () => {
38541
+ if (document.visibilityState === "hidden") {
38542
+ flush();
38543
+ }
38544
+ };
38545
+ document.addEventListener("visibilitychange", onVisibilityChange);
38546
+ window.addEventListener("pagehide", flush);
38547
+
38548
+ // Runs at once with what the signal already holds (the slot read from
38549
+ // storage), then on every write — the slot's own ones are recognized and
38550
+ // left alone.
38551
+ const unsubscribe = keptSignal.subscribe((value) => {
38552
+ if (value === lastWritten) {
38553
+ return;
38554
+ }
38555
+ lastWritten = value;
38556
+ pendingWrite = NOTHING_PENDING;
38557
+ clearTimeout(writeTimeout);
38558
+ if (
38559
+ value === undefined ||
38560
+ value === null ||
38561
+ typeof value !== "object" ||
38562
+ !isAllowed()
38563
+ ) {
38564
+ forget();
38565
+ return;
38566
+ }
38567
+ const entries =
38568
+ value.entries && typeof value.entries === "object" ? value.entries : {};
38569
+ loadedEntryMap = new Map(Object.entries(entries));
38570
+ loadedAlwaysKeys = Array.isArray(value.always)
38571
+ ? value.always.filter((key) => loadedEntryMap.has(key))
38572
+ : [];
38573
+ alwaysReads = null;
38574
+ paused = false;
38575
+ offerKeptReads(loadedEntryMap);
38576
+ });
38577
+
38578
+ const actionReadWeakMap = new WeakMap();
38579
+ const actionReadOf = (action) => {
38580
+ let read = actionReadWeakMap.get(action);
38581
+ if (read) {
38582
+ return read;
38583
+ }
38584
+ read = () => {
38585
+ const state = action.runningStateSignal.value;
38586
+ const stateSeen = stateSeenWeakMap.get(action);
38587
+ stateSeenWeakMap.set(action, state);
38588
+ const landed = state === COMPLETED && stateSeen !== COMPLETED;
38589
+ if (landed) {
38590
+ answeredActionWeakSet.add(action);
38591
+ }
38592
+ return {
38593
+ key: getKeptReadKey(action.name, action.params),
38594
+ answered: answeredActionWeakSet.has(action),
38595
+ landed,
38596
+ readEntry: () => {
38597
+ const data = action.dataSignal.value;
38598
+ if (data === undefined || data === null) {
38599
+ return undefined;
38600
+ }
38601
+ return serializeData(data);
38602
+ },
38603
+ };
38604
+ };
38605
+ actionReadWeakMap.set(action, read);
38606
+ return read;
38607
+ };
38608
+ const rangeReadWeakMap = new WeakMap();
38609
+ const rangeReadOf = (reader) => {
38610
+ let read = rangeReadWeakMap.get(reader);
38611
+ if (read) {
38612
+ return read;
38613
+ }
38614
+ read = () => {
38615
+ const { key, answeredAt, readEntry } = reader.readKept();
38616
+ const answeredAtSeen = answeredAtSeenWeakMap.get(reader) || 0;
38617
+ answeredAtSeenWeakMap.set(reader, answeredAt);
38618
+ const landed = answeredAt > answeredAtSeen;
38619
+ if (landed) {
38620
+ answeredRangeKeySet.add(key);
38621
+ }
38622
+ return {
38623
+ key,
38624
+ answered: answeredRangeKeySet.has(key),
38625
+ landed,
38626
+ readEntry,
38627
+ };
38628
+ };
38629
+ rangeReadWeakMap.set(reader, read);
38630
+ return read;
38631
+ };
38632
+ const readsOnScreen = () => {
38633
+ const reads = [];
38634
+ for (const routeAction of activeRouteActionsSignal.value) {
38635
+ // The instance is what is kept, so that a page left keeps what it read.
38636
+ // The binding's callSource names that instance and is rewritten once the
38637
+ // binding has moved to another one: read, it brings the slot along.
38638
+ // eslint-disable-next-line no-unused-expressions
38639
+ routeAction.callSource;
38640
+ const action = getActionPrivateProperties(routeAction).currentAction;
38641
+ if (action.params === NO_PARAMS || !isKeepableRead(action)) {
38642
+ continue;
38643
+ }
38644
+ reads.push(actionReadOf(action));
38645
+ }
38646
+ for (const reader of shownRangeReadersSignal.value.keys()) {
38647
+ reads.push(rangeReadOf(reader));
38648
+ }
38649
+ return reads;
38650
+ };
38651
+
38652
+ const stopEffect = effect(() => {
38653
+ const allowed = isAllowed();
38654
+ const routed = routedSignal.value;
38655
+ const alwaysMatching = alwaysMatchingSignal.value;
38656
+ if (!allowed) {
38657
+ forget();
38658
+ pendingWrite = undefined;
38659
+ flush();
38660
+ return;
38661
+ }
38662
+ if (!routed) {
38663
+ // No page yet: the slot is what the first reads will draw.
38664
+ return;
38665
+ }
38666
+ const screenReads = readsOnScreen();
38667
+ if (alwaysMatching) {
38668
+ alwaysReads = screenReads;
38669
+ }
38670
+ const entries = {};
38671
+ let landed = false;
38672
+ const collect = (reads) => {
38673
+ const keys = [];
38674
+ for (const read of reads) {
38675
+ const result = read();
38676
+ if (result.landed) {
38677
+ landed = true;
38678
+ }
38679
+ const entry = result.answered
38680
+ ? result.readEntry()
38681
+ : loadedEntryMap.get(result.key);
38682
+ if (entry === undefined) {
38683
+ continue;
38684
+ }
38685
+ entries[result.key] = entry;
38686
+ keys.push(result.key);
38687
+ }
38688
+ return keys;
38689
+ };
38690
+ const screenKeys = collect(screenReads);
38691
+ let alwaysKeys;
38692
+ if (alwaysReads === screenReads) {
38693
+ alwaysKeys = screenKeys;
38694
+ } else if (alwaysReads) {
38695
+ alwaysKeys = collect(alwaysReads);
38696
+ } else {
38697
+ alwaysKeys = loadedAlwaysKeys;
38698
+ for (const key of alwaysKeys) {
38699
+ entries[key] = loadedEntryMap.get(key);
38700
+ }
38701
+ }
38702
+ if (paused) {
38703
+ if (!landed) {
38704
+ return;
38705
+ }
38706
+ paused = false;
38707
+ }
38708
+ if (screenKeys.length === 0 && alwaysKeys.length === 0) {
38709
+ scheduleWrite(undefined);
38710
+ return;
38711
+ }
38712
+ scheduleWrite({ entries, screen: screenKeys, always: alwaysKeys });
38713
+ });
38714
+
38715
+ const stop = () => {
38716
+ flush();
38717
+ stopEffect();
38718
+ unsubscribe();
38719
+ document.removeEventListener("visibilitychange", onVisibilityChange);
38720
+ window.removeEventListener("pagehide", flush);
38721
+ withdrawKeptReads();
38722
+ if (stopKeepingCurrent === stop) {
38723
+ stopKeepingCurrent = null;
38724
+ }
38725
+ };
38726
+ stopKeepingCurrent = stop;
38727
+ return stop;
38728
+ };
38729
+
38062
38730
  /**
38063
38731
  * A popup that is the anchor, brought to the front.
38064
38732
  *
@@ -65131,7 +65799,7 @@ const css$E = /* css */`
65131
65799
  /* A plain div, unlike dialogEl itself (a real <dialog>, natively hidden
65132
65800
  by default until .show()/.showModal() adds [open]) — needs its own
65133
65801
  starting-hidden mechanism. [navi-hidden] is set from useDialogProps'
65134
- own backdropProps (recomputed from openController.opened on every
65802
+ own backdropProps (recomputed from openController.openedInDom on every
65135
65803
  render, present from the very first one), then toggled by plain
65136
65804
  removeAttribute/setAttribute in openEffect/close, never an explicit
65137
65805
  display override — removing the attribute just lets this rule stop
@@ -66732,7 +67400,7 @@ const useDialogProps = props => {
66732
67400
  "navi-out-of-flow": "",
66733
67401
  "baseClassName": "navi_dialog_backdrop",
66734
67402
  "aria-hidden": "true",
66735
- // Recomputed fresh on every render from openController.opened (not
67403
+ // Recomputed fresh on every render from openController.openedInDom (not
66736
67404
  // driven through a mount-time layout effect, unlike this file's own
66737
67405
  // imperative open/close toggling below) — present in the DOM
66738
67406
  // synchronously from the very first commit, matching this file's own
@@ -66742,7 +67410,7 @@ const useDialogProps = props => {
66742
67410
  // needs — see popover.jsx's own identical prop for the full reasoning.
66743
67411
  "aria-expanded": openController.openedInDom ? "true" : "false",
66744
67412
  // Present from this very first render (recomputed fresh on every one
66745
- // from openController.opened, not a frozen mount-time constant) so
67413
+ // from openController.openedInDom, not a frozen mount-time constant) so
66746
67414
  // there's no gap for the browser to ever paint this plain-div backdrop
66747
67415
  // visible before anything has actually opened it — see popover.jsx's
66748
67416
  // own identical prop for the full reasoning, and this file's own CSS
@@ -66762,7 +67430,7 @@ const useDialogProps = props => {
66762
67430
  // (kept once, not repeated here).
66763
67431
  "aria-expanded": openController.openedInDom ? "true" : "false",
66764
67432
  // Present from the very first render (recomputed fresh from
66765
- // openController.opened every time, not a frozen mount-time constant —
67433
+ // openController.openedInDom every time, not a frozen mount-time constant —
66766
67434
  // see popover.jsx's own identical prop for the full reasoning) so a
66767
67435
  // consumer whose own CSS also sets display (e.g. Popup's flex prop)
66768
67436
  // can't silently defeat showModal()/close()'s native open/close — see
@@ -67138,7 +67806,7 @@ const css$D = /* css */`
67138
67806
  without this it would flash visible for one frame on mount, before
67139
67807
  openEffect's own JS ever gets a chance to hide it. [navi-hidden] is
67140
67808
  set from usePopoverProps' own contentProps (recomputed from
67141
- openController.opened on every render, present from the very first
67809
+ openController.openedInDom on every render, present from the very first
67142
67810
  one — see there for why), then toggled by plain
67143
67811
  removeAttribute/setAttribute in openEffect/close, never an explicit
67144
67812
  display override: removing the attribute just lets this rule stop
@@ -68494,7 +69162,7 @@ const usePopoverProps = props => {
68494
69162
  "navi-out-of-flow": "",
68495
69163
  "baseClassName": "navi_popover_backdrop",
68496
69164
  "aria-hidden": "true",
68497
- // Recomputed fresh on every render from openController.opened (not a
69165
+ // Recomputed fresh on every render from openController.openedInDom (not a
68498
69166
  // frozen mount-time constant, same pattern as navi-hidden just below)
68499
69167
  // rather than driven through a mount-time layout effect — its own
68500
69168
  // actual open/close toggling is still done entirely imperatively
@@ -68569,7 +69237,7 @@ const usePopoverProps = props => {
68569
69237
  // starting-hidden default) — present from this very first render so
68570
69238
  // there's no gap for the browser to ever paint it visible before
68571
69239
  // anything has actually opened it. Recomputed fresh on every render from
68572
- // openController.opened (not a frozen mount-time constant) — Preact
69240
+ // openController.openedInDom (not a frozen mount-time constant) — Preact
68573
69241
  // only touches the DOM for a prop whose value actually changed since
68574
69242
  // the last render, so as long as this always reflects the *current*
68575
69243
  // truth, it never fights the imperative
@@ -72995,7 +73663,6 @@ const ListUI = props => {
72995
73663
  // What the runs ask for and stand for, in items: a source paginates in items,
72996
73664
  // and asks for the first picture's items and the rest in one round trip.
72997
73665
  listItems.pageSize = PAGE_SIZE_DEFAULT;
72998
- listItems.scrolled = scrolled ?? defaultScrolled;
72999
73666
  const {
73000
73667
  virtualItemSizeSignal,
73001
73668
  renderWindow,
@@ -73335,6 +74002,45 @@ const useListScrollSync = ({
73335
74002
  columns
73336
74003
  }) => {
73337
74004
  const debugScroll = useDebugScroll();
74005
+ // Where the list must be: what the caller holds it at (`scrolled`), or where
74006
+ // it opens and then lets go (`defaultScrolled`). A `scrolled` that changes is
74007
+ // the caller moving the list, so the hold is armed again — that is what makes
74008
+ // it controlled. A `defaultScrolled` that changes is where the list opens,
74009
+ // read again from something that answered again (a count fresher than the
74010
+ // one kept from the previous visit): followed for as long as nobody has
74011
+ // moved the list, and compared by value, since it is said at every render.
74012
+ // Nothing to hold it at (a position not saved yet) is not a position: the
74013
+ // list opens where it opens.
74014
+ const startPlaceRef = useRef(null);
74015
+ if (startPlaceRef.current === null) {
74016
+ startPlaceRef.current = {
74017
+ userTookOver: false,
74018
+ wanted: scrolled ?? defaultScrolled,
74019
+ scrolled,
74020
+ defaultScrolled,
74021
+ way: null,
74022
+ arrived: false
74023
+ };
74024
+ } else {
74025
+ const place = startPlaceRef.current;
74026
+ if (place.scrolled !== scrolled) {
74027
+ place.scrolled = scrolled;
74028
+ place.defaultScrolled = defaultScrolled;
74029
+ place.wanted = scrolled ?? defaultScrolled;
74030
+ place.userTookOver = false;
74031
+ } else if ((scrolled === undefined || scrolled === null) && !compareTwoJsValues(place.defaultScrolled, defaultScrolled)) {
74032
+ place.defaultScrolled = defaultScrolled;
74033
+ if (!place.userTookOver) {
74034
+ place.wanted = defaultScrolled;
74035
+ // Placed again from where it stands: the way to the previous place
74036
+ // says nothing about whose the next scrolls are.
74037
+ place.way = null;
74038
+ place.arrived = false;
74039
+ }
74040
+ }
74041
+ }
74042
+ const scrolledWanted = startPlaceRef.current.wanted;
74043
+ listItems.scrolled = scrolledWanted;
73338
74044
  // The items drawn, [start, end) among the list's own. A ref as well as a
73339
74045
  // state: the render moves it where the list is held (holdWindow) without a
73340
74046
  // commit of its own.
@@ -73349,7 +74055,7 @@ const useListScrollSync = ({
73349
74055
  const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal, {
73350
74056
  inLines: Boolean(columns),
73351
74057
  windowLeavesItemsOut,
73352
- scrolledWanted: scrolled ?? defaultScrolled,
74058
+ scrolledWanted,
73353
74059
  // The fillers hold the room of items above the screen at this size: a
73354
74060
  // size that changes moves what is on screen, like items landing above it.
73355
74061
  beforeSizeChange: () => listItems.captureAnchor()
@@ -73680,25 +74386,7 @@ const useListScrollSync = ({
73680
74386
  const newEnd = newStart + windowSize;
73681
74387
  updateRenderWindow(newStart, newEnd);
73682
74388
  };
73683
-
73684
- // Where the list must be: what the caller holds it at (`scrolled`), or where
73685
- // it opens and then lets go (`defaultScrolled`). A `scrolled` that changes is
73686
- // the caller moving the list, so the hold is armed again — that is what makes
73687
- // it controlled.
73688
- // Nothing to hold it at (a position not saved yet) is not a position: the
73689
- // list opens where it opens.
73690
- const scrolledWanted = scrolled ?? defaultScrolled;
73691
74389
  const scrolledFallback = scrolled === undefined || scrolled === null ? "start" : defaultScrolled ?? "start";
73692
- const startPlaceRef = useRef({
73693
- userTookOver: false,
73694
- wanted: scrolledWanted,
73695
- way: null,
73696
- arrived: false
73697
- });
73698
- if (startPlaceRef.current.wanted !== scrolledWanted) {
73699
- startPlaceRef.current.wanted = scrolledWanted;
73700
- startPlaceRef.current.userTookOver = false;
73701
- }
73702
74390
  // Only one thing owns the scroll at a time: while the list is holding itself
73703
74391
  // somewhere, the anchoring stays out of it (holding an item still is precisely
73704
74392
  // not being at the end anymore once what is above it shrinks).
@@ -77418,6 +78106,17 @@ const useItemStore = ({
77418
78106
  }
77419
78107
  const pages = pagesRef.current;
77420
78108
  const [, setPageVersion] = useState(0);
78109
+ // A list on screen is part of what the page reads: the composition it reads
78110
+ // is kept with the page for the next document (see keepPageOnScreen).
78111
+ useLayoutEffect(() => {
78112
+ if (inMemory || typeof itemsAction !== "function") {
78113
+ return undefined;
78114
+ }
78115
+ if (!itemsAction.isRangeReader) {
78116
+ return undefined;
78117
+ }
78118
+ return showRangeReader(itemsAction);
78119
+ }, [inMemory, itemsAction]);
77421
78120
  // The ranks whose item is from before: the run came back to the screen, or
77422
78121
  // the source said the collection moved. They stay drawn until a page confirms
77423
78122
  // or replaces them, rank by rank. A page covers what the window framed when it
@@ -77551,6 +78250,7 @@ const useItemStore = ({
77551
78250
  }
77552
78251
  }
77553
78252
  if (typeof itemsAction === "function" && itemsAction.trimComposition) {
78253
+ itemsAction.frameComposition(windowFrom, windowTo);
77554
78254
  itemsAction.trimComposition(keepFrom, keepTo, budget);
77555
78255
  }
77556
78256
  },
@@ -78120,6 +78820,9 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
78120
78820
  * room has to be restated as a number by whoever asks. The `index` and
78121
78821
  * `visibleCount` it also hands out say where to aim before the item is found,
78122
78822
  * and how many items to draw before the first paint (see `renderBudget`).
78823
+ * A value that changes before anyone has moved the list is followed (a place
78824
+ * read from a count that answered again): it is compared by value, so it can
78825
+ * be computed at every render.
78123
78826
  * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.scrolled]
78124
78827
  * The same, but held: the list goes back there every time this changes, even
78125
78828
  * after the user has scrolled — the caller owns where the list is (see
@@ -93540,5 +94243,5 @@ const UserSvg = () => jsx("svg", {
93540
94243
  })
93541
94244
  });
93542
94245
 
93543
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NetworkPolicyError, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, dispatchRequestSetUIState, enableDebugActions, enableDebugOnDocumentLoading, enableVirtualKeyboardOverlay, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, getNowHours, getNowHoursRoundedToStep, isCellSelected, isColumnSelected, isNetworkPolicyError, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markAsOutsideTextFlow, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, preloadUrl, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, routeFallback, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePickerState, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useTransitionCover, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
94246
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NetworkPolicyError, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, activeRouteActionsSignal, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, dispatchRequestSetUIState, enableDebugActions, enableDebugOnDocumentLoading, enableVirtualKeyboardOverlay, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, getNowHours, getNowHoursRoundedToStep, isCellSelected, isColumnSelected, isNetworkPolicyError, isRowSelected, isScrolling, isToday, keepPageOnScreen, languagesSignal, localStorageSignal, markAsOutsideTextFlow, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, preloadUrl, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, routeFallback, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePickerState, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useTransitionCover, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
93544
94247
  //# sourceMappingURL=jsenv_navi.js.map