@jsenv/navi 0.29.67 → 0.29.69

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.
@@ -6273,12 +6273,16 @@ const route = (pattern, { searchParams } = {}) => {
6273
6273
  const routeUrl = resolveRouteUrl(routeRelativeUrl);
6274
6274
  return routeUrl;
6275
6275
  };
6276
- route.navTo = (params) => {
6276
+ // Options travel as they do to navTo() itself — `routeTransition` is the
6277
+ // one that matters here: what this one navigation asks of a route
6278
+ // transition (see route_transition.jsx), which is the programmatic half of
6279
+ // what a <Link routeTransition> says.
6280
+ route.navTo = (params, options) => {
6277
6281
  if (!integration) {
6278
6282
  return Promise.resolve();
6279
6283
  }
6280
6284
  const routeUrl = route.buildUrl(params);
6281
- return integration.navTo(routeUrl);
6285
+ return integration.navTo(routeUrl, options);
6282
6286
  };
6283
6287
  route.redirectTo = (params, { callReason } = {}) => {
6284
6288
  if (!integration) {
@@ -9250,8 +9254,7 @@ const createCalloutManager = (
9250
9254
  callout.update(message, calloutOptions);
9251
9255
  return;
9252
9256
  }
9253
- const resolvedAnchorElement =
9254
- anchorElement || controller.ref.current;
9257
+ const resolvedAnchorElement = anchorElement || controller.ref.current;
9255
9258
  const removeCloseOnCleanup = addTeardown?.(() => {
9256
9259
  requestCloseCallout(new CustomEvent("cleanup"), "cleanup");
9257
9260
  });
@@ -14998,7 +15001,8 @@ const ONE_OF_CONSTRAINT = {
14998
15001
  );
14999
15002
  const isNoMatch = visibleOptions.length === 0;
15000
15003
  const message = field.controlHostProps["data-one-of-message"];
15001
- const noMatchMessage = field.controlHostProps["data-one-of-no-match-message"];
15004
+ const noMatchMessage =
15005
+ field.controlHostProps["data-one-of-no-match-message"];
15002
15006
  if (isNoMatch) {
15003
15007
  return noMatchMessage || naviI18n("constraint.one_of.no_match");
15004
15008
  }
@@ -15575,7 +15579,11 @@ const useDebugFocus = () => {
15575
15579
  const debug = useContext(DebugFocusContext);
15576
15580
  return debug || debugNoop;
15577
15581
  };
15578
- /** Logger for virtual scroll / wheel motion (drag, momentum, glide), or a no-op. */
15582
+ /**
15583
+ * Logger for virtual scroll / wheel motion (drag, momentum, glide) and for what
15584
+ * a virtualized list does about it — the render window moving, and the rows the
15585
+ * run asks for or decides not to ask for. Or a no-op.
15586
+ */
15579
15587
  const useDebugScroll = () => {
15580
15588
  const debug = useContext(DebugScrollContext);
15581
15589
  return debug || debugNoop;
@@ -15618,7 +15626,9 @@ const useDebugUIState = () => {
15618
15626
  * @param {boolean|Function} [props.debugCommand] - navi command dispatch (`--navi-*`).
15619
15627
  * @param {boolean|Function} [props.debugInteraction] - Gated interactions; also implies focus/scroll/popup.
15620
15628
  * @param {boolean|Function} [props.debugFocus] - Focus moves and focus-visible decisions.
15621
- * @param {boolean|Function} [props.debugScroll] - Virtual scroll / wheel motion.
15629
+ * @param {boolean|Function} [props.debugScroll] - Virtual scroll / wheel motion,
15630
+ * the render window of a virtualized list, and every pass of its run — what it
15631
+ * asked for, or why it asked for nothing (see docs/list_refresh.md).
15622
15632
  * @param {boolean|Function} [props.debugPopup] - Popover/dialog open/close/positioning.
15623
15633
  * @param {boolean|Function} [props.debugAction] - Action lifecycle.
15624
15634
  * @param {boolean|Function} [props.debugUIState] - UI-state transitions and validation.
@@ -21810,6 +21820,244 @@ const updateDocumentState = (value) => {
21810
21820
  documentStateSignal.value = value;
21811
21821
  };
21812
21822
 
21823
+ /**
21824
+ * The document's rendering, held for the one frame a view transition needs.
21825
+ *
21826
+ * The browser does not take the picture of the page being left when a
21827
+ * transition is ASKED for — it takes it at the next frame, just before running
21828
+ * the update callback. Preact renders sooner than that, in a microtask: so a
21829
+ * change nobody asked for (a tab pressed, the back button) has already reached
21830
+ * the DOM when the picture is taken, and the picture is of the page ARRIVING.
21831
+ * Both sides of the animation then show it, and one watches a page slide onto
21832
+ * itself.
21833
+ *
21834
+ * So what Preact has queued waits until the update callback, which is the
21835
+ * moment the API is built around — the change belongs inside it. The whole
21836
+ * document is held: it is about to be frozen under a picture anyway.
21837
+ *
21838
+ * ONE hold for the whole document, whoever animates. The hold is a wrapper
21839
+ * around Preact's `options.debounceRendering`, and two of them installed
21840
+ * independently restore each other in the wrong order when they let go — every
21841
+ * render queued in between is then handed to a wrapper nobody will ever
21842
+ * release. Everything that photographs a navigation (RouteTravel's box, a
21843
+ * route transition) must therefore hold through this module, never through a
21844
+ * wrapper of its own.
21845
+ */
21846
+
21847
+
21848
+ let renderingHold = null;
21849
+ const holdRendering = () => {
21850
+ if (renderingHold) {
21851
+ return renderingHold.release;
21852
+ }
21853
+ const debounceRenderingBefore = options.debounceRendering;
21854
+ const hold = {
21855
+ render: null,
21856
+ waiting: [],
21857
+ release: () => {
21858
+ // Only the hold that is still standing may be given back: a holder
21859
+ // releasing after another has taken over must not let go of what it
21860
+ // does not hold.
21861
+ if (renderingHold !== hold) {
21862
+ return;
21863
+ }
21864
+ renderingHold = null;
21865
+ options.debounceRendering = debounceRenderingBefore;
21866
+ const { render, waiting } = hold;
21867
+ hold.render = null;
21868
+ hold.waiting = [];
21869
+ if (render) {
21870
+ render();
21871
+ }
21872
+ for (const wait of waiting) {
21873
+ wait();
21874
+ }
21875
+ },
21876
+ };
21877
+ renderingHold = hold;
21878
+ options.debounceRendering = (render) => {
21879
+ hold.render = render;
21880
+ };
21881
+ return hold.release;
21882
+ };
21883
+
21884
+ // Anything else that must not happen before the picture is taken, and the
21885
+ // scroll is the other one: a page one arrives at starts at its top, and the
21886
+ // document put back to its top while the page being left is still on screen is
21887
+ // a page that has ALREADY jumped when the picture is taken. Worse, the browser
21888
+ // paints what the new offset shows and nothing else, so the picture keeps only
21889
+ // the band it had already painted — the page being left is then seen in
21890
+ // fragments, whatever the movement does afterwards.
21891
+ //
21892
+ // Run at once when nobody is photographing anything, which is the common case
21893
+ // and must stay free.
21894
+ const whenRenderingResumes = (callback) => {
21895
+ if (!renderingHold) {
21896
+ callback();
21897
+ return;
21898
+ }
21899
+ renderingHold.waiting.push(callback);
21900
+ };
21901
+
21902
+ // The hold a navigation takes on its way in — from before its first write,
21903
+ // because by the time a route announces that it matches, Preact has already
21904
+ // been told and the render is queued; a hold taken then is a hold taken too
21905
+ // late. Kept here until whoever animates the change takes it over, or the
21906
+ // navigation turns out to be one nobody animates.
21907
+ let routingRenderingHold = null;
21908
+ const holdRenderingForRouting = () => {
21909
+ routingRenderingHold = holdRendering();
21910
+ };
21911
+ // Nobody had a picture to take: a page held for a change it does not animate
21912
+ // is a page that stutters for nothing.
21913
+ const releaseRoutingRenderingHold = () => {
21914
+ const release = routingRenderingHold;
21915
+ routingRenderingHold = null;
21916
+ if (release) {
21917
+ release();
21918
+ }
21919
+ };
21920
+ // An animator takes the navigation's hold as its own — taking another would be
21921
+ // taking a hold on a page that is holding still — or takes a fresh one when
21922
+ // the change it animates is not a navigation.
21923
+ const takeoverRoutingRenderingHold = () => {
21924
+ const release = routingRenderingHold || holdRendering();
21925
+ routingRenderingHold = null;
21926
+ return release;
21927
+ };
21928
+
21929
+ /**
21930
+ * A container has put its page on screen — or as much of it as it can.
21931
+ *
21932
+ * A route matching is a signal changing, and the page it selects reaches the
21933
+ * DOM only once Preact has rendered — an unknown number of passes later, in an
21934
+ * unknown number of microtasks. Anyone who needs the page as it IS rather than
21935
+ * as it has been decided (a travel about to have its picture taken by the
21936
+ * browser, see route_travel.jsx) waits for this instead of counting.
21937
+ *
21938
+ * A page waiting on data is announced too, by the boundary showing its loading
21939
+ * state (see Loading in use_async_data.jsx): what the container could put on
21940
+ * screen is what the browser is about to take a picture of, and a page that
21941
+ * cannot render yet would otherwise be waited on until the transition dies of
21942
+ * it. It lives in a module of its own for that: the async layer says it as much
21943
+ * as the router does, and neither can import the other.
21944
+ */
21945
+ const [publishRouteRender, observeRouteRender] = createPubSub();
21946
+
21947
+ /**
21948
+ * Where a page was left, given back when one comes back to it.
21949
+ *
21950
+ * The browser does this on its own, and gets it wrong here for a reason that
21951
+ * has nothing to do with it: it puts the offset back at the instant the entry
21952
+ * changes, when the document still holds the page being LEFT. A position
21953
+ * further down than that page is tall is clamped to its bottom and lost — so
21954
+ * coming back to a long page from a short one lands short, and the deeper one
21955
+ * was, the more is missing.
21956
+ *
21957
+ * So the browser is told to stop (`scrollRestoration = "manual"`) and the
21958
+ * position is put back once the page one is coming back to is really there —
21959
+ * through the same wait as everything else that must not happen before the
21960
+ * picture of a transition is taken (see rendering_hold.js): restored after the
21961
+ * picture, the page arriving would be photographed at the top and seen jumping
21962
+ * from it.
21963
+ *
21964
+ * Kept per URL rather than per history entry: an entry has no name of its own
21965
+ * that survives a reload, and two entries on the same URL are the same place
21966
+ * to a reader. Kept in the session too, so a reload lands where the browser
21967
+ * would have landed — the flag above is a promise to do the whole job.
21968
+ *
21969
+ * What is NOT covered, and cannot be from here: a page whose height depends on
21970
+ * something still loading. Its content is not there at the moment it is put
21971
+ * back, so a position beyond what has arrived is clamped as before. Only the
21972
+ * page knows when it is whole.
21973
+ */
21974
+
21975
+
21976
+ const STORAGE_KEY = "navi_scroll_positions";
21977
+
21978
+ const positionByUrl = new Map();
21979
+ const readStoredPositions = () => {
21980
+ let stored;
21981
+ try {
21982
+ stored = window.sessionStorage.getItem(STORAGE_KEY);
21983
+ } catch {
21984
+ // A session storage that refuses to answer (a private window, a policy) is
21985
+ // not a reason to lose the positions of THIS session.
21986
+ return;
21987
+ }
21988
+ if (!stored) {
21989
+ return;
21990
+ }
21991
+ try {
21992
+ for (const [url, position] of Object.entries(JSON.parse(stored))) {
21993
+ positionByUrl.set(url, position);
21994
+ }
21995
+ } catch {
21996
+ // Something else wrote there, or it was truncated.
21997
+ }
21998
+ };
21999
+ const storePositions = () => {
22000
+ try {
22001
+ window.sessionStorage.setItem(
22002
+ STORAGE_KEY,
22003
+ JSON.stringify(Object.fromEntries(positionByUrl)),
22004
+ );
22005
+ } catch {
22006
+ // Full, or refused: the session is the only thing lost.
22007
+ }
22008
+ };
22009
+
22010
+ let installed = false;
22011
+ const installScrollRestoration = () => {
22012
+ if (installed) {
22013
+ return;
22014
+ }
22015
+ installed = true;
22016
+ if (!("scrollRestoration" in window.history)) {
22017
+ return;
22018
+ }
22019
+ window.history.scrollRestoration = "manual";
22020
+ readStoredPositions();
22021
+ // Read as it happens rather than when leaving: a traverse changes the url
22022
+ // before anything here is told, so a position read then would be read for
22023
+ // the wrong page.
22024
+ window.addEventListener(
22025
+ "scroll",
22026
+ () => {
22027
+ positionByUrl.set(window.location.href, {
22028
+ x: window.scrollX,
22029
+ y: window.scrollY,
22030
+ });
22031
+ },
22032
+ { passive: true },
22033
+ );
22034
+ window.addEventListener("pagehide", storePositions);
22035
+ // What a reload asks for, now that the browser has been told not to do it.
22036
+ // Once, and at the first render of a route: the position is only meaningful
22037
+ // once there is a page under it.
22038
+ const positionOnLoad = positionByUrl.get(window.location.href);
22039
+ if (positionOnLoad && (positionOnLoad.x || positionOnLoad.y)) {
22040
+ const stopListening = observeRouteRender(() => {
22041
+ stopListening();
22042
+ scrollTo(positionOnLoad);
22043
+ });
22044
+ }
22045
+ };
22046
+
22047
+ // Nothing to put back is not the same as putting back the top: a page arrived
22048
+ // at for the first time is startAtTop's business, and this must not step on it.
22049
+ const restoreScrollPosition = (url) => {
22050
+ const position = positionByUrl.get(new URL(url, window.location.href).href);
22051
+ if (!position) {
22052
+ return;
22053
+ }
22054
+ scrollTo(position);
22055
+ };
22056
+
22057
+ const scrollTo = ({ x, y }) => {
22058
+ window.scrollTo({ top: y, left: x, behavior: "instant" });
22059
+ };
22060
+
21813
22061
  /**
21814
22062
  * A navigation is ABOUT to be applied — said before its very first write.
21815
22063
  *
@@ -21882,8 +22130,67 @@ const setupBrowserIntegrationViaHistory = ({
21882
22130
  visitedUrlsSignal.value++;
21883
22131
  };
21884
22132
 
22133
+ // The one thing the History API cannot say and the Navigation API can: what
22134
+ // stands NEXT to the current entry. A link to the page one just came from is
22135
+ // morally a back — pushed, it grows the stack (A, B, A, B…) and lands at the
22136
+ // top; traversed, the stack stays what the reader thinks it is and the page
22137
+ // comes back where they left it. So a push whose destination is the entry
22138
+ // right behind (or right ahead) is turned into a traversal, and the whole
22139
+ // traverse machinery (routing, scroll, movement) answers it as if the
22140
+ // browser's own button had been pressed.
22141
+ //
22142
+ // Only where the browser exposes the stack (window.navigation — everywhere
22143
+ // but Firefox today; without it a push stays a push, which is what this
22144
+ // whole file already does). And only towards entries of THIS document: a
22145
+ // traversal to another document is a full page load, which no press on a
22146
+ // link asked for — the entries that are ours are recorded as they are
22147
+ // created, starting with the one this document was loaded into.
22148
+ const sameDocumentEntryKeys = new Set();
22149
+ const rememberEntryIsOfThisDocument = () => {
22150
+ if (window.navigation) {
22151
+ sameDocumentEntryKeys.add(window.navigation.currentEntry.key);
22152
+ }
22153
+ };
22154
+ rememberEntryIsOfThisDocument();
22155
+ const adjacentEntryDelta = (url) => {
22156
+ const { navigation } = window;
22157
+ if (!navigation) {
22158
+ return 0;
22159
+ }
22160
+ const entries = navigation.entries();
22161
+ const index = navigation.currentEntry.index;
22162
+ // Behind first: when the same page stands on both sides (A, B, A and one
22163
+ // is on B), a link to it reads as going back.
22164
+ for (const delta of [-1, 1]) {
22165
+ const entry = entries[index + delta];
22166
+ if (entry && entry.url === url && sameDocumentEntryKeys.has(entry.key)) {
22167
+ return delta;
22168
+ }
22169
+ }
22170
+ return 0;
22171
+ };
22172
+
21885
22173
  let abortController = null;
21886
22174
  const handleRoutingTask = (url, options) => {
22175
+ // Decided before anything is announced: an elided push IS the traversal it
22176
+ // becomes, and the traversal will make its own announcements when the
22177
+ // browser answers — a before/after cycle here would be about a navigation
22178
+ // that never happens.
22179
+ if (
22180
+ options.navigationType === "push" &&
22181
+ options.state === undefined &&
22182
+ url !== window.location.href
22183
+ ) {
22184
+ const delta = adjacentEntryDelta(url);
22185
+ if (delta === -1) {
22186
+ window.history.back();
22187
+ return undefined;
22188
+ }
22189
+ if (delta === 1) {
22190
+ window.history.forward();
22191
+ return undefined;
22192
+ }
22193
+ }
21887
22194
  // Before anything is written: the visited set, the URL and every route are
21888
22195
  // about to change, and this is the last moment the page still stands as it
21889
22196
  // was. And after, whichever way the change went out — so that whoever took
@@ -21943,6 +22250,7 @@ const setupBrowserIntegrationViaHistory = ({
21943
22250
  } else {
21944
22251
  window.history.replaceState(effectiveState, null, url);
21945
22252
  }
22253
+ rememberEntryIsOfThisDocument();
21946
22254
  updateDocumentUrl(url);
21947
22255
  updateDocumentState(effectiveState);
21948
22256
  } else {
@@ -21978,7 +22286,12 @@ const setupBrowserIntegrationViaHistory = ({
21978
22286
  state,
21979
22287
  });
21980
22288
  if (navigationType === "push") {
21981
- startAtTop(url);
22289
+ whenRenderingResumes(() => startAtTop(url));
22290
+ } else if (navigationType === "traverse") {
22291
+ // Where this entry was left. Waited for like the reset above, and for
22292
+ // the same two reasons: the page has to be there to be scrolled, and a
22293
+ // picture taken before it would be of a page at its top.
22294
+ whenRenderingResumes(() => restoreScrollPosition(url));
21982
22295
  }
21983
22296
  executeWithCleanup(
21984
22297
  () => allResult,
@@ -22042,6 +22355,12 @@ const setupBrowserIntegrationViaHistory = ({
22042
22355
  handleRoutingTask(href, {
22043
22356
  reason: `"click" on a[href="${href}"]`,
22044
22357
  navigationType: "push",
22358
+ // Who started it. Announced with the navigation because a press
22359
+ // carries things the url does not: what a link asks of a route
22360
+ // transition is the first of them (see route_transition.jsx). Read by
22361
+ // whoever knows what to do with it, and it is the anchor itself —
22362
+ // resolved here, where it already is.
22363
+ element: linkElement,
22045
22364
  });
22046
22365
  },
22047
22366
  { capture: true },
@@ -22056,6 +22375,11 @@ const setupBrowserIntegrationViaHistory = ({
22056
22375
  { capture: true },
22057
22376
  );
22058
22377
 
22378
+ // The browser's own scroll restoration is taken over here rather than left
22379
+ // to whoever navigates: it is a decision about the document, and the entry
22380
+ // being left must be recorded from the first pixel scrolled.
22381
+ installScrollRestoration();
22382
+
22059
22383
  window.addEventListener("popstate", (popstateEvent) => {
22060
22384
  const url = window.location.href;
22061
22385
  const state = popstateEvent.state;
@@ -22074,11 +22398,15 @@ const setupBrowserIntegrationViaHistory = ({
22074
22398
  updateDocumentUrl(window.location.href);
22075
22399
  });
22076
22400
 
22077
- const navTo = async (url, { replace, state } = {}) => {
22401
+ const navTo = async (url, { replace, state, routeTransition } = {}) => {
22078
22402
  handleRoutingTask(url, {
22079
22403
  reason: `navTo called with "${url}"`,
22080
22404
  navigationType: replace ? "replace" : "push",
22081
22405
  state,
22406
+ // What this one navigation asks of a route transition, said by the call
22407
+ // that starts it rather than by an element — the programmatic half of
22408
+ // what a <Link routeTransition> says (see route_transition.jsx).
22409
+ routeTransition,
22082
22410
  });
22083
22411
  };
22084
22412
 
@@ -22140,13 +22468,16 @@ const setupBrowserIntegrationViaHistory = ({
22140
22468
  // route_travel.jsx), and resetting there would throw the reader out of a page
22141
22469
  // they never left.
22142
22470
  //
22143
- // After the routes have been told, and that ordering is the whole subtlety:
22144
- // the routes changing is what sets a travel off, and a travel measures the box
22145
- // it is leaving as it stands. Reset before that and the picture of the page
22146
- // being left is taken at the top of a page the reader was not at the top of —
22147
- // it is then watched jumping back to its first line before it even begins to
22148
- // leave (see holdTravelGeometry in route_travel.jsx). After pushState too, so
22149
- // the entry being left keeps the offset it is at.
22471
+ // After the routes have been told, and after the picture of the page being
22472
+ // left has been taken that ordering is the whole subtlety. The routes
22473
+ // changing is what sets a movement off, and a movement measures the box it is
22474
+ // leaving as it stands; put the document back to its top any earlier and the
22475
+ // picture is of a page at its first line, which the reader was not at. The
22476
+ // browser paints what the new offset shows and nothing else, so what is kept
22477
+ // of the page being left is the band it had already painted, and the movement
22478
+ // carries a fragment (see rendering_hold.js, which is where the waiting
22479
+ // happens). After pushState too, so the entry being left keeps the offset it
22480
+ // is at.
22150
22481
  //
22151
22482
  // The document, because the document is the scrollport in the common case. An
22152
22483
  // app that scrolls an element of its own scrolls it itself.
@@ -22227,7 +22558,9 @@ const applyRouting = (
22227
22558
  return { ...updateActionsResult, activeRouteSet };
22228
22559
  };
22229
22560
 
22230
- const browserIntegration = setupBrowserIntegrationViaHistory({
22561
+ const setupBrowserIntegration =
22562
+ setupBrowserIntegrationViaHistory;
22563
+ const browserIntegration = setupBrowserIntegration({
22231
22564
  applyActions,
22232
22565
  applyRouting,
22233
22566
  // Routes are declared by the consumer and registered through
@@ -36224,24 +36557,6 @@ const TYPE_CONVERTERS = {
36224
36557
  },
36225
36558
  };
36226
36559
 
36227
- /**
36228
- * A container has put its page on screen — or as much of it as it can.
36229
- *
36230
- * A route matching is a signal changing, and the page it selects reaches the
36231
- * DOM only once Preact has rendered — an unknown number of passes later, in an
36232
- * unknown number of microtasks. Anyone who needs the page as it IS rather than
36233
- * as it has been decided (a travel about to have its picture taken by the
36234
- * browser, see route_travel.jsx) waits for this instead of counting.
36235
- *
36236
- * A page waiting on data is announced too, by the boundary showing its loading
36237
- * state (see Loading in use_async_data.jsx): what the container could put on
36238
- * screen is what the browser is about to take a picture of, and a page that
36239
- * cannot render yet would otherwise be waited on until the transition dies of
36240
- * it. It lives in a module of its own for that: the async layer says it as much
36241
- * as the router does, and neither can import the other.
36242
- */
36243
- const [publishRouteRender, observeRouteRender] = createPubSub();
36244
-
36245
36560
  const promiseStateWeakMap = new WeakMap();
36246
36561
  const usePromiseAsyncData = (
36247
36562
  promise,
@@ -38316,89 +38631,6 @@ const RouteUI = ({
38316
38631
  return element;
38317
38632
  };
38318
38633
 
38319
- /**
38320
- * The document's rendering, held for the one frame a view transition needs.
38321
- *
38322
- * The browser does not take the picture of the page being left when a
38323
- * transition is ASKED for — it takes it at the next frame, just before running
38324
- * the update callback. Preact renders sooner than that, in a microtask: so a
38325
- * change nobody asked for (a tab pressed, the back button) has already reached
38326
- * the DOM when the picture is taken, and the picture is of the page ARRIVING.
38327
- * Both sides of the animation then show it, and one watches a page slide onto
38328
- * itself.
38329
- *
38330
- * So what Preact has queued waits until the update callback, which is the
38331
- * moment the API is built around — the change belongs inside it. The whole
38332
- * document is held: it is about to be frozen under a picture anyway.
38333
- *
38334
- * ONE hold for the whole document, whoever animates. The hold is a wrapper
38335
- * around Preact's `options.debounceRendering`, and two of them installed
38336
- * independently restore each other in the wrong order when they let go — every
38337
- * render queued in between is then handed to a wrapper nobody will ever
38338
- * release. Everything that photographs a navigation (RouteTravel's box, a
38339
- * route transition) must therefore hold through this module, never through a
38340
- * wrapper of its own.
38341
- */
38342
-
38343
-
38344
- let renderingHold = null;
38345
- const holdRendering = () => {
38346
- if (renderingHold) {
38347
- return renderingHold.release;
38348
- }
38349
- const debounceRenderingBefore = options.debounceRendering;
38350
- const hold = {
38351
- render: null,
38352
- release: () => {
38353
- // Only the hold that is still standing may be given back: a holder
38354
- // releasing after another has taken over must not let go of what it
38355
- // does not hold.
38356
- if (renderingHold !== hold) {
38357
- return;
38358
- }
38359
- renderingHold = null;
38360
- options.debounceRendering = debounceRenderingBefore;
38361
- const { render } = hold;
38362
- hold.render = null;
38363
- if (render) {
38364
- render();
38365
- }
38366
- },
38367
- };
38368
- renderingHold = hold;
38369
- options.debounceRendering = (render) => {
38370
- hold.render = render;
38371
- };
38372
- return hold.release;
38373
- };
38374
-
38375
- // The hold a navigation takes on its way in — from before its first write,
38376
- // because by the time a route announces that it matches, Preact has already
38377
- // been told and the render is queued; a hold taken then is a hold taken too
38378
- // late. Kept here until whoever animates the change takes it over, or the
38379
- // navigation turns out to be one nobody animates.
38380
- let routingRenderingHold = null;
38381
- const holdRenderingForRouting = () => {
38382
- routingRenderingHold = holdRendering();
38383
- };
38384
- // Nobody had a picture to take: a page held for a change it does not animate
38385
- // is a page that stutters for nothing.
38386
- const releaseRoutingRenderingHold = () => {
38387
- const release = routingRenderingHold;
38388
- routingRenderingHold = null;
38389
- if (release) {
38390
- release();
38391
- }
38392
- };
38393
- // An animator takes the navigation's hold as its own — taking another would be
38394
- // taking a hold on a page that is holding still — or takes a fresh one when
38395
- // the change it animates is not a navigation.
38396
- const takeoverRoutingRenderingHold = () => {
38397
- const release = routingRenderingHold || holdRendering();
38398
- routingRenderingHold = null;
38399
- return release;
38400
- };
38401
-
38402
38634
  /**
38403
38635
  * The window two pages are seen through while one replaces the other, measured
38404
38636
  * once and published for the length of the movement.
@@ -38516,6 +38748,18 @@ installImportMetaCssBuild(import.meta);/**
38516
38748
  * own CSS (see the JSDoc below). Said without one, the relation plays the
38517
38749
  * browser's cross-fade.
38518
38750
  *
38751
+ * A relation holds for every way of reaching a page, and one navigation may
38752
+ * know better: the rare way round a pair — a badge that jumps back OUT to the
38753
+ * game it belongs to, a card that leads to the player it describes — is walked
38754
+ * against the map, and there is no telling it from the common way by the
38755
+ * routes alone. So the navigation itself may ask for something: a `<Link
38756
+ * routeTransition>`, or navTo(url, { routeTransition }). What it asks holds
38757
+ * for THAT
38758
+ * navigation and no other, and only for the fields it names — `{ direction:
38759
+ * "back" }` keeps the pair's movement and turns it round (see
38760
+ * readNavigationRequest). A pair no relation was ever written for animates the
38761
+ * same way, for the one press that asks.
38762
+ *
38519
38763
  * There is no box in the tree: by default what animates is the document itself
38520
38764
  * (its `root` view transition group), which is right for pages that ARE the
38521
38765
  * whole viewport. An application whose pages live between fixed bars marks the
@@ -38549,6 +38793,10 @@ const TRANSITION_DURATION_PROPERTY = "--navi-route-transition-duration";
38549
38793
  // right for a page that IS the whole viewport.
38550
38794
  const TRANSITION_AREA_ATTRIBUTE = "data-navi-route-transition-area";
38551
38795
  const TRANSITION_TARGET_ATTRIBUTE = "data-navi-route-transition-target";
38796
+ // What ONE navigation asks for, over whatever the relations say: worn by the
38797
+ // link being pressed (see <Link routeTransition>), or handed to navTo(). It answers
38798
+ // for that navigation and for no other — the next one is back to the relations.
38799
+ const TRANSITION_REQUEST_ATTRIBUTE = "data-navi-route-transition-request";
38552
38800
  const AREA_NAME = "navi-route-transition";
38553
38801
  // route_travel.jsx wears this on the root for the length of one of its
38554
38802
  // travels (its TRAVEL_ATTRIBUTE — a comment there mirrors this one). Read by
@@ -38694,10 +38942,13 @@ const css$T = /* css */`
38694
38942
  The movements. One of \`root\` and \`navi-route-transition\` exists at a
38695
38943
  time (see the opt-out above), so each is written for both.
38696
38944
  ------------------------------------------------------------------ */
38697
- &[data-navi-route-transition-type="slide-x"],
38698
- &[data-navi-route-transition-type="slide-y"],
38699
- &[data-navi-route-transition-type="cover-x"],
38700
- &[data-navi-route-transition-type="cover-y"] {
38945
+ /* What a NAMED movement is made of, whatever the movement is — the types
38946
+ navi ships and the ones an application writes alike. The attribute is
38947
+ present for a type and only for a type ("cross-fade" normalizes to no
38948
+ type at all, "none" starts nothing), so the browser's own cross-fade
38949
+ keeps every default below: scaling one picture into the other and seeing
38950
+ through both IS the movement there. */
38951
+ &[data-navi-route-transition-type] {
38701
38952
  &::view-transition-old(root),
38702
38953
  &::view-transition-new(root),
38703
38954
  &::view-transition-old(navi-route-transition),
@@ -38707,20 +38958,34 @@ const css$T = /* css */`
38707
38958
  the page it crosses. The picture is as wide as the box the browser
38708
38959
  gives it — the arriving one's — so a page leaving a narrower box (a
38709
38960
  scrollbar appeared, a side panel closed) would be seen zooming over
38710
- the length of the movement. Left to the untyped cross-fade, where
38711
- scaling one picture into the other is the whole idea. */
38961
+ the length of the movement, and one leaving a shorter box would be
38962
+ seen inflating. */
38712
38963
  height: auto;
38713
38964
  object-fit: none;
38714
38965
  object-position: top left;
38715
- /* The default cross-fade, dropped: two pages sliding past each other
38716
- are two solid things, and seeing through one to the other says they
38717
- are the same page changing its mind. */
38966
+ /* Two pages crossing are two solid things, and seeing through one to
38967
+ the other says they are the same page changing its mind. A movement
38968
+ that keeps the browser's fade on one of its two sides wants the
38969
+ opposite, and says so — see zoom below. */
38718
38970
  mix-blend-mode: normal;
38719
- animation-timing-function: ease;
38720
38971
  animation-fill-mode: both;
38721
38972
  }
38722
38973
  }
38723
38974
 
38975
+ /* Eased, which is a taste about THESE four: a custom type says its own
38976
+ curve. */
38977
+ &[data-navi-route-transition-type="slide-x"],
38978
+ &[data-navi-route-transition-type="slide-y"],
38979
+ &[data-navi-route-transition-type="cover-x"],
38980
+ &[data-navi-route-transition-type="cover-y"] {
38981
+ &::view-transition-old(root),
38982
+ &::view-transition-new(root),
38983
+ &::view-transition-old(navi-route-transition),
38984
+ &::view-transition-new(navi-route-transition) {
38985
+ animation-timing-function: ease;
38986
+ }
38987
+ }
38988
+
38724
38989
  &[data-navi-route-transition-type="slide-x"] {
38725
38990
  &[data-navi-route-transition="forward"] {
38726
38991
  &::view-transition-old(root),
@@ -38833,7 +39098,11 @@ const css$T = /* css */`
38833
39098
  &::view-transition-new(root),
38834
39099
  &::view-transition-old(navi-route-transition),
38835
39100
  &::view-transition-new(navi-route-transition) {
38836
- animation-fill-mode: both;
39101
+ /* One side of this one is the browser's fade, and a fade is two
39102
+ half-transparent pictures: they must ADD up rather than cover each
39103
+ other, or the page behind shows through the middle of the
39104
+ movement. */
39105
+ mix-blend-mode: plus-lighter;
38837
39106
  }
38838
39107
  &[data-navi-route-transition="forward"] {
38839
39108
  &::view-transition-new(root),
@@ -38986,6 +39255,12 @@ const RouteTransitionArea = ({
38986
39255
  * animation-name: my-spin-in;
38987
39256
  * }
38988
39257
  * }
39258
+ *
39259
+ * Whatever is written here is what EVERY crossing of the pair plays. One
39260
+ * crossing can ask for something else — `<Link routeTransition>`, or
39261
+ * navTo(url, { routeTransition }) — which overrides this field by field, for
39262
+ * that
39263
+ * navigation alone.
38989
39264
  * @returns {() => void} remove this relation.
38990
39265
  */
38991
39266
  const defineRouteTransition = (from, to, transition) => {
@@ -39002,13 +39277,11 @@ const defineRouteTransition = (from, to, transition) => {
39002
39277
  };
39003
39278
  relations.push(relation);
39004
39279
  rebuildWatcher();
39005
- updateRoutingObservers();
39006
39280
  return () => {
39007
39281
  const index = relations.indexOf(relation);
39008
39282
  if (index > -1) {
39009
39283
  relations.splice(index, 1);
39010
39284
  rebuildWatcher();
39011
- updateRoutingObservers();
39012
39285
  }
39013
39286
  };
39014
39287
  };
@@ -39030,11 +39303,9 @@ const defineRouteDefaultTransition = transition => {
39030
39303
  import.meta.css = [css$T, "@jsenv/navi/src/nav/route_transition.jsx"];
39031
39304
  const value = normalizeTransition(transition);
39032
39305
  defaultTransition = value;
39033
- updateRoutingObservers();
39034
39306
  return () => {
39035
39307
  if (defaultTransition === value) {
39036
39308
  defaultTransition = null;
39037
- updateRoutingObservers();
39038
39309
  }
39039
39310
  };
39040
39311
  };
@@ -39055,6 +39326,98 @@ const normalizeTransition = transition => {
39055
39326
  };
39056
39327
  };
39057
39328
 
39329
+ /**
39330
+ * What THIS navigation asked for, whatever the relations say.
39331
+ *
39332
+ * A relation is about the map of the app and holds for every way of reaching a
39333
+ * page; a request is about one crossing of it. The rare way round a pair — a
39334
+ * badge that jumps back out to the game it belongs to, a card that leads to
39335
+ * the player it describes — is a navigation that knows something the pair does
39336
+ * not, and this is where it says it.
39337
+ *
39338
+ * Two mouths, one meaning: the element being pressed wears it (a `<Link
39339
+ * routeTransition>`, or the attribute by hand on any anchor), or navTo() is
39340
+ * handed
39341
+ * it. Both arrive here through the announcement the navigation makes before it
39342
+ * writes anything (see before_routing.js).
39343
+ *
39344
+ * A request answers FIELD BY FIELD: what it does not say, the relation — or
39345
+ * the default — still answers for. So `{ direction: "back" }` keeps the pair's
39346
+ * movement and only turns it round, and `"none"` cuts where something would
39347
+ * have played.
39348
+ */
39349
+ const readNavigationRequest = ({
39350
+ routeTransition,
39351
+ element
39352
+ }) => {
39353
+ if (routeTransition !== undefined && routeTransition !== null) {
39354
+ return normalizeRequest(routeTransition);
39355
+ }
39356
+ if (element && element.getAttribute) {
39357
+ const asked = element.getAttribute(TRANSITION_REQUEST_ATTRIBUTE);
39358
+ if (asked === null) {
39359
+ return null;
39360
+ }
39361
+ const value = asked.trim();
39362
+ if (value === "") {
39363
+ return null;
39364
+ }
39365
+ // A type is a name, and a name is all most links have to say. Anything
39366
+ // more — a way round, a pace — is the same object the API takes
39367
+ // everywhere else, written as JSON so that it travels on an attribute
39368
+ // (and so that a plain <a> can say it too).
39369
+ if (value[0] === "{") {
39370
+ let parsed;
39371
+ try {
39372
+ parsed = JSON.parse(value);
39373
+ } catch {
39374
+ console.warn(`${TRANSITION_REQUEST_ATTRIBUTE} is neither a type name nor JSON: ${value}`);
39375
+ return null;
39376
+ }
39377
+ return normalizeRequest(parsed);
39378
+ }
39379
+ return normalizeRequest(value);
39380
+ }
39381
+ return null;
39382
+ };
39383
+ const normalizeRequest = transition => {
39384
+ const {
39385
+ type,
39386
+ duration,
39387
+ direction
39388
+ } = typeof transition === "string" ? {
39389
+ type: transition
39390
+ } : transition;
39391
+ return {
39392
+ type: type === "cross-fade" ? undefined : type,
39393
+ // Whether a type was SAID, which is not the same as having one: asking for
39394
+ // "cross-fade" is asking for the browser's own animation, and a request
39395
+ // that names no type at all keeps the relation's.
39396
+ typeSaid: type !== undefined,
39397
+ duration,
39398
+ direction
39399
+ };
39400
+ };
39401
+
39402
+ // The request first, field by field, then what was defined for this pair (or
39403
+ // for everything). Written as one function because both ends of the file
39404
+ // resolve the same way: the one that knows the pair, and the one that only
39405
+ // knows a navigation landed.
39406
+ const resolveTransition = (request, base) => {
39407
+ const baseType = base ? base.type : undefined;
39408
+ const baseDuration = base ? base.duration : undefined;
39409
+ if (!request) {
39410
+ return {
39411
+ type: baseType,
39412
+ duration: baseDuration
39413
+ };
39414
+ }
39415
+ return {
39416
+ type: request.typeSaid ? request.type : baseType,
39417
+ duration: request.duration === undefined ? baseDuration : request.duration
39418
+ };
39419
+ };
39420
+
39058
39421
  // Every relation defined, and the single watcher standing over all of them.
39059
39422
  const relations = [];
39060
39423
  let watcher = null;
@@ -39098,27 +39461,32 @@ const rebuildWatcher = () => {
39098
39461
  return;
39099
39462
  }
39100
39463
  const found = findRelation(pages[fromIndex], pages[index]);
39101
- if (!found) {
39102
- // No relation says anything about these two: they are side by side, and
39103
- // silence is the fact — not a missing case.
39464
+ if (!found && !navigationRequest) {
39465
+ // No relation says anything about these two and this navigation asked
39466
+ // for nothing: they are side by side, and silence is the fact — not a
39467
+ // missing case.
39104
39468
  return;
39105
39469
  }
39106
39470
  const {
39107
- direction,
39108
- relation
39109
- } = found;
39110
- if (relation.type === "none") {
39471
+ type,
39472
+ duration
39473
+ } = resolveTransition(navigationRequest, found ? found.relation : null);
39474
+ if (type === "none") {
39111
39475
  // Silence said out loud: this way of the pair was written to play
39112
- // nothing, where the reverse of the other wayor the default — would
39113
- // have played.
39476
+ // nothing or this one navigation asked for nothing where the reverse
39477
+ // of the other way, or the default, would have played.
39114
39478
  navigationAnimated = true;
39115
39479
  return;
39116
39480
  }
39117
39481
  beginTransition({
39118
39482
  page: pages[index],
39119
- direction,
39120
- type: relation.type,
39121
- duration: relation.duration
39483
+ // Which way it plays: what the navigation itself said first — the link
39484
+ // being pressed is where the way the app is being walked is known — then
39485
+ // the relation, and forward for a navigation that asked for a movement
39486
+ // between two pages no relation orders.
39487
+ direction: navigationRequest && navigationRequest.direction || found && found.direction || "forward",
39488
+ type,
39489
+ duration
39122
39490
  });
39123
39491
  };
39124
39492
  // `subscribe` rather than `effect`: it hands the value to a callback that is
@@ -39130,52 +39498,57 @@ const rebuildWatcher = () => {
39130
39498
  };
39131
39499
  };
39132
39500
 
39133
- // What plays when no relation matched (see defineRouteDefaultTransition), and
39134
- // whether the navigation now landing found an answer already a relation's
39135
- // transition, a "none", a RouteTravel travel. The flag is reset when a
39136
- // navigation begins, so it is always about the latest one.
39501
+ // What plays when no relation matched (see defineRouteDefaultTransition), what
39502
+ // the navigation now landing asked for on its own (see readNavigationRequest),
39503
+ // and whether it found an answer already a relation's transition, a "none",
39504
+ // a RouteTravel travel. The last two are read at the start of every
39505
+ // navigation, so they are always about the latest one.
39137
39506
  let defaultTransition = null;
39507
+ let navigationRequest = null;
39138
39508
  let navigationAnimated = false;
39139
39509
 
39140
- // The two ends of a navigation, watched while there is anyone to animate it.
39141
- // The picture of the page being left has to be honest, so rendering is held
39142
- // from before the navigation's first write (see rendering_hold.js) — and given
39143
- // back at the far end when the change turns out to be one nobody animates,
39144
- // which is also the one moment the DEFAULT can decide: every relation has had
39145
- // its say by then.
39146
- let stopRoutingObservers = null;
39147
- const updateRoutingObservers = () => {
39148
- const wanted = relations.length > 0 || defaultTransition !== null;
39149
- if (wanted && !stopRoutingObservers) {
39150
- const stopWatchingStart = observeBeforeRouting(() => {
39151
- navigationAnimated = false;
39152
- holdRenderingForRouting();
39153
- });
39154
- const stopWatchingEnd = observeAfterRouting(() => {
39155
- if (defaultTransition && defaultTransition.type !== "none" && !navigationAnimated) {
39156
- beginTransition({
39157
- page: null,
39158
- // A default has no direction: nothing says which of two arbitrary
39159
- // pages is before the other. The attribute is worn empty — present
39160
- // for whoever keys on "one of ours is playing", silent on the way.
39161
- direction: "",
39162
- type: defaultTransition.type,
39163
- duration: defaultTransition.duration
39164
- });
39165
- }
39166
- releaseRoutingRenderingHold();
39167
- });
39168
- stopRoutingObservers = () => {
39169
- stopWatchingStart();
39170
- stopWatchingEnd();
39171
- };
39510
+ // The two ends of every navigation, watched from here on. The picture of the
39511
+ // page being left has to be honest, so rendering is held from before the
39512
+ // navigation's first write (see rendering_hold.js) — but only when something
39513
+ // could be photographed: a document where nothing is defined and nothing is
39514
+ // asked for holds nothing. It is given back at the far end, which is also the
39515
+ // one moment the DEFAULT can decide: every relation has had its say by then.
39516
+ observeBeforeRouting(details => {
39517
+ navigationAnimated = false;
39518
+ navigationRequest = readNavigationRequest(details);
39519
+ if (relations.length === 0 && !defaultTransition && !navigationRequest) {
39172
39520
  return;
39173
39521
  }
39174
- if (!wanted && stopRoutingObservers) {
39175
- stopRoutingObservers();
39176
- stopRoutingObservers = null;
39522
+ holdRenderingForRouting();
39523
+ });
39524
+ observeAfterRouting(() => {
39525
+ const request = navigationRequest;
39526
+ // Read here and dropped here: a request answers for the navigation it was
39527
+ // made on, and the next one is back to the relations.
39528
+ navigationRequest = null;
39529
+ if (!navigationAnimated && (request || defaultTransition)) {
39530
+ const {
39531
+ type,
39532
+ duration
39533
+ } = resolveTransition(request, defaultTransition);
39534
+ if (type !== "none") {
39535
+ beginTransition({
39536
+ page: null,
39537
+ // A default has no direction: nothing says which of two arbitrary
39538
+ // pages is before the other, and the attribute is then worn empty —
39539
+ // present for whoever keys on "one of ours is playing", silent on the
39540
+ // way. A request is the other case: a navigation IS a way round, so a
39541
+ // press that names the movement means forward unless it says
39542
+ // otherwise — and a movement of navi's is written on the direction,
39543
+ // so left empty it would play nothing at all.
39544
+ direction: request && request.direction || (request && request.typeSaid ? "forward" : ""),
39545
+ type,
39546
+ duration
39547
+ });
39548
+ }
39177
39549
  }
39178
- };
39550
+ releaseRoutingRenderingHold();
39551
+ });
39179
39552
 
39180
39553
  // The exact way travelled first, over the whole registry, and only then the
39181
39554
  // reverses: a relation written B → A owns that way, and being the reverse of
@@ -43351,6 +43724,14 @@ Object.assign(PSEUDO_CLASSES, {
43351
43724
  * out of flow — the "#" anchor-on-hover pattern (e.g. inside a `Title`).
43352
43725
  * @param {boolean} [props.hrefFallback] - Use `href` as the visible text when
43353
43726
  * no children are given; defaults to `true` unless `anchor`.
43727
+ * @param {string|{type?: string, duration?: number|string, direction?: "forward"|"back"}} [props.routeTransition] -
43728
+ * What pressing THIS link asks of a route transition, for that one
43729
+ * navigation: a type name (`"slide-x"`, `"none"`, …), or an object to also
43730
+ * say the pace or which way it plays. It overrides field by field what
43731
+ * `defineRouteTransition` wrote for the pair — `{ direction: "back" }` keeps
43732
+ * the pair's movement and only turns it round, which is what the rare way
43733
+ * round a pair usually needs. Said nowhere else, the relations answer as
43734
+ * they always do.
43354
43735
  * @param {boolean} [props.preventDefault] - Call `event.preventDefault()` on
43355
43736
  * click (navigation suppressed; `onClick` still runs).
43356
43737
  * @param {(event: MouseEvent) => void} [props.onClick]
@@ -43414,6 +43795,7 @@ const LinkPlain = props => {
43414
43795
  endIcon,
43415
43796
  revealOnInteraction = false,
43416
43797
  hrefFallback = !anchor,
43798
+ routeTransition,
43417
43799
  children
43418
43800
  } = props;
43419
43801
  if (anchor && !props.id) {
@@ -43520,6 +43902,13 @@ const LinkPlain = props => {
43520
43902
  } else {
43521
43903
  innerEndIcon = endIcon;
43522
43904
  }
43905
+
43906
+ // What this link asks of a route transition, worn as an attribute so that
43907
+ // the navigation reads it off the element being pressed (see
43908
+ // route_transition.jsx, which owns the name and does the reading). A type is
43909
+ // a name; anything more travels as JSON, which is also how a plain <a>
43910
+ // writes it by hand.
43911
+ const routeTransitionRequest = routeTransition === undefined || routeTransition === null ? undefined : typeof routeTransition === "string" ? routeTransition : JSON.stringify(routeTransition);
43523
43912
  const innerChildren = children || (hrefFallback ? href : children);
43524
43913
  const startIconEl = startIcon;
43525
43914
  const endIconEl = innerEndIcon;
@@ -43570,6 +43959,8 @@ const LinkPlain = props => {
43570
43959
  startIcon: undefined,
43571
43960
  endIcon: undefined,
43572
43961
  hrefFallback: undefined,
43962
+ routeTransition: undefined,
43963
+ "data-navi-route-transition-request": routeTransitionRequest,
43573
43964
  onClick: e => {
43574
43965
  onClick?.(e);
43575
43966
  if (slide) {
@@ -60181,6 +60572,18 @@ const useItemStore = ({
60181
60572
  memoryBudget,
60182
60573
  onRequestStateChange
60183
60574
  }) => {
60575
+ // The run's asking, on the same channel as the window it asks for — they are
60576
+ // one subject: what the list is about to draw is what it goes to fetch (see
60577
+ // `useRequestMissing`, and `updateRenderWindow` which logs the other half).
60578
+ //
60579
+ // A run that decides NOT to ask is the case this exists for. It sends
60580
+ // nothing and changes no state, so nothing outside can see it: the network
60581
+ // is silent, and `onRequestStateChange` — which reports what a request is
60582
+ // doing — has no request to report. A run that declined and a run that was
60583
+ // never mounted look identical from the application's side, which makes
60584
+ // "this list stopped refreshing" a question with no observable answer.
60585
+ // Here it has one, and every pass says which.
60586
+ const debugScroll = useDebugScroll();
60184
60587
  // What the source kept of the collection when the screen it was on went away
60185
60588
  // (a range reader keeps the composition: see resource_range_reader.js). The
60186
60589
  // rows are drawn from it right away and the window is asked for again — the
@@ -60407,17 +60810,32 @@ const useItemStore = ({
60407
60810
  }
60408
60811
  }
60409
60812
  const ask = () => {
60813
+ // One line per pass, whatever the outcome — an absence in the trace
60814
+ // then means the run did not render, which is a different fact from
60815
+ // the run choosing not to ask. The state that decides is on the line
60816
+ // rather than left to be inferred: `revalidating` says the run knows
60817
+ // what it holds is from before, `holdPending` that the list is on its
60818
+ // way somewhere the window does not frame yet, `count` that it knows
60819
+ // how many rows it stands for.
60820
+ const debugAsk = outcome => {
60821
+ debugScroll(`ask ${start}-${end}: ${outcome}`, `(revalidating=${revalidating} holdPending=${virtual.holdPending} count=${pages.count})`);
60822
+ };
60410
60823
  if (start === -1) {
60824
+ // Nothing missing and nothing to revalidate: the run has what it
60825
+ // draws.
60826
+ debugAsk("nothing missing");
60411
60827
  return;
60412
60828
  }
60413
60829
  if (virtual.holdPending && pages.count !== undefined && !askingAroundWantedRow) {
60414
60830
  // The one ask a hold lets through: the row the list is held on is
60415
60831
  // what would lift the hold, and nothing else is going to bring it.
60832
+ debugAsk("held on a row not reached yet");
60416
60833
  return;
60417
60834
  }
60418
60835
  const request = requestRef.current;
60419
60836
  if (revalidating && request.busy) {
60420
60837
  if (request.revalidating) {
60838
+ debugAsk("already revalidating");
60421
60839
  return;
60422
60840
  }
60423
60841
  // A page for a window that is about to be replaced wholesale.
@@ -60435,6 +60853,7 @@ const useItemStore = ({
60435
60853
  // waiting for to exist at all.
60436
60854
  const stillWanted = pages.count === undefined || request.start <= windowTo && request.end >= windowFrom;
60437
60855
  if (stillWanted) {
60856
+ debugAsk("a request still covers this window");
60438
60857
  return;
60439
60858
  }
60440
60859
  request.controller?.abort();
@@ -60445,6 +60864,7 @@ const useItemStore = ({
60445
60864
  // nothing since, can only produce the same answer. A revalidation is
60446
60865
  // exactly the case where it produces another one.
60447
60866
  if (!revalidating && request.start === start && request.end === end && request.held === held) {
60867
+ debugAsk("this range was asked for already");
60448
60868
  return;
60449
60869
  }
60450
60870
  request.start = start;
@@ -60468,6 +60888,7 @@ const useItemStore = ({
60468
60888
  };
60469
60889
  request.busy = true;
60470
60890
  request.revalidating = revalidating;
60891
+ debugAsk("sent");
60471
60892
  if (revalidating) {
60472
60893
  setRefreshing(true);
60473
60894
  }