@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.
@@ -3696,8 +3696,11 @@ const actionWeakMap = new WeakMap();
3696
3696
  * than a first load (data_states.md). Called at the start of every run whose
3697
3697
  * value is undefined; the answer replaces it as usual, and `undefined` means
3698
3698
  * "nothing known". A resource `GET` uses it to draw the row its store holds.
3699
- * An action declaring it is asked for it by a binding moving to new params,
3700
- * rather than handed the previous params' answer (see inheritData).
3699
+ * @param {boolean} [rootOptions.inheritData=true] - `false`: a binding moving
3700
+ * to new params starts the new instance without the previous params' answer
3701
+ * (see createActionProxyFromSignal). For an answer that would say something
3702
+ * false about the new params (a `GET` drawing another row), not for one that
3703
+ * stands in while the next one comes (a list of search results).
3701
3704
  * @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
3702
3705
  * answer stays good once it has landed. Without it an answer lives exactly as
3703
3706
  * long as something references it: the screen that asked goes away, and
@@ -3740,6 +3743,7 @@ const createAction = (callback, rootOptions = {}) => {
3740
3743
  resultToValue,
3741
3744
  valueToData,
3742
3745
  provisionalValue,
3746
+ inheritData = true,
3743
3747
  dataDefault,
3744
3748
  data = dataDefault,
3745
3749
 
@@ -4416,6 +4420,7 @@ const createAction = (callback, rootOptions = {}) => {
4416
4420
  const privateProperties = {
4417
4421
  valueInitial,
4418
4422
  provisionalValue,
4423
+ inheritData,
4419
4424
 
4420
4425
  performRun,
4421
4426
  performReset,
@@ -4448,7 +4453,7 @@ const createAction = (callback, rootOptions = {}) => {
4448
4453
  * a list of filters changes, providing real-time results without user interaction.
4449
4454
  * @param {boolean} options.inheritData - When false, each new target action starts fresh with no inherited state.
4450
4455
  * By default (true), the proxy carries over the previous target's value and error into the new action —
4451
- * unless the action declares a `provisionalValue`, which then says what the new one holds.
4456
+ * unless the action itself declares `inheritData: false` (createAction), which a resource `GET` does.
4452
4457
  * This keeps the facade in sync with the latest known data: `action.dataSignal.value` only changes when a
4453
4458
  * new action completes, not when it starts loading. Code that needs to distinguish loading state can still
4454
4459
  * check `action.runningState`, while code that just reads `action.data` always sees the most recent
@@ -4495,12 +4500,12 @@ const createActionProxyFromSignal = (
4495
4500
  let currentActionPrivateProperties = getActionPrivateProperties(action);
4496
4501
  let actionTargetPreviousWeakRef = null;
4497
4502
 
4498
- // An action saying for itself what it holds for params it has not answered
4499
- // (provisionalValue: a resource GET draws the row its store holds) is not
4500
- // handed the previous params' answer — an answer about another row. What it
4501
- // does not know stays unknown: a skeleton, never a different row.
4503
+ // An action whose answer for other params would be false for these (a
4504
+ // resource GET: another row) says so itself, and is not handed it. What it
4505
+ // does not know stays unknown: a skeleton, or its provisionalValue, never a
4506
+ // different row.
4502
4507
  const inheritsAnswer =
4503
- inheritData && !getActionPrivateProperties(action).provisionalValue;
4508
+ inheritData && getActionPrivateProperties(action).inheritData;
4504
4509
  const createTarget = (params) => {
4505
4510
  if (inheritsAnswer) {
4506
4511
  const previousActionTarget = actionTargetPreviousWeakRef?.deref();
@@ -10651,6 +10656,9 @@ const paramsTargetCanPlace = (redirectRoute, urlParams) => {
10651
10656
 
10652
10657
  let setupRoutesCalled = false;
10653
10658
  let activeRouteSet = null;
10659
+ // True once the routes have been matched against an address. Before that, no
10660
+ // route matching says nothing about the page: nothing has been asked yet.
10661
+ const routedSignal = signal(false);
10654
10662
 
10655
10663
  const routePreloadMap = new WeakMap();
10656
10664
  const registerRoutePreload = (route, preload) => {
@@ -11075,6 +11083,7 @@ This prevents cross-test pollution and ensures clean state.`,
11075
11083
  }
11076
11084
  // Reset flag after URL -> Signal synchronization is complete
11077
11085
  isUpdatingRoutesFromUrl = false;
11086
+ routedSignal.value = true;
11078
11087
  Object.assign(returnValue, { matchingRouteSet });
11079
11088
  for (const [route, repairedParams] of repairedParamsByRoute) {
11080
11089
  route.replaceParams(repairedParams, {
@@ -11124,6 +11133,7 @@ This prevents cross-test pollution and ensures clean state.`,
11124
11133
  droppingRouteSet = null;
11125
11134
  setupRoutesCalled = false;
11126
11135
  activeRouteSet = null;
11136
+ routedSignal.value = false;
11127
11137
  };
11128
11138
  return {
11129
11139
  updateRoutes,
@@ -15628,6 +15638,13 @@ const createOpenController = (
15628
15638
  change();
15629
15639
  };
15630
15640
  const runChange = (change, { opened, event }) => {
15641
+ // What the controller answers about itself is the decision, written before
15642
+ // the change runs, whatever runs it. Whoever asks meanwhile reads it: the
15643
+ // render an opening forces (onOpen, mountContent) reads the open state
15644
+ // the caller holds, and an onOpen writing the popup's own `signal` has
15645
+ // useOpenPropsEffectOnOpenController asking for this very opening — a
15646
+ // second one, nested in the first, if `opened` still said closed.
15647
+ controller.opened = opened;
15631
15648
  const applyChange = () => {
15632
15649
  // Recorded with the change itself: what the DOM shows is what a render
15633
15650
  // landing between the ask and the picture must draw (see `openedInDom`).
@@ -15643,12 +15660,8 @@ const createOpenController = (
15643
15660
  applyChange();
15644
15661
  return;
15645
15662
  }
15646
- // What the controller answers about itself does not wait for the picture:
15647
- // whoever just asked reads `opened` on the spot (see
15648
- // useOpenPropsEffectOnOpenController, which writes it back into the
15649
- // caller's own signal), and a request arriving before the change lands
15650
- // runs it first rather than reading a DOM that disagrees.
15651
- controller.opened = opened;
15663
+ // A request arriving before the change lands runs it first rather than
15664
+ // reading a DOM that disagrees.
15652
15665
  changeAwaitingTransition = applyChange;
15653
15666
  transitionChange(
15654
15667
  () => {
@@ -15661,7 +15674,6 @@ const createOpenController = (
15661
15674
  };
15662
15675
 
15663
15676
  const performClose = (closeEvent) => {
15664
- controller.opened = false;
15665
15677
  // Read before any close effect touches the DOM: closing a native <dialog>
15666
15678
  // hands the focus back to whatever held it at showModal() time, so by the
15667
15679
  // time the close cleanup runs, the popup's content has already lost the
@@ -15929,7 +15941,6 @@ const createOpenController = (
15929
15941
  // told it opened right after (see popup_content_mount.js and
15930
15942
  // use_displayed_layout_effect.js).
15931
15943
  controller.openedInDom = true;
15932
- controller.opened = true;
15933
15944
  // Which press it opened during, so the release of that press is not
15934
15945
  // read as somebody dismissing it (see openedDuringThisPress).
15935
15946
  controller.pressCountAtOpen = pressCount;
@@ -16374,8 +16385,10 @@ const useOpenPropsEffectOnOpenController = (
16374
16385
  return undefined;
16375
16386
  }
16376
16387
  // Skip when the controller is already in the desired state.
16377
- // openController.opened tracks actual open/close (updated by onopen/onclose,
16378
- // not by renders) so it is the authoritative check against feedback loops.
16388
+ // openController.opened is what the controller decided, written before an
16389
+ // opening renders anything (see runChange), so it is the authoritative
16390
+ // check against feedback loops — the popup's own onOpen writing its
16391
+ // `signal` included.
16379
16392
  if (open === openController.opened) {
16380
16393
  return undefined;
16381
16394
  }
@@ -26725,6 +26738,171 @@ const isOwnerReadBy = (childResource, childAction, ownerGetAction) => {
26725
26738
  return childStore !== undefined && childStore === getChildStore(ownerId);
26726
26739
  };
26727
26740
 
26741
+ /*
26742
+ * What a previous document kept of the page it was on (see keepPageOnScreen in
26743
+ * nav/page_kept.js), waiting for the reads that draw it again, and what those
26744
+ * reads are made of on the way out: rows with their relations inline.
26745
+ *
26746
+ * A kept read is found by the read's name and the params it was asked with
26747
+ * (getKeptReadKey). It is handed out once: the first run of the read draws it
26748
+ * while its request is out, and from then on the store holds the rows. Handed
26749
+ * out again, after a reset say, it would write the previous document's rows
26750
+ * over a fresher answer.
26751
+ *
26752
+ * This module is the half the resources see: `resource()` and the range reader
26753
+ * take from it, and say which of their reads can be kept at all. Which reads
26754
+ * are on the page on screen, and what the slot is written into, is
26755
+ * page_kept.js.
26756
+ */
26757
+
26758
+ // key → what the read answered in the previous document, not handed out yet
26759
+ const offeredReadMap = new Map();
26760
+ const offerKeptReads = (entryMap) => {
26761
+ offeredReadMap.clear();
26762
+ for (const [key, entry] of entryMap) {
26763
+ offeredReadMap.set(key, entry);
26764
+ }
26765
+ };
26766
+ const withdrawKeptReads = () => {
26767
+ offeredReadMap.clear();
26768
+ };
26769
+ const takeKeptRead = (key) => {
26770
+ const entry = offeredReadMap.get(key);
26771
+ if (entry === undefined) {
26772
+ return undefined;
26773
+ }
26774
+ offeredReadMap.delete(key);
26775
+ return entry;
26776
+ };
26777
+
26778
+ // The root actions whose answer is rows of a store, written and read back the
26779
+ // same way: a resource's GET and GET_MANY. Anything else a route runs (a page's
26780
+ // code, a computation) has nothing a later document could draw.
26781
+ const keepableReadWeakSet = new WeakSet();
26782
+ const markKeepableRead = (rootAction) => {
26783
+ keepableReadWeakSet.add(rootAction);
26784
+ };
26785
+ const isKeepableRead = (action) => {
26786
+ const rootAction = action.rootAction || action;
26787
+ return keepableReadWeakSet.has(rootAction);
26788
+ };
26789
+
26790
+ // The range readers a <List.Items> on screen reads through, each with the number
26791
+ // of lists reading it: the reads of the page a route action does not cover.
26792
+ const shownRangeReadersSignal = signal(new Map());
26793
+ const showRangeReader = (reader) => {
26794
+ const shownMap = new Map(shownRangeReadersSignal.peek());
26795
+ shownMap.set(reader, (shownMap.get(reader) || 0) + 1);
26796
+ shownRangeReadersSignal.value = shownMap;
26797
+ return () => {
26798
+ const shownMapNow = new Map(shownRangeReadersSignal.peek());
26799
+ const count = shownMapNow.get(reader);
26800
+ if (count === 1) {
26801
+ shownMapNow.delete(reader);
26802
+ } else {
26803
+ shownMapNow.set(reader, count - 1);
26804
+ }
26805
+ shownRangeReadersSignal.value = shownMapNow;
26806
+ };
26807
+ };
26808
+
26809
+ // A row already in the store is the fresher one: the kept copy only stands in
26810
+ // for rows nothing has answered yet. Its relations are written as they were
26811
+ // kept, which a real answer corrects like any other.
26812
+ const upsertKeptItem = (store, keptItem) => {
26813
+ const id = keptItem[store.idKey];
26814
+ if (id !== undefined) {
26815
+ const existing = store.select(id);
26816
+ if (existing) {
26817
+ return existing;
26818
+ }
26819
+ }
26820
+ return store.upsert(keptItem);
26821
+ };
26822
+
26823
+ const getKeptReadKey = (name, params) => {
26824
+ return `${name}|${getParamsKey(params)}`;
26825
+ };
26826
+ // `true` is what a route action without params is bound to (see routeAction),
26827
+ // and a key holding undefined says nothing a missing one does not.
26828
+ const getParamsKey = (params) => {
26829
+ if (params === undefined || params === NO_PARAMS || params === true) {
26830
+ return "";
26831
+ }
26832
+ if (isPlainParams(params)) {
26833
+ const keys = Object.keys(params).filter((key) => params[key] !== undefined);
26834
+ if (keys.length === 0) {
26835
+ return "";
26836
+ }
26837
+ }
26838
+ return stableStringify(params);
26839
+ };
26840
+ const isPlainParams = (value) => {
26841
+ return value !== null && typeof value === "object" && !Array.isArray(value);
26842
+ };
26843
+ const stableStringify = (value) => {
26844
+ if (value === null || typeof value !== "object") {
26845
+ return JSON.stringify(value);
26846
+ }
26847
+ if (Array.isArray(value)) {
26848
+ return `[${value.map(stableStringify).join(",")}]`;
26849
+ }
26850
+ const keys = Object.keys(value)
26851
+ .filter((key) => value[key] !== undefined)
26852
+ .sort();
26853
+ const parts = [];
26854
+ for (const key of keys) {
26855
+ parts.push(`${JSON.stringify(key)}:${stableStringify(value[key])}`);
26856
+ }
26857
+ return `{${parts.join(",")}}`;
26858
+ };
26859
+
26860
+ // What a read's data is made of, ready to be written: a row, or the rows of a
26861
+ // list, each with its relations inline.
26862
+ const serializeData = (data) => {
26863
+ if (Array.isArray(data)) {
26864
+ return data.map((item) => serializeItem(item, new Set()));
26865
+ }
26866
+ return serializeItem(data, new Set());
26867
+ };
26868
+ // The item with its relations inline: a relation value stands for what its
26869
+ // signal holds (the child row, the child rows, or nothing), a row reached
26870
+ // twice on the same path is written as its id — the setters accept both.
26871
+ const serializeItem = (item, ancestorSet) => {
26872
+ ancestorSet.add(item);
26873
+ const serialized = {};
26874
+ for (const key of Object.keys(item)) {
26875
+ serialized[key] = serializeValue(item[key], ancestorSet);
26876
+ }
26877
+ ancestorSet.delete(item);
26878
+ return serialized;
26879
+ };
26880
+ const serializeValue = (value, ancestorSet) => {
26881
+ if (value === null || typeof value !== "object") {
26882
+ return value;
26883
+ }
26884
+ if (Array.isArray(value)) {
26885
+ return value.map((entry) => serializeValue(entry, ancestorSet));
26886
+ }
26887
+ const objectSignal = value[SYMBOL_OBJECT_SIGNAL];
26888
+ if (objectSignal) {
26889
+ const held = objectSignal.value;
26890
+ if (held === undefined || held === null) {
26891
+ return null;
26892
+ }
26893
+ return serializeValue(held, ancestorSet);
26894
+ }
26895
+ if (typeof value.toJSON === "function") {
26896
+ return value;
26897
+ }
26898
+ if (ancestorSet.has(value)) {
26899
+ return Object.hasOwn(value, SYMBOL_IDENTITY)
26900
+ ? value[SYMBOL_IDENTITY]
26901
+ : null;
26902
+ }
26903
+ return serializeItem(value, ancestorSet);
26904
+ };
26905
+
26728
26906
  const paramScopeWeakSet = createIterableWeakSet();
26729
26907
  let paramScopeIdCounter = 0;
26730
26908
  const getParamScope = (params) => {
@@ -26895,66 +27073,6 @@ const createResourcePersistence = (
26895
27073
  };
26896
27074
  };
26897
27075
 
26898
- const getParamsKey = (params) => {
26899
- if (params === undefined || params === NO_PARAMS) {
26900
- return "";
26901
- }
26902
- return stableStringify(params);
26903
- };
26904
-
26905
- const stableStringify = (value) => {
26906
- if (value === null || typeof value !== "object") {
26907
- return JSON.stringify(value);
26908
- }
26909
- if (Array.isArray(value)) {
26910
- return `[${value.map(stableStringify).join(",")}]`;
26911
- }
26912
- const keys = Object.keys(value).sort();
26913
- const parts = [];
26914
- for (const key of keys) {
26915
- parts.push(`${JSON.stringify(key)}:${stableStringify(value[key])}`);
26916
- }
26917
- return `{${parts.join(",")}}`;
26918
- };
26919
-
26920
- // The item with its relations inline: a relation value stands for what its
26921
- // signal holds (the child row, the child rows, or nothing), a row reached
26922
- // twice on the same path is written as its id — the setters accept both.
26923
- const serializeItem = (item, ancestorSet) => {
26924
- ancestorSet.add(item);
26925
- const serialized = {};
26926
- for (const key of Object.keys(item)) {
26927
- serialized[key] = serializeValue(item[key], ancestorSet);
26928
- }
26929
- ancestorSet.delete(item);
26930
- return serialized;
26931
- };
26932
- const serializeValue = (value, ancestorSet) => {
26933
- if (value === null || typeof value !== "object") {
26934
- return value;
26935
- }
26936
- if (Array.isArray(value)) {
26937
- return value.map((entry) => serializeValue(entry, ancestorSet));
26938
- }
26939
- const objectSignal = value[SYMBOL_OBJECT_SIGNAL];
26940
- if (objectSignal) {
26941
- const held = objectSignal.value;
26942
- if (held === undefined || held === null) {
26943
- return null;
26944
- }
26945
- return serializeValue(held, ancestorSet);
26946
- }
26947
- if (typeof value.toJSON === "function") {
26948
- return value;
26949
- }
26950
- if (ancestorSet.has(value)) {
26951
- return Object.hasOwn(value, SYMBOL_IDENTITY)
26952
- ? value[SYMBOL_IDENTITY]
26953
- : null;
26954
- }
26955
- return serializeItem(value, ancestorSet);
26956
- };
26957
-
26958
27076
  /*
26959
27077
  * GET_RANGE: reading a resource one slice at a time, for a list that draws its
26960
27078
  * rows as it goes (`<List.Items itemsAction>`).
@@ -26991,9 +27109,23 @@ const serializeValue = (value, ancestorSet) => {
26991
27109
  * of what a rerun is for an action. Every reader made by `bindParams` shares
26992
27110
  * the signal and the compositions of the one it comes from: the params say
26993
27111
  * which slices are read, not which collection.
27112
+ *
27113
+ * A composition also outlives the document when a list on the page on screen
27114
+ * reads it (see keepPageOnScreen): the ranks around the window it draws are
27115
+ * written with their rows, and the first read of the next document finds them
27116
+ * as a composition left by a list that went away — the same return, after a
27117
+ * longer absence.
26994
27118
  */
26995
27119
 
26996
27120
 
27121
+ // The ranks kept of a composition for the next document: the window a list
27122
+ // draws, widened by at least one step on each side, and snapped to steps so a
27123
+ // scroll within one draws the same ranks and writes nothing.
27124
+ const KEPT_RANK_STEP = 20;
27125
+ // Counts the answers every composition took, so that one landing can be told
27126
+ // from one seen before (see readKept).
27127
+ let answerSequence = 0;
27128
+
26997
27129
  const createRangeReader = (
26998
27130
  actionName,
26999
27131
  callback,
@@ -27002,6 +27134,9 @@ const createRangeReader = (
27002
27134
  params: boundParams,
27003
27135
  invalidationSignal = signal(0),
27004
27136
  compositionSet = new Set(),
27137
+ // Bumped whenever what is kept of a composition for the next document
27138
+ // changes (see readKept); shared like the compositions themselves.
27139
+ keptVersionSignal = signal(0),
27005
27140
  },
27006
27141
  ) => {
27007
27142
  // Which composition this reader is about: the values its params hold, not its
@@ -27018,6 +27153,40 @@ const createRangeReader = (
27018
27153
  }
27019
27154
  return null;
27020
27155
  };
27156
+ const keptKeyOf = (params) => getKeptReadKey(actionName, params);
27157
+ const bumpKeptVersion = () => {
27158
+ keptVersionSignal.value = keptVersionSignal.peek() + 1;
27159
+ };
27160
+ // The composition the previous document kept for these params, entering the
27161
+ // store and the compositions as if a list had just left it.
27162
+ const restoreKeptComposition = (params) => {
27163
+ const kept = takeKeptRead(keptKeyOf(params));
27164
+ if (!kept || typeof kept.count !== "number" || !kept.items) {
27165
+ return null;
27166
+ }
27167
+ const composition = {
27168
+ params,
27169
+ idByIndex: new Map(),
27170
+ count: kept.count,
27171
+ keptFrom: undefined,
27172
+ keptTo: undefined,
27173
+ answeredAt: 0,
27174
+ };
27175
+ for (const rank of Object.keys(kept.items)) {
27176
+ const index = Number(rank);
27177
+ const item = upsertKeptItem(store, kept.items[rank]);
27178
+ composition.idByIndex.set(index, item[store.idKey]);
27179
+ if (composition.keptFrom === undefined || index < composition.keptFrom) {
27180
+ composition.keptFrom = index;
27181
+ }
27182
+ if (composition.keptTo === undefined || index + 1 > composition.keptTo) {
27183
+ composition.keptTo = index + 1;
27184
+ }
27185
+ }
27186
+ compositionSet.add(composition);
27187
+ bumpKeptVersion();
27188
+ return composition;
27189
+ };
27021
27190
  const readRange = async (range = {}) => {
27022
27191
  const { signal, ...rangeParams } = range;
27023
27192
  const paramsResolved = { ...resolveParams(boundParams), ...rangeParams };
@@ -27051,11 +27220,15 @@ const createRangeReader = (
27051
27220
  readRange.invalidate = () => {
27052
27221
  compositionSet.clear();
27053
27222
  invalidationSignal.value = invalidationSignal.peek() + 1;
27223
+ bumpKeptVersion();
27054
27224
  };
27055
27225
  // The rows of a composition, drawn from the store: a rank whose row is gone
27056
27226
  // from the store resolves to nothing and is asked for again.
27057
27227
  readRange.readComposition = () => {
27058
- const composition = findComposition(currentParams());
27228
+ const params = currentParams();
27229
+ const composition = untracked(
27230
+ () => findComposition(params) || restoreKeptComposition(params),
27231
+ );
27059
27232
  if (!composition) {
27060
27233
  return null;
27061
27234
  }
@@ -27079,18 +27252,85 @@ const createRangeReader = (
27079
27252
  const params = currentParams();
27080
27253
  let composition = findComposition(params);
27081
27254
  if (!composition) {
27082
- composition = { params, idByIndex: new Map(), count };
27255
+ composition = {
27256
+ params,
27257
+ idByIndex: new Map(),
27258
+ count,
27259
+ keptFrom: undefined,
27260
+ keptTo: undefined,
27261
+ answeredAt: 0,
27262
+ };
27083
27263
  compositionSet.add(composition);
27084
27264
  } else if (replace) {
27085
27265
  composition.idByIndex = new Map();
27086
27266
  }
27087
27267
  composition.count = count;
27268
+ answerSequence++;
27269
+ composition.answeredAt = answerSequence;
27088
27270
  for (const [index, item] of byIndex) {
27089
27271
  const id = item ? item[store.idKey] : undefined;
27090
27272
  if (id !== undefined) {
27091
27273
  composition.idByIndex.set(index, id);
27092
27274
  }
27093
27275
  }
27276
+ bumpKeptVersion();
27277
+ };
27278
+ // The ranks [windowFrom, windowTo) a list draws of this composition: what is
27279
+ // kept of it for the next document is around them (see readKept).
27280
+ readRange.frameComposition = (windowFrom, windowTo) => {
27281
+ const composition = findComposition(currentParams());
27282
+ if (!composition) {
27283
+ return;
27284
+ }
27285
+ let keptFrom =
27286
+ Math.floor((windowFrom - KEPT_RANK_STEP) / KEPT_RANK_STEP) *
27287
+ KEPT_RANK_STEP;
27288
+ if (keptFrom < 0) {
27289
+ keptFrom = 0;
27290
+ }
27291
+ const keptTo =
27292
+ Math.ceil((windowTo + KEPT_RANK_STEP) / KEPT_RANK_STEP) * KEPT_RANK_STEP;
27293
+ if (composition.keptFrom === keptFrom && composition.keptTo === keptTo) {
27294
+ return;
27295
+ }
27296
+ composition.keptFrom = keptFrom;
27297
+ composition.keptTo = keptTo;
27298
+ bumpKeptVersion();
27299
+ };
27300
+ // What a document keeps of the composition these params read, for the
27301
+ // next one (see keepPageOnScreen): the count, and the ranks around the
27302
+ // window last drawn with their rows inline. Read inside an effect, it
27303
+ // follows the composition and the rows. `answeredAt` grows with every answer
27304
+ // the composition takes (0: none in this document, restored from a kept one).
27305
+ readRange.readKept = () => {
27306
+ // eslint-disable-next-line no-unused-expressions
27307
+ keptVersionSignal.value;
27308
+ const params = resolveParams(boundParams);
27309
+ const key = keptKeyOf(params);
27310
+ const composition = findComposition(params);
27311
+ if (!composition || composition.count === undefined) {
27312
+ return { key, answeredAt: 0, readEntry: () => undefined };
27313
+ }
27314
+ const readEntry = () => {
27315
+ const items = {};
27316
+ for (const [index, id] of composition.idByIndex) {
27317
+ if (
27318
+ composition.keptFrom !== undefined &&
27319
+ index < composition.keptFrom
27320
+ ) {
27321
+ continue;
27322
+ }
27323
+ if (composition.keptTo !== undefined && index >= composition.keptTo) {
27324
+ continue;
27325
+ }
27326
+ const item = store.select(id);
27327
+ if (item) {
27328
+ items[index] = serializeItem(item, new Set());
27329
+ }
27330
+ }
27331
+ return { count: composition.count, items };
27332
+ };
27333
+ return { key, answeredAt: composition.answeredAt, readEntry };
27094
27334
  };
27095
27335
  // The same trade a list makes with the rows it holds, applied to what is kept
27096
27336
  // for the next mount. Two lists on one collection each trim by their own
@@ -27101,11 +27341,16 @@ const createRangeReader = (
27101
27341
  if (!composition || composition.idByIndex.size <= budget) {
27102
27342
  return;
27103
27343
  }
27344
+ let trimmed = false;
27104
27345
  for (const index of composition.idByIndex.keys()) {
27105
27346
  if (index < keepFrom || index > keepTo) {
27106
27347
  composition.idByIndex.delete(index);
27348
+ trimmed = true;
27107
27349
  }
27108
27350
  }
27351
+ if (trimmed) {
27352
+ bumpKeptVersion();
27353
+ }
27109
27354
  };
27110
27355
  // Memoized for the reasons an action's bindParams is (see actions.js): params
27111
27356
  // and the reader they make have synchronized lifetimes, and params equal in
@@ -27121,6 +27366,7 @@ const createRangeReader = (
27121
27366
  params: boundParams ? { ...boundParams, ...paramsToBind } : paramsToBind,
27122
27367
  invalidationSignal,
27123
27368
  compositionSet,
27369
+ keptVersionSignal,
27124
27370
  });
27125
27371
  readerByParams.set(paramsToBind, reader);
27126
27372
  return reader;
@@ -28555,7 +28801,7 @@ const createRestActionFactoryForRoot = (
28555
28801
  `${name}.${verb}`,
28556
28802
  declarationSite,
28557
28803
  );
28558
- return createAction(callback, {
28804
+ const action = createAction(callback, {
28559
28805
  name: `${name}.${verb}`,
28560
28806
  meta: { verb, isMany: false, paramScope },
28561
28807
  resultToValue: (result, action) => {
@@ -28591,17 +28837,33 @@ const createRestActionFactoryForRoot = (
28591
28837
  },
28592
28838
  valueToData: (itemId) => store.select(itemId),
28593
28839
  // While the request is out, the row the store already holds for these
28594
- // params is drawn: the action starts on the "refresh over a known
28840
+ // params is drawn, or the row the page kept from the previous document
28841
+ // (kept_reads.js): the action starts on the "refresh over a known
28595
28842
  // answer" line of data_states.md rather than the first-load one.
28596
28843
  provisionalValue:
28597
28844
  verb === "GET"
28598
- ? (params) => {
28599
- const item = untracked(() => findItemForGet(params));
28845
+ ? (params, action) => {
28846
+ const item = untracked(() => {
28847
+ const itemForGet = findItemForGet(params);
28848
+ if (itemForGet) {
28849
+ return itemForGet;
28850
+ }
28851
+ const keptItem = takeKeptRead(
28852
+ getKeptReadKey(action.name, params),
28853
+ );
28854
+ return keptItem ? upsertKeptItem(store, keptItem) : null;
28855
+ });
28600
28856
  return item ? item[idKey] : undefined;
28601
28857
  }
28602
28858
  : undefined,
28859
+ // Another params' answer is another row.
28860
+ inheritData: verb !== "GET",
28603
28861
  completeSideEffect: onActionComplete,
28604
28862
  });
28863
+ if (verb === "GET") {
28864
+ markKeepableRead(action);
28865
+ }
28866
+ return action;
28605
28867
  };
28606
28868
  const createActionAffectingManyItems = (
28607
28869
  verb,
@@ -28616,7 +28878,7 @@ const createRestActionFactoryForRoot = (
28616
28878
  return itemArray.map((item) => item[idKey]);
28617
28879
  };
28618
28880
 
28619
- return createAction(callback, {
28881
+ const action = createAction(callback, {
28620
28882
  meta: { verb, isMany: true, paramScope },
28621
28883
  name: `${name}.${verb}_MANY`,
28622
28884
  resultToValue: applyResultToValue,
@@ -28624,6 +28886,25 @@ const createRestActionFactoryForRoot = (
28624
28886
  // undefined so a screen can tell "not asked yet" from "answered: none".
28625
28887
  valueToData: (idArray) =>
28626
28888
  idArray === undefined ? undefined : store.selectAll(idArray),
28889
+ // The rows the page kept from the previous document (kept_reads.js),
28890
+ // drawn while the request is out. A binding moving to new params still
28891
+ // hands over the previous answer when there are none: a list standing
28892
+ // in for the next one beats a skeleton.
28893
+ provisionalValue:
28894
+ verb === "GET"
28895
+ ? (params, action) =>
28896
+ untracked(() => {
28897
+ const keptItems = takeKeptRead(
28898
+ getKeptReadKey(action.name, params),
28899
+ );
28900
+ if (!Array.isArray(keptItems)) {
28901
+ return undefined;
28902
+ }
28903
+ return keptItems.map(
28904
+ (keptItem) => upsertKeptItem(store, keptItem)[idKey],
28905
+ );
28906
+ })
28907
+ : undefined,
28627
28908
  completeSideEffect: (actionCompleted) => {
28628
28909
  onActionComplete(actionCompleted);
28629
28910
  if (
@@ -28639,6 +28920,10 @@ const createRestActionFactoryForRoot = (
28639
28920
  return syncIdArrayOnRename(store, idKey, actionCompleted.valueSignal);
28640
28921
  },
28641
28922
  });
28923
+ if (verb === "GET") {
28924
+ markKeepableRead(action);
28925
+ }
28926
+ return action;
28642
28927
  };
28643
28928
 
28644
28929
  return createActionForRoot;
@@ -28649,10 +28934,12 @@ const createRestActionFactoryForRoot = (
28649
28934
  // NetworkPolicyError carrying the policy's reason. A read is called or not depending
28650
28935
  // on where the policy answers reads from. Answered from the store, a GET of a
28651
28936
  // root resource completes with the row the store holds for it (see
28652
- // findItemInStore) — handing the item back is an upsert without effect, so the
28653
- // action completes with what it had and nothing is asked; a relationship GET
28654
- // has no row of its own to answer with and settles with the same error. A
28655
- // completed GET asked to rerun never gets here (actions.js holds it).
28937
+ // findItemInStore), and a root GET or GET_MANY holding rows for these params
28938
+ // (the kept page, drawn while the request is out) with those — handing them
28939
+ // back is an upsert without effect, so the action completes with what it had
28940
+ // and nothing is asked. A relationship GET has no row of its own to answer
28941
+ // with and settles with the same error. A completed GET asked to rerun never
28942
+ // gets here (actions.js holds it).
28656
28943
  const applyNetworkPolicy = (
28657
28944
  restCallback,
28658
28945
  { verb, isMany, findItemInStore },
@@ -28666,6 +28953,12 @@ const applyNetworkPolicy = (
28666
28953
  if (!policy.readsFromStore) {
28667
28954
  return restCallback(params, context);
28668
28955
  }
28956
+ if (isKeepableRead(context.action)) {
28957
+ const dataHeld = context.action.dataSignal.peek();
28958
+ if (dataHeld !== undefined) {
28959
+ return dataHeld;
28960
+ }
28961
+ }
28669
28962
  if (!isMany && findItemInStore) {
28670
28963
  const item = findItemInStore(params, context.action);
28671
28964
  if (item) {
@@ -38915,6 +39208,37 @@ const writeCover = (edge, element, size) => {
38915
39208
  style.setProperty(property, value);
38916
39209
  };
38917
39210
 
39211
+ // Every route action, with whether it asks something of the address at the
39212
+ // moment: its route matches and its params getter returns something.
39213
+ const routeActionEntriesSignal = signal([]);
39214
+ let activeRouteActionsPrevious = new Set();
39215
+ /**
39216
+ * The route actions asking something for the page on screen: their route
39217
+ * matches and their params getter returns something — the running ones, not
39218
+ * every matching one, so an action bound to every page that asks only when a
39219
+ * popup is open over it counts while it is open. Read to rerun what the page
39220
+ * reads (the app back from the background), and by `keepPageOnScreen`.
39221
+ * The set is handed out again only when its members change.
39222
+ *
39223
+ * A page's code is declared as a route action too; `action.meta.verb` says
39224
+ * which ones read data.
39225
+ *
39226
+ * @type {import("@preact/signals").ReadonlySignal<Set<object>>}
39227
+ */
39228
+ const activeRouteActionsSignal = computed(() => {
39229
+ const active = new Set();
39230
+ for (const { routeAction, askingSignal } of routeActionEntriesSignal.value) {
39231
+ if (askingSignal.value) {
39232
+ active.add(routeAction);
39233
+ }
39234
+ }
39235
+ if (haveSameMembers(active, activeRouteActionsPrevious)) {
39236
+ return activeRouteActionsPrevious;
39237
+ }
39238
+ activeRouteActionsPrevious = active;
39239
+ return active;
39240
+ });
39241
+
38918
39242
  /**
38919
39243
  * Binds an action to a route: it runs when the route matches, with the params
38920
39244
  * the effect reads off the address, runs again on a `reload()`, and is aborted
@@ -38972,6 +39296,13 @@ const routeAction = (
38972
39296
  return params;
38973
39297
  };
38974
39298
  const actionBoundToRoute = actionRunEffect(action, readParamsAsked, options);
39299
+ routeActionEntriesSignal.value = [
39300
+ ...routeActionEntriesSignal.peek(),
39301
+ {
39302
+ routeAction: actionBoundToRoute,
39303
+ askingSignal: computed(() => Boolean(readParamsAsked())),
39304
+ },
39305
+ ];
38975
39306
  for (const route of routes) {
38976
39307
  // Asked of the route's params as they are, like the effect asks them.
38977
39308
  registerRouteReload(route, () =>
@@ -39055,6 +39386,343 @@ const anyMatchingRouteSignal = (routes) => {
39055
39386
  return anyMatchingSignal;
39056
39387
  };
39057
39388
 
39389
+ const haveSameMembers = (setA, setB) => {
39390
+ if (setA.size !== setB.size) {
39391
+ return false;
39392
+ }
39393
+ for (const value of setA) {
39394
+ if (!setB.has(value)) {
39395
+ return false;
39396
+ }
39397
+ }
39398
+ return true;
39399
+ };
39400
+
39401
+ /*
39402
+ * The page on screen, kept for the next document: what its reads answered,
39403
+ * written into a signal the app hands over, and drawn again by the same reads
39404
+ * in a document opening on the same page (a reload, a tab the system
39405
+ * discarded). The read side is state/rest/kept_reads.js; this is what decides
39406
+ * which reads are the page, and when the slot is written.
39407
+ *
39408
+ * The unit is the page, not the resource. One resource is read by several
39409
+ * screens — a search, someone's games, a game opened from anywhere — and kept
39410
+ * per resource it would keep every row ever opened. The page's reads are what
39411
+ * navi already runs for it: the route actions asking something for the page
39412
+ * (activeRouteActionsSignal) and the compositions the lists on screen read
39413
+ * (shownRangeReadersSignal). A search typed on the page is neither, and is not
39414
+ * kept.
39415
+ *
39416
+ * A read is written once it has answered in this document. Before that it
39417
+ * keeps what the previous document kept for it, never what it shows meanwhile:
39418
+ * an answer handed over from other params (a list standing in for the next
39419
+ * one) says nothing about these.
39420
+ *
39421
+ * The write waits for the page to settle and is flushed when the document is
39422
+ * hidden. A navigation goes through pages half there — the route matched, its
39423
+ * list not mounted yet, the page left still drawn under a transition — and
39424
+ * each would otherwise be written out in full. Hidden is the moment the system
39425
+ * may discard the document, so nothing is lost by waiting until then.
39426
+ *
39427
+ * The signal turning `undefined` from outside (sign-out) forgets the page, and
39428
+ * nothing is written until a read lands again: written at once, the page still
39429
+ * on screen would put back the account just left.
39430
+ */
39431
+
39432
+ const WRITE_DELAY = 1000;
39433
+ const NOTHING_PENDING = {};
39434
+ let stopKeepingCurrent = null;
39435
+
39436
+ /**
39437
+ * Keeps what the page on screen reads, so that a new document opening on it —
39438
+ * a reload, a pull-to-refresh, a tab the system discarded in the background —
39439
+ * draws it again while its reads go out, rather than skeletons.
39440
+ *
39441
+ * What the page reads is what navi runs for it: the route actions asking
39442
+ * something for it (see `activeRouteActionsSignal`) whose action is a resource
39443
+ * `GET` or `GET_MANY`, and the `GET_RANGE` compositions the `<List.Items>` on
39444
+ * screen read, around the window they draw. A navigation replaces the slot with
39445
+ * the next page's reads, and the page left is dropped. In the next document,
39446
+ * the first run of the same read with the same params draws the kept answer as
39447
+ * its provisional value: `data` set while `loading` is `true`, the answer
39448
+ * replacing it. Under a network policy answering reads from the store, the
39449
+ * kept answer answers the read. The copy follows the store: a `PUT` on a row
39450
+ * rewrites it.
39451
+ *
39452
+ * Call it before the routes start: the first runs are the ones that look.
39453
+ *
39454
+ * @param {object} options
39455
+ * @param {import("@preact/signals").Signal} options.signal - where the slot is
39456
+ * written: a `stateSignal` with `persists: true` and `type: "object"`, its
39457
+ * `id` holding whatever makes a kept page unusable (the deployed version).
39458
+ * Writing `undefined` into it (sign-out) forgets the page, and nothing is
39459
+ * written again until a read lands.
39460
+ * @param {() => boolean} [options.when] - read whenever the slot is about to be
39461
+ * read or written: `false` reads nothing, writes nothing, and empties the
39462
+ * signal (a tab viewing as someone else). A function, so it can read a signal.
39463
+ * @param {object[]} [options.always] - routes whose reads stay kept once the
39464
+ * page is left, until it is visited again: the page an app opens on when it
39465
+ * is launched rather than reloaded.
39466
+ * @returns {() => void} stops keeping the page.
39467
+ * @see docs/resource.md — what is kept and what is not, what stays the app's
39468
+ */
39469
+ const keepPageOnScreen = ({
39470
+ signal: keptSignal,
39471
+ when,
39472
+ always = [],
39473
+ } = {}) => {
39474
+ if (!isSignal(keptSignal)) {
39475
+ throw new TypeError(
39476
+ `keepPageOnScreen() needs a signal to write the page into, received ${keptSignal}`,
39477
+ );
39478
+ }
39479
+ if (stopKeepingCurrent) {
39480
+ stopKeepingCurrent();
39481
+ }
39482
+ const isAllowed = () => {
39483
+ return when ? Boolean(when()) : true;
39484
+ };
39485
+ const alwaysMatchingSignal = anyMatchingRouteSignal(always);
39486
+
39487
+ // What the signal held when it was read: the previous document's page.
39488
+ let loadedEntryMap = new Map();
39489
+ let loadedAlwaysKeys = [];
39490
+ // Which reads answered in this document (since the last forget): only
39491
+ // those write what they show.
39492
+ let answeredActionWeakSet = new WeakSet();
39493
+ let answeredRangeKeySet = new Set();
39494
+ // The state each action was last seen in, and the last answer seen of each
39495
+ // range reader: a read lands when they move, and not because the slot looks
39496
+ // again at something that had answered before a forget.
39497
+ const stateSeenWeakMap = new WeakMap();
39498
+ const answeredAtSeenWeakMap = new WeakMap();
39499
+ // Forgotten: nothing is written until a read lands.
39500
+ let paused = false;
39501
+ // The reads of the always-kept page, as they were the last time it was on
39502
+ // screen in this document; null until then.
39503
+ let alwaysReads = null;
39504
+
39505
+ const forget = () => {
39506
+ loadedEntryMap = new Map();
39507
+ loadedAlwaysKeys = [];
39508
+ answeredActionWeakSet = new WeakSet();
39509
+ answeredRangeKeySet = new Set();
39510
+ alwaysReads = null;
39511
+ paused = true;
39512
+ withdrawKeptReads();
39513
+ };
39514
+
39515
+ let lastWritten;
39516
+ let pendingWrite = NOTHING_PENDING;
39517
+ let writeTimeout;
39518
+ const flush = () => {
39519
+ clearTimeout(writeTimeout);
39520
+ if (pendingWrite === NOTHING_PENDING) {
39521
+ return;
39522
+ }
39523
+ const value = pendingWrite;
39524
+ pendingWrite = NOTHING_PENDING;
39525
+ if (compareTwoJsValues(value, lastWritten)) {
39526
+ return;
39527
+ }
39528
+ lastWritten = value;
39529
+ keptSignal.value = value;
39530
+ };
39531
+ const scheduleWrite = (value) => {
39532
+ pendingWrite = value;
39533
+ clearTimeout(writeTimeout);
39534
+ writeTimeout = setTimeout(flush, WRITE_DELAY);
39535
+ };
39536
+ const onVisibilityChange = () => {
39537
+ if (document.visibilityState === "hidden") {
39538
+ flush();
39539
+ }
39540
+ };
39541
+ document.addEventListener("visibilitychange", onVisibilityChange);
39542
+ window.addEventListener("pagehide", flush);
39543
+
39544
+ // Runs at once with what the signal already holds (the slot read from
39545
+ // storage), then on every write — the slot's own ones are recognized and
39546
+ // left alone.
39547
+ const unsubscribe = keptSignal.subscribe((value) => {
39548
+ if (value === lastWritten) {
39549
+ return;
39550
+ }
39551
+ lastWritten = value;
39552
+ pendingWrite = NOTHING_PENDING;
39553
+ clearTimeout(writeTimeout);
39554
+ if (
39555
+ value === undefined ||
39556
+ value === null ||
39557
+ typeof value !== "object" ||
39558
+ !isAllowed()
39559
+ ) {
39560
+ forget();
39561
+ return;
39562
+ }
39563
+ const entries =
39564
+ value.entries && typeof value.entries === "object" ? value.entries : {};
39565
+ loadedEntryMap = new Map(Object.entries(entries));
39566
+ loadedAlwaysKeys = Array.isArray(value.always)
39567
+ ? value.always.filter((key) => loadedEntryMap.has(key))
39568
+ : [];
39569
+ alwaysReads = null;
39570
+ paused = false;
39571
+ offerKeptReads(loadedEntryMap);
39572
+ });
39573
+
39574
+ const actionReadWeakMap = new WeakMap();
39575
+ const actionReadOf = (action) => {
39576
+ let read = actionReadWeakMap.get(action);
39577
+ if (read) {
39578
+ return read;
39579
+ }
39580
+ read = () => {
39581
+ const state = action.runningStateSignal.value;
39582
+ const stateSeen = stateSeenWeakMap.get(action);
39583
+ stateSeenWeakMap.set(action, state);
39584
+ const landed = state === COMPLETED && stateSeen !== COMPLETED;
39585
+ if (landed) {
39586
+ answeredActionWeakSet.add(action);
39587
+ }
39588
+ return {
39589
+ key: getKeptReadKey(action.name, action.params),
39590
+ answered: answeredActionWeakSet.has(action),
39591
+ landed,
39592
+ readEntry: () => {
39593
+ const data = action.dataSignal.value;
39594
+ if (data === undefined || data === null) {
39595
+ return undefined;
39596
+ }
39597
+ return serializeData(data);
39598
+ },
39599
+ };
39600
+ };
39601
+ actionReadWeakMap.set(action, read);
39602
+ return read;
39603
+ };
39604
+ const rangeReadWeakMap = new WeakMap();
39605
+ const rangeReadOf = (reader) => {
39606
+ let read = rangeReadWeakMap.get(reader);
39607
+ if (read) {
39608
+ return read;
39609
+ }
39610
+ read = () => {
39611
+ const { key, answeredAt, readEntry } = reader.readKept();
39612
+ const answeredAtSeen = answeredAtSeenWeakMap.get(reader) || 0;
39613
+ answeredAtSeenWeakMap.set(reader, answeredAt);
39614
+ const landed = answeredAt > answeredAtSeen;
39615
+ if (landed) {
39616
+ answeredRangeKeySet.add(key);
39617
+ }
39618
+ return {
39619
+ key,
39620
+ answered: answeredRangeKeySet.has(key),
39621
+ landed,
39622
+ readEntry,
39623
+ };
39624
+ };
39625
+ rangeReadWeakMap.set(reader, read);
39626
+ return read;
39627
+ };
39628
+ const readsOnScreen = () => {
39629
+ const reads = [];
39630
+ for (const routeAction of activeRouteActionsSignal.value) {
39631
+ // The instance is what is kept, so that a page left keeps what it read.
39632
+ // The binding's callSource names that instance and is rewritten once the
39633
+ // binding has moved to another one: read, it brings the slot along.
39634
+ // eslint-disable-next-line no-unused-expressions
39635
+ routeAction.callSource;
39636
+ const action = getActionPrivateProperties(routeAction).currentAction;
39637
+ if (action.params === NO_PARAMS || !isKeepableRead(action)) {
39638
+ continue;
39639
+ }
39640
+ reads.push(actionReadOf(action));
39641
+ }
39642
+ for (const reader of shownRangeReadersSignal.value.keys()) {
39643
+ reads.push(rangeReadOf(reader));
39644
+ }
39645
+ return reads;
39646
+ };
39647
+
39648
+ const stopEffect = effect(() => {
39649
+ const allowed = isAllowed();
39650
+ const routed = routedSignal.value;
39651
+ const alwaysMatching = alwaysMatchingSignal.value;
39652
+ if (!allowed) {
39653
+ forget();
39654
+ pendingWrite = undefined;
39655
+ flush();
39656
+ return;
39657
+ }
39658
+ if (!routed) {
39659
+ // No page yet: the slot is what the first reads will draw.
39660
+ return;
39661
+ }
39662
+ const screenReads = readsOnScreen();
39663
+ if (alwaysMatching) {
39664
+ alwaysReads = screenReads;
39665
+ }
39666
+ const entries = {};
39667
+ let landed = false;
39668
+ const collect = (reads) => {
39669
+ const keys = [];
39670
+ for (const read of reads) {
39671
+ const result = read();
39672
+ if (result.landed) {
39673
+ landed = true;
39674
+ }
39675
+ const entry = result.answered
39676
+ ? result.readEntry()
39677
+ : loadedEntryMap.get(result.key);
39678
+ if (entry === undefined) {
39679
+ continue;
39680
+ }
39681
+ entries[result.key] = entry;
39682
+ keys.push(result.key);
39683
+ }
39684
+ return keys;
39685
+ };
39686
+ const screenKeys = collect(screenReads);
39687
+ let alwaysKeys;
39688
+ if (alwaysReads === screenReads) {
39689
+ alwaysKeys = screenKeys;
39690
+ } else if (alwaysReads) {
39691
+ alwaysKeys = collect(alwaysReads);
39692
+ } else {
39693
+ alwaysKeys = loadedAlwaysKeys;
39694
+ for (const key of alwaysKeys) {
39695
+ entries[key] = loadedEntryMap.get(key);
39696
+ }
39697
+ }
39698
+ if (paused) {
39699
+ if (!landed) {
39700
+ return;
39701
+ }
39702
+ paused = false;
39703
+ }
39704
+ if (screenKeys.length === 0 && alwaysKeys.length === 0) {
39705
+ scheduleWrite(undefined);
39706
+ return;
39707
+ }
39708
+ scheduleWrite({ entries, screen: screenKeys, always: alwaysKeys });
39709
+ });
39710
+
39711
+ const stop = () => {
39712
+ flush();
39713
+ stopEffect();
39714
+ unsubscribe();
39715
+ document.removeEventListener("visibilitychange", onVisibilityChange);
39716
+ window.removeEventListener("pagehide", flush);
39717
+ withdrawKeptReads();
39718
+ if (stopKeepingCurrent === stop) {
39719
+ stopKeepingCurrent = null;
39720
+ }
39721
+ };
39722
+ stopKeepingCurrent = stop;
39723
+ return stop;
39724
+ };
39725
+
39058
39726
  /**
39059
39727
  * A popup that is the anchor, brought to the front.
39060
39728
  *
@@ -66417,7 +67085,7 @@ const css$E = /* css */`
66417
67085
  /* A plain div, unlike dialogEl itself (a real <dialog>, natively hidden
66418
67086
  by default until .show()/.showModal() adds [open]) — needs its own
66419
67087
  starting-hidden mechanism. [navi-hidden] is set from useDialogProps'
66420
- own backdropProps (recomputed from openController.opened on every
67088
+ own backdropProps (recomputed from openController.openedInDom on every
66421
67089
  render, present from the very first one), then toggled by plain
66422
67090
  removeAttribute/setAttribute in openEffect/close, never an explicit
66423
67091
  display override — removing the attribute just lets this rule stop
@@ -68077,7 +68745,7 @@ const useDialogProps = props => {
68077
68745
  "navi-out-of-flow": "",
68078
68746
  "baseClassName": "navi_dialog_backdrop",
68079
68747
  "aria-hidden": "true",
68080
- // Recomputed fresh on every render from openController.opened (not
68748
+ // Recomputed fresh on every render from openController.openedInDom (not
68081
68749
  // driven through a mount-time layout effect, unlike this file's own
68082
68750
  // imperative open/close toggling below) — present in the DOM
68083
68751
  // synchronously from the very first commit, matching this file's own
@@ -68087,7 +68755,7 @@ const useDialogProps = props => {
68087
68755
  // needs — see popover.jsx's own identical prop for the full reasoning.
68088
68756
  "aria-expanded": openController.openedInDom ? "true" : "false",
68089
68757
  // Present from this very first render (recomputed fresh on every one
68090
- // from openController.opened, not a frozen mount-time constant) so
68758
+ // from openController.openedInDom, not a frozen mount-time constant) so
68091
68759
  // there's no gap for the browser to ever paint this plain-div backdrop
68092
68760
  // visible before anything has actually opened it — see popover.jsx's
68093
68761
  // own identical prop for the full reasoning, and this file's own CSS
@@ -68107,7 +68775,7 @@ const useDialogProps = props => {
68107
68775
  // (kept once, not repeated here).
68108
68776
  "aria-expanded": openController.openedInDom ? "true" : "false",
68109
68777
  // Present from the very first render (recomputed fresh from
68110
- // openController.opened every time, not a frozen mount-time constant —
68778
+ // openController.openedInDom every time, not a frozen mount-time constant —
68111
68779
  // see popover.jsx's own identical prop for the full reasoning) so a
68112
68780
  // consumer whose own CSS also sets display (e.g. Popup's flex prop)
68113
68781
  // can't silently defeat showModal()/close()'s native open/close — see
@@ -68510,7 +69178,7 @@ const css$D = /* css */`
68510
69178
  without this it would flash visible for one frame on mount, before
68511
69179
  openEffect's own JS ever gets a chance to hide it. [navi-hidden] is
68512
69180
  set from usePopoverProps' own contentProps (recomputed from
68513
- openController.opened on every render, present from the very first
69181
+ openController.openedInDom on every render, present from the very first
68514
69182
  one — see there for why), then toggled by plain
68515
69183
  removeAttribute/setAttribute in openEffect/close, never an explicit
68516
69184
  display override: removing the attribute just lets this rule stop
@@ -69875,7 +70543,7 @@ const usePopoverProps = props => {
69875
70543
  "navi-out-of-flow": "",
69876
70544
  "baseClassName": "navi_popover_backdrop",
69877
70545
  "aria-hidden": "true",
69878
- // Recomputed fresh on every render from openController.opened (not a
70546
+ // Recomputed fresh on every render from openController.openedInDom (not a
69879
70547
  // frozen mount-time constant, same pattern as navi-hidden just below)
69880
70548
  // rather than driven through a mount-time layout effect — its own
69881
70549
  // actual open/close toggling is still done entirely imperatively
@@ -69950,7 +70618,7 @@ const usePopoverProps = props => {
69950
70618
  // starting-hidden default) — present from this very first render so
69951
70619
  // there's no gap for the browser to ever paint it visible before
69952
70620
  // anything has actually opened it. Recomputed fresh on every render from
69953
- // openController.opened (not a frozen mount-time constant) — Preact
70621
+ // openController.openedInDom (not a frozen mount-time constant) — Preact
69954
70622
  // only touches the DOM for a prop whose value actually changed since
69955
70623
  // the last render, so as long as this always reflects the *current*
69956
70624
  // truth, it never fights the imperative
@@ -74390,7 +75058,6 @@ const ListUI = props => {
74390
75058
  // What the runs ask for and stand for, in items: a source paginates in items,
74391
75059
  // and asks for the first picture's items and the rest in one round trip.
74392
75060
  listItems.pageSize = PAGE_SIZE_DEFAULT;
74393
- listItems.scrolled = scrolled ?? defaultScrolled;
74394
75061
  const {
74395
75062
  virtualItemSizeSignal,
74396
75063
  renderWindow,
@@ -74769,6 +75436,45 @@ const useListScrollSync = ({
74769
75436
  columns
74770
75437
  }) => {
74771
75438
  const debugScroll = useDebugScroll();
75439
+ // Where the list must be: what the caller holds it at (`scrolled`), or where
75440
+ // it opens and then lets go (`defaultScrolled`). A `scrolled` that changes is
75441
+ // the caller moving the list, so the hold is armed again — that is what makes
75442
+ // it controlled. A `defaultScrolled` that changes is where the list opens,
75443
+ // read again from something that answered again (a count fresher than the
75444
+ // one kept from the previous visit): followed for as long as nobody has
75445
+ // moved the list, and compared by value, since it is said at every render.
75446
+ // Nothing to hold it at (a position not saved yet) is not a position: the
75447
+ // list opens where it opens.
75448
+ const startPlaceRef = useRef(null);
75449
+ if (startPlaceRef.current === null) {
75450
+ startPlaceRef.current = {
75451
+ userTookOver: false,
75452
+ wanted: scrolled ?? defaultScrolled,
75453
+ scrolled,
75454
+ defaultScrolled,
75455
+ way: null,
75456
+ arrived: false
75457
+ };
75458
+ } else {
75459
+ const place = startPlaceRef.current;
75460
+ if (place.scrolled !== scrolled) {
75461
+ place.scrolled = scrolled;
75462
+ place.defaultScrolled = defaultScrolled;
75463
+ place.wanted = scrolled ?? defaultScrolled;
75464
+ place.userTookOver = false;
75465
+ } else if ((scrolled === undefined || scrolled === null) && !compareTwoJsValues(place.defaultScrolled, defaultScrolled)) {
75466
+ place.defaultScrolled = defaultScrolled;
75467
+ if (!place.userTookOver) {
75468
+ place.wanted = defaultScrolled;
75469
+ // Placed again from where it stands: the way to the previous place
75470
+ // says nothing about whose the next scrolls are.
75471
+ place.way = null;
75472
+ place.arrived = false;
75473
+ }
75474
+ }
75475
+ }
75476
+ const scrolledWanted = startPlaceRef.current.wanted;
75477
+ listItems.scrolled = scrolledWanted;
74772
75478
  // The items drawn, [start, end) among the list's own. A ref as well as a
74773
75479
  // state: the render moves it where the list is held (holdWindow) without a
74774
75480
  // commit of its own.
@@ -74783,7 +75489,7 @@ const useListScrollSync = ({
74783
75489
  const virtualItemSizeSignal = useVirtualItemSizeSignal(ref, virtualItemSize, horizontal, {
74784
75490
  inLines: Boolean(columns),
74785
75491
  windowLeavesItemsOut,
74786
- scrolledWanted: scrolled ?? defaultScrolled,
75492
+ scrolledWanted,
74787
75493
  // The fillers hold the room of items above the screen at this size: a
74788
75494
  // size that changes moves what is on screen, like items landing above it.
74789
75495
  beforeSizeChange: () => listItems.captureAnchor()
@@ -75116,25 +75822,7 @@ const useListScrollSync = ({
75116
75822
  const newEnd = newStart + windowSize;
75117
75823
  updateRenderWindow(newStart, newEnd, `item to scroll (at ${index}) is out of render window`);
75118
75824
  };
75119
-
75120
- // Where the list must be: what the caller holds it at (`scrolled`), or where
75121
- // it opens and then lets go (`defaultScrolled`). A `scrolled` that changes is
75122
- // the caller moving the list, so the hold is armed again — that is what makes
75123
- // it controlled.
75124
- // Nothing to hold it at (a position not saved yet) is not a position: the
75125
- // list opens where it opens.
75126
- const scrolledWanted = scrolled ?? defaultScrolled;
75127
75825
  const scrolledFallback = scrolled === undefined || scrolled === null ? "start" : defaultScrolled ?? "start";
75128
- const startPlaceRef = useRef({
75129
- userTookOver: false,
75130
- wanted: scrolledWanted,
75131
- way: null,
75132
- arrived: false
75133
- });
75134
- if (startPlaceRef.current.wanted !== scrolledWanted) {
75135
- startPlaceRef.current.wanted = scrolledWanted;
75136
- startPlaceRef.current.userTookOver = false;
75137
- }
75138
75826
  // Only one thing owns the scroll at a time: while the list is holding itself
75139
75827
  // somewhere, the anchoring stays out of it (holding an item still is precisely
75140
75828
  // not being at the end anymore once what is above it shrinks).
@@ -78962,6 +79650,17 @@ const useItemStore = ({
78962
79650
  }
78963
79651
  const pages = pagesRef.current;
78964
79652
  const [, setPageVersion] = useState(0);
79653
+ // A list on screen is part of what the page reads: the composition it reads
79654
+ // is kept with the page for the next document (see keepPageOnScreen).
79655
+ useLayoutEffect(() => {
79656
+ if (inMemory || typeof itemsAction !== "function") {
79657
+ return undefined;
79658
+ }
79659
+ if (!itemsAction.isRangeReader) {
79660
+ return undefined;
79661
+ }
79662
+ return showRangeReader(itemsAction);
79663
+ }, [inMemory, itemsAction]);
78965
79664
  // The ranks whose item is from before: the run came back to the screen, or
78966
79665
  // the source said the collection moved. They stay drawn until a page confirms
78967
79666
  // or replaces them, rank by rank. A page covers what the window framed when it
@@ -79095,6 +79794,7 @@ const useItemStore = ({
79095
79794
  }
79096
79795
  }
79097
79796
  if (typeof itemsAction === "function" && itemsAction.trimComposition) {
79797
+ itemsAction.frameComposition(windowFrom, windowTo);
79098
79798
  itemsAction.trimComposition(keepFrom, keepTo, budget);
79099
79799
  }
79100
79800
  },
@@ -79664,6 +80364,9 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
79664
80364
  * room has to be restated as a number by whoever asks. The `index` and
79665
80365
  * `visibleCount` it also hands out say where to aim before the item is found,
79666
80366
  * and how many items to draw before the first paint (see `renderBudget`).
80367
+ * A value that changes before anyone has moved the list is followed (a place
80368
+ * read from a count that answered again): it is compared by value, so it can
80369
+ * be computed at every render.
79667
80370
  * @param {"start"|"end"|number|{id: string, index?: number, offset?: number, visibleCount?: number}} [props.scrolled]
79668
80371
  * The same, but held: the list goes back there every time this changes, even
79669
80372
  * after the user has scrolled — the caller owns where the list is (see
@@ -95758,5 +96461,5 @@ const UserSvg = () => jsx("svg", {
95758
96461
  })
95759
96462
  });
95760
96463
 
95761
- 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 };
96464
+ 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 };
95762
96465
  //# sourceMappingURL=jsenv_navi.js.map