@jsenv/navi 0.29.393 → 0.29.394

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.
@@ -15580,6 +15580,219 @@ const getParamScope = (params) => {
15580
15580
  return newParamScope;
15581
15581
  };
15582
15582
 
15583
+ /*
15584
+ * The rows a resource's GET landed with, mirrored into a signal the app hands
15585
+ * over — a `stateSignal` with `persists` for a copy that survives a reload, so
15586
+ * that the reload starts on the "refresh over a known answer" line of
15587
+ * docs/data_states.md rather than on the first-load one. Where the signal
15588
+ * keeps its value is the signal's business; this module only decides what the
15589
+ * copy contains, when it enters the store, and when it is rewritten.
15590
+ *
15591
+ * What the signal holds: one item per params the GET was asked with (`""` for
15592
+ * a GET without params), as the store holds it with its relations written
15593
+ * inline — `user: { … }`, `my_games: [ … ]`. That is the shape the GET
15594
+ * callback itself returns, so the copy re-enters the store through the same
15595
+ * setters a real answer goes through and its relations normalize into their
15596
+ * own stores.
15597
+ *
15598
+ * It re-enters at the first run of the GET asking for it, never at
15599
+ * declaration: relations are declared after `resource()` returns, and a row
15600
+ * upserted before them keeps its relation values as plain data.
15601
+ *
15602
+ * The write follows the store, not the callback: an effect reads the item and
15603
+ * every row it reaches through a relation, so a PUT on the item or a list
15604
+ * upserting one of its children rewrites the copy. The signal turning
15605
+ * `undefined` from outside is "forget": nothing is written until a GET lands
15606
+ * again.
15607
+ */
15608
+ const createResourcePersistence = (
15609
+ store,
15610
+ { idKey, name, signal: persistedSignal, when },
15611
+ ) => {
15612
+ if (
15613
+ !persistedSignal ||
15614
+ typeof persistedSignal !== "object" ||
15615
+ typeof persistedSignal.subscribe !== "function"
15616
+ ) {
15617
+ throw new TypeError(
15618
+ `resource("${name}").persist needs a signal to write the copy into, received ${persistedSignal}`,
15619
+ );
15620
+ }
15621
+
15622
+ // paramsKey → item the signal holds that is not in the store yet
15623
+ const storedItemMap = new Map();
15624
+ // paramsKey → id of the row the GET holds for these params
15625
+ const entryMapSignal = signal(new Map());
15626
+ const recordItem = (paramsKey, itemId) => {
15627
+ const entryMap = entryMapSignal.peek();
15628
+ if (entryMap.get(paramsKey) === itemId) {
15629
+ return;
15630
+ }
15631
+ const entryMapUpdated = new Map(entryMap);
15632
+ entryMapUpdated.set(paramsKey, itemId);
15633
+ entryMapSignal.value = entryMapUpdated;
15634
+ };
15635
+ const isAllowed = () => {
15636
+ return when ? Boolean(when()) : true;
15637
+ };
15638
+
15639
+ // An empty signal means "nothing is known": whoever emptied it — sign-out
15640
+ // writing undefined, or `when` saying no — nothing is written again until a
15641
+ // GET lands.
15642
+ const forget = () => {
15643
+ storedItemMap.clear();
15644
+ if (entryMapSignal.peek().size > 0) {
15645
+ entryMapSignal.value = new Map();
15646
+ }
15647
+ };
15648
+ let lastWritten;
15649
+ const write = (value) => {
15650
+ if (compareTwoJsValues(value, lastWritten)) {
15651
+ return;
15652
+ }
15653
+ lastWritten = value;
15654
+ persistedSignal.value = value;
15655
+ };
15656
+ // Runs at once with what the signal already holds (the copy read from
15657
+ // storage), then on every write — the module's own ones are recognized
15658
+ // and left alone.
15659
+ persistedSignal.subscribe((value) => {
15660
+ if (value === lastWritten) {
15661
+ return;
15662
+ }
15663
+ lastWritten = value;
15664
+ storedItemMap.clear();
15665
+ if (value === undefined || value === null) {
15666
+ forget();
15667
+ return;
15668
+ }
15669
+ if (typeof value !== "object") {
15670
+ return;
15671
+ }
15672
+ for (const paramsKey of Object.keys(value)) {
15673
+ storedItemMap.set(paramsKey, value[paramsKey]);
15674
+ }
15675
+ });
15676
+
15677
+ const findItem = (params) => {
15678
+ const paramsKey = getParamsKey(params);
15679
+ return untracked(() => {
15680
+ const itemId = entryMapSignal.peek().get(paramsKey);
15681
+ if (itemId !== undefined) {
15682
+ const item = store.select(itemId);
15683
+ if (item) {
15684
+ return item;
15685
+ }
15686
+ }
15687
+ if (!isAllowed()) {
15688
+ return null;
15689
+ }
15690
+ const storedItem = storedItemMap.get(paramsKey);
15691
+ if (!storedItem || typeof storedItem !== "object") {
15692
+ return null;
15693
+ }
15694
+ storedItemMap.delete(paramsKey);
15695
+ const item = store.upsert(storedItem);
15696
+ recordItem(paramsKey, item[idKey]);
15697
+ return item;
15698
+ });
15699
+ };
15700
+ const recordGetItem = (params, itemId) => {
15701
+ recordItem(getParamsKey(params), itemId);
15702
+ };
15703
+
15704
+ effect(() => {
15705
+ const entryMap = entryMapSignal.value;
15706
+ if (!isAllowed()) {
15707
+ write(undefined);
15708
+ forget();
15709
+ return;
15710
+ }
15711
+ if (entryMap.size === 0) {
15712
+ // Nothing is known yet: the copy is what a GET will draw, it must not
15713
+ // be erased by a page that has not asked anything.
15714
+ return;
15715
+ }
15716
+ const stored = {};
15717
+ for (const [paramsKey, storedItem] of storedItemMap) {
15718
+ stored[paramsKey] = storedItem;
15719
+ }
15720
+ for (const [paramsKey, itemId] of entryMap) {
15721
+ const item = store.select(itemId);
15722
+ if (!item) {
15723
+ continue;
15724
+ }
15725
+ stored[paramsKey] = serializeItem(item, new Set());
15726
+ }
15727
+ write(stored);
15728
+ });
15729
+
15730
+ return {
15731
+ findItem,
15732
+ recordGetItem,
15733
+ };
15734
+ };
15735
+
15736
+ const getParamsKey = (params) => {
15737
+ if (params === undefined || params === NO_PARAMS) {
15738
+ return "";
15739
+ }
15740
+ return stableStringify(params);
15741
+ };
15742
+
15743
+ const stableStringify = (value) => {
15744
+ if (value === null || typeof value !== "object") {
15745
+ return JSON.stringify(value);
15746
+ }
15747
+ if (Array.isArray(value)) {
15748
+ return `[${value.map(stableStringify).join(",")}]`;
15749
+ }
15750
+ const keys = Object.keys(value).sort();
15751
+ const parts = [];
15752
+ for (const key of keys) {
15753
+ parts.push(`${JSON.stringify(key)}:${stableStringify(value[key])}`);
15754
+ }
15755
+ return `{${parts.join(",")}}`;
15756
+ };
15757
+
15758
+ // The item with its relations inline: a relation value stands for what its
15759
+ // signal holds (the child row, the child rows, or nothing), a row reached
15760
+ // twice on the same path is written as its id — the setters accept both.
15761
+ const serializeItem = (item, ancestorSet) => {
15762
+ ancestorSet.add(item);
15763
+ const serialized = {};
15764
+ for (const key of Object.keys(item)) {
15765
+ serialized[key] = serializeValue(item[key], ancestorSet);
15766
+ }
15767
+ ancestorSet.delete(item);
15768
+ return serialized;
15769
+ };
15770
+ const serializeValue = (value, ancestorSet) => {
15771
+ if (value === null || typeof value !== "object") {
15772
+ return value;
15773
+ }
15774
+ if (Array.isArray(value)) {
15775
+ return value.map((entry) => serializeValue(entry, ancestorSet));
15776
+ }
15777
+ const objectSignal = value[SYMBOL_OBJECT_SIGNAL];
15778
+ if (objectSignal) {
15779
+ const held = objectSignal.value;
15780
+ if (held === undefined || held === null) {
15781
+ return null;
15782
+ }
15783
+ return serializeValue(held, ancestorSet);
15784
+ }
15785
+ if (typeof value.toJSON === "function") {
15786
+ return value;
15787
+ }
15788
+ if (ancestorSet.has(value)) {
15789
+ return Object.hasOwn(value, SYMBOL_IDENTITY)
15790
+ ? value[SYMBOL_IDENTITY]
15791
+ : null;
15792
+ }
15793
+ return serializeItem(value, ancestorSet);
15794
+ };
15795
+
15583
15796
  /*
15584
15797
  * GET_RANGE: reading a resource one slice at a time, for a list that draws its
15585
15798
  * rows as it goes (`<List.Items itemsAction>`).
@@ -15830,6 +16043,20 @@ const debug$3 = (...args) => {
15830
16043
  * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, GET_RANGE, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
15831
16044
  * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"`
15832
16045
  * @param {string[]} [restCallbacks.uniqueKeys] - alternate keys the store can find an item by (e.g. `"username"`)
16046
+ * @param {{ signal: import("@preact/signals").Signal, when?: () => boolean }} [restCallbacks.persist] -
16047
+ * mirrors the rows `GET` lands with into `signal` — a `stateSignal` with
16048
+ * `persists: true` and `type: "object"` keeps them across a reload, whose
16049
+ * first `GET` run then draws the last known row while the request is out (a
16050
+ * refresh, not a first load — see docs/data_states.md); the request goes out
16051
+ * as usual and its answer replaces the row. The copy follows the store: a
16052
+ * `PUT`, a list upserting a child, a relation changing rewrite it. Put
16053
+ * whatever makes a stored copy unusable in the signal's `id` (the deployed
16054
+ * version, say): a new id starts clean. `when` is read whenever the copy is
16055
+ * about to be read or written: `false` neither reads it nor writes it, and
16056
+ * empties the signal. Writing `undefined` into the signal (sign-out) forgets
16057
+ * the copy until a `GET` lands again. One row is kept per params the GET was
16058
+ * asked with, so it is meant for a singleton or a handful of rows, not for a
16059
+ * resource whose GET is asked for every id.
15833
16060
  * @see docs/resource.md — relationships, callback return contracts, decision table
15834
16061
  *
15835
16062
  * @example
@@ -15848,6 +16075,7 @@ const resource = (
15848
16075
  uniqueKeys = [],
15849
16076
  rerunOn,
15850
16077
  dependencies,
16078
+ persist,
15851
16079
 
15852
16080
  GET,
15853
16081
  GET_MANY,
@@ -15905,15 +16133,20 @@ const resource = (
15905
16133
  return item;
15906
16134
  },
15907
16135
  });
16136
+ const persistence = persist
16137
+ ? createResourcePersistence(store, { idKey, name, ...persist })
16138
+ : null;
15908
16139
  // The row a GET designates, when the store already holds it — by its params:
15909
16140
  // the value under idKey may be the id or any unique key (a route opening a
15910
16141
  // user by id or by slug names both `id`), and a unique key may be given under
15911
- // its own name. findItemByParams is what a GET draws while its request is in
15912
- // flight (the action's provisionalValue). findItemInStore adds, when the
15913
- // params name no row (a GET without params, or whose params carry no key),
15914
- // the row the action last completed with: that fallback answers a GET under
15915
- // a network policy (see applyNetworkPolicy) and only there — a GET of a row
15916
- // the store lacks must draw its skeleton, never the row read just before.
16142
+ // its own name, or, on a persisted resource, as the row kept for these
16143
+ // params (a GET without params has one row). findItemForGet is what a GET
16144
+ // draws while its request is in flight (the action's provisionalValue).
16145
+ // findItemInStore adds, when the params name no row (a GET without params,
16146
+ // or whose params carry no key), the row the action last completed with:
16147
+ // that fallback answers a GET under a network policy (see
16148
+ // applyNetworkPolicy) and only there — a GET of a row the store lacks must
16149
+ // draw its skeleton, never the row read just before.
15917
16150
  const selectByAnyKey = (value) => {
15918
16151
  const item = store.select(value);
15919
16152
  if (item) {
@@ -15952,11 +16185,21 @@ const resource = (
15952
16185
  }
15953
16186
  return null;
15954
16187
  };
16188
+ const findItemForGet = (params) => {
16189
+ const itemByParams = findItemByParams(params);
16190
+ if (itemByParams) {
16191
+ return itemByParams;
16192
+ }
16193
+ if (persistence) {
16194
+ return persistence.findItem(params);
16195
+ }
16196
+ return null;
16197
+ };
15955
16198
  const findItemInStore = (params, action) => {
15956
16199
  return untracked(() => {
15957
- const itemByParams = findItemByParams(params);
15958
- if (itemByParams) {
15959
- return itemByParams;
16200
+ const itemForGet = findItemForGet(params);
16201
+ if (itemForGet) {
16202
+ return itemForGet;
15960
16203
  }
15961
16204
  const lastItemId = getLastGetItemId(action);
15962
16205
  if (lastItemId === undefined) {
@@ -15968,7 +16211,8 @@ const resource = (
15968
16211
  const createRestActionForRoot = createRestActionFactoryForRoot(name, {
15969
16212
  idKey,
15970
16213
  store,
15971
- findItemByParams,
16214
+ findItemForGet,
16215
+ persistence,
15972
16216
  declarationSite,
15973
16217
  });
15974
16218
  return createResource(name, {
@@ -17066,7 +17310,8 @@ const createRestActionFactoryForRoot = (
17066
17310
  {
17067
17311
  idKey,
17068
17312
  store, // see array_signal_store.js
17069
- findItemByParams,
17313
+ findItemForGet,
17314
+ persistence,
17070
17315
  declarationSite,
17071
17316
  },
17072
17317
  ) => {
@@ -17133,6 +17378,9 @@ const createRestActionFactoryForRoot = (
17133
17378
  recordGetResultProperties(action, Object.keys(result));
17134
17379
  const itemId = applyResultToValue(result);
17135
17380
  lastGetItemIdWeakMap.set(action, itemId);
17381
+ if (persistence) {
17382
+ persistence.recordGetItem(action.params, itemId);
17383
+ }
17136
17384
  return itemId;
17137
17385
  }
17138
17386
  return applyResultToValue(result);
@@ -17144,7 +17392,7 @@ const createRestActionFactoryForRoot = (
17144
17392
  provisionalValue:
17145
17393
  verb === "GET"
17146
17394
  ? (params) => {
17147
- const item = untracked(() => findItemByParams(params));
17395
+ const item = untracked(() => findItemForGet(params));
17148
17396
  return item ? item[idKey] : undefined;
17149
17397
  }
17150
17398
  : undefined,
@@ -17547,7 +17795,15 @@ const generateSignalId = () => {
17547
17795
  * refuse an address written too often (Safari: 100 writes per 10 s, then a
17548
17796
  * SecurityError). A value one DRAGS is written at 60 Hz: keep it in the
17549
17797
  * gesture while the finger is down and write the state once, on release —
17550
- * an address is not a recording of a gesture.
17798
+ * an address is not a recording of a gesture. When every intermediate value
17799
+ * IS a valid address (a map centre), see `debounceUrl`.
17800
+ * @param {number} [options.debounceUrl=0] - Milliseconds the browser's address
17801
+ * waits for this state to settle before a replace is written: writes closer
17802
+ * together than that make one write, with the last value. The routes, the
17803
+ * document url and the renders still move on every write; only
17804
+ * `window.location` is behind. For a state legitimately written per frame
17805
+ * (the centre of a panned map, the hour of a dragged sun), e.g. 200. A push
17806
+ * is never debounced.
17551
17807
  * @param {boolean} [options.debug=false] - Enable debug logging for this signal's operations
17552
17808
  * @returns {import("@preact/signals").Signal} A signal that can be synchronized with a source signal and/or persisted in localStorage. The signal includes a `validity` property for validation state.
17553
17809
  *
@@ -17622,6 +17878,7 @@ const stateSignal = (defaultValue, options = {}) => {
17622
17878
  autoFix,
17623
17879
  weak = false,
17624
17880
  history = "replace",
17881
+ debounceUrl = 0,
17625
17882
  } = options;
17626
17883
 
17627
17884
  if (history !== "replace" && history !== "push") {
@@ -17629,6 +17886,11 @@ const stateSignal = (defaultValue, options = {}) => {
17629
17886
  `stateSignal "${id}": history must be "replace" or "push", got ${history}.`,
17630
17887
  );
17631
17888
  }
17889
+ if (typeof debounceUrl !== "number" || debounceUrl < 0) {
17890
+ throw new TypeError(
17891
+ `stateSignal "${id}": debounceUrl must be a number of milliseconds, got ${debounceUrl}.`,
17892
+ );
17893
+ }
17632
17894
  if (weak && persists) {
17633
17895
  throw new TypeError(
17634
17896
  `stateSignal "${id}": weak and persists are contradictory — a weak param qualifies one visit, it cannot be restored from a previous session.`,
@@ -17984,6 +18246,7 @@ const stateSignal = (defaultValue, options = {}) => {
17984
18246
  ...options,
17985
18247
  history,
17986
18248
  getHistory: () => historyForCurrentWrite || history,
18249
+ debounceUrl,
17987
18250
  };
17988
18251
  globalSignalRegistry.set(signalIdString, {
17989
18252
  signal: facadeSignal,
@@ -26127,19 +26390,23 @@ const route = (
26127
26390
  const routeUrl = route.buildUrl(params);
26128
26391
  return integration.navTo(routeUrl, options);
26129
26392
  };
26130
- route.redirectTo = (params, { callReason, history = "replace" } = {}) => {
26393
+ route.redirectTo = (
26394
+ params,
26395
+ { callReason, history = "replace", debounceUrl } = {},
26396
+ ) => {
26131
26397
  if (!integration) {
26132
26398
  return Promise.resolve();
26133
26399
  }
26134
26400
  const routeUrl = route.buildUrl(params);
26135
26401
  return integration.navTo(routeUrl, {
26136
26402
  replace: history !== "push",
26403
+ debounce: debounceUrl,
26137
26404
  callReason,
26138
26405
  });
26139
26406
  };
26140
26407
  route.replaceParams = (
26141
26408
  newParams,
26142
- { callReason, isSignalChange, history = "replace" } = {},
26409
+ { callReason, isSignalChange, history = "replace", debounceUrl } = {},
26143
26410
  ) => {
26144
26411
  const matching = route.matchingSignal.peek();
26145
26412
  if (!matching) {
@@ -26194,6 +26461,7 @@ const route = (
26194
26461
  return mostSpecificRoute.redirectTo(newParams, {
26195
26462
  callReason: `replaceParams delegation from ${route} to ${mostSpecificRoute} (original reason: ${callReason})`,
26196
26463
  history,
26464
+ debounceUrl,
26197
26465
  });
26198
26466
  }
26199
26467
 
@@ -26210,6 +26478,7 @@ const route = (
26210
26478
  );
26211
26479
  return integration.navTo(targetUrl, {
26212
26480
  replace: history !== "push",
26481
+ debounce: debounceUrl,
26213
26482
  callReason,
26214
26483
  });
26215
26484
  };
@@ -26287,6 +26556,9 @@ const route = (
26287
26556
  // which is what a param qualifying a screen is.
26288
26557
  const historyOfWrite = () =>
26289
26558
  paramSignal.options?.getHistory?.() || "replace";
26559
+ // Milliseconds the browser's address waits for this state to settle
26560
+ // (see stateSignal's `debounceUrl`); 0 writes at once. A plain signal says nothing.
26561
+ const debounceUrl = paramSignal.options?.debounceUrl || 0;
26290
26562
  if (debug) {
26291
26563
  console.debug(
26292
26564
  `[route] connecting url param "${paramName}" to signal`,
@@ -26330,6 +26602,7 @@ const route = (
26330
26602
  callReason: `${paramName} signal change on ${route}`,
26331
26603
  isSignalChange: true,
26332
26604
  history: historyOfWrite(),
26605
+ debounceUrl,
26333
26606
  },
26334
26607
  );
26335
26608
  return;
@@ -26348,6 +26621,7 @@ const route = (
26348
26621
  callReason: `${paramName} signal reset to default on ${route}`,
26349
26622
  isSignalChange: true,
26350
26623
  history: historyOfWrite(),
26624
+ debounceUrl,
26351
26625
  },
26352
26626
  );
26353
26627
  return;
@@ -26368,6 +26642,7 @@ const route = (
26368
26642
  callReason: `${paramName} signal change on ${route}`,
26369
26643
  isSignalChange: true,
26370
26644
  history: historyOfWrite(),
26645
+ debounceUrl,
26371
26646
  },
26372
26647
  );
26373
26648
  });
@@ -27660,8 +27935,6 @@ const setupBrowserIntegrationViaHistory = ({
27660
27935
  applyRouting,
27661
27936
  isRouting,
27662
27937
  }) => {
27663
- const { history } = window;
27664
-
27665
27938
  let globalAbortController = new AbortController();
27666
27939
  const triggerGlobalAbort = (reason) => {
27667
27940
  globalAbortController.abort(reason);
@@ -27679,7 +27952,8 @@ const setupBrowserIntegrationViaHistory = ({
27679
27952
  setActionDispatcher(dispatchActions);
27680
27953
 
27681
27954
  const getDocumentState = () => {
27682
- return window.history.state ? { ...window.history.state } : null;
27955
+ const entryState = readEntryState();
27956
+ return entryState ? { ...entryState } : null;
27683
27957
  };
27684
27958
 
27685
27959
  const historyStartAtStart = getDocumentState();
@@ -27693,7 +27967,7 @@ const setupBrowserIntegrationViaHistory = ({
27693
27967
  const visitedUrlsSignal = signal(0);
27694
27968
 
27695
27969
  const isVisited = (url) => {
27696
- url = new URL(url, window.location.href).href;
27970
+ url = new URL(url, readAddress()).href;
27697
27971
  return visitedUrlSet.has(url);
27698
27972
  };
27699
27973
  const markUrlAsVisited = (url) => {
@@ -27755,20 +28029,20 @@ const setupBrowserIntegrationViaHistory = ({
27755
28029
  let abortController = null;
27756
28030
  const handleRoutingTask = (target, options) => {
27757
28031
  // Everything below this line reasons on the URL as a whole: it is compared
27758
- // to window.location.href, looked up in the history stack, written into the
28032
+ // to the address, looked up in the history stack, written into the
27759
28033
  // document url signal and parsed there. A relative target ("/", "../x")
27760
28034
  // would silently lose every one of those — the browser would still resolve
27761
28035
  // it in pushState, but nothing else here would. So it is resolved once, at
27762
28036
  // the single door every navigation goes through, rather than by each caller
27763
28037
  // (navBack's fallback in particular arrives here raw).
27764
- const url = new URL(target, window.location.href).href;
28038
+ const url = new URL(target, readAddress()).href;
27765
28039
  // An address that only sends elsewhere never becomes anything here: asked
27766
28040
  // before the announcement, before the history write and before the routes,
27767
28041
  // so that what follows is entirely about where the reader is going (see
27768
28042
  // resolveRouteRedirection).
27769
28043
  const redirectionUrl = resolveRouteRedirection(url);
27770
28044
  if (redirectionUrl) {
27771
- if (redirectionUrl === window.location.href) {
28045
+ if (redirectionUrl === readAddress()) {
27772
28046
  // Asked to go where we already are: an address that redirects is never
27773
28047
  // the one being displayed, so there is nothing to go to and nothing to
27774
28048
  // stack on the history.
@@ -27787,7 +28061,7 @@ const setupBrowserIntegrationViaHistory = ({
27787
28061
  if (
27788
28062
  options.navigationType === "push" &&
27789
28063
  options.state === undefined &&
27790
- url !== window.location.href
28064
+ url !== readAddress()
27791
28065
  ) {
27792
28066
  const delta = adjacentEntryDelta(url);
27793
28067
  if (delta === -1) {
@@ -27828,12 +28102,13 @@ const setupBrowserIntegrationViaHistory = ({
27828
28102
  const applyRoutingTask = (url, options) => {
27829
28103
  // Read before the history is written: the url the reader is being taken
27830
28104
  // away from, which is what decides where a push lands (see startAtTop).
27831
- const urlLeft = window.location.href;
28105
+ const urlLeft = readAddress();
27832
28106
  const {
27833
28107
  reason,
27834
28108
  navigationType, // "load", "reload", "replace", "push", "traverse"
27835
28109
  redirected,
27836
28110
  landOn,
28111
+ debounce,
27837
28112
  } = options;
27838
28113
  let { state } = options;
27839
28114
 
@@ -27852,7 +28127,14 @@ const setupBrowserIntegrationViaHistory = ({
27852
28127
  [NAV_DEPTH_STATE_KEY]: getNavDepth(),
27853
28128
  },
27854
28129
  });
27855
- writeHistoryEntry(navigationType, effectiveState, url);
28130
+ if (navigationType === "replace" && debounce && state === undefined) {
28131
+ // Nothing of its own for the entry: the address moves, the state is
28132
+ // carried over. The one kind of write allowed to lag, and only when
28133
+ // the state written asked for it (see replaceAddressWhenSettled).
28134
+ replaceAddressWhenSettled(effectiveState, url, debounce);
28135
+ } else {
28136
+ writeHistoryEntry(navigationType, effectiveState, url);
28137
+ }
27856
28138
  rememberEntryIsOfThisDocument();
27857
28139
  updateDocumentUrl(url);
27858
28140
  updateDocumentState(effectiveState);
@@ -28043,7 +28325,20 @@ const setupBrowserIntegrationViaHistory = ({
28043
28325
  // being left must be recorded from the first pixel scrolled.
28044
28326
  installScrollRestoration();
28045
28327
 
28328
+ // The document is leaving, or may be killed in the background: what the
28329
+ // entry says about it has to be what the document says (see
28330
+ // replaceAddressWhenSettled).
28331
+ window.addEventListener("pagehide", flushPendingReplace);
28332
+ document.addEventListener("visibilitychange", () => {
28333
+ if (document.visibilityState === "hidden") {
28334
+ flushPendingReplace();
28335
+ }
28336
+ });
28337
+
28046
28338
  window.addEventListener("popstate", (popstateEvent) => {
28339
+ // The entry a pending replace was for is no longer the current one, and
28340
+ // the History API has no way to write it (see replaceAddressWhenSettled).
28341
+ dropPendingReplace();
28047
28342
  const url = window.location.href;
28048
28343
  const state = popstateEvent.state;
28049
28344
  const landOn = landOnPending;
@@ -28072,11 +28367,18 @@ const setupBrowserIntegrationViaHistory = ({
28072
28367
  updateDocumentUrl(window.location.href);
28073
28368
  });
28074
28369
 
28075
- const navTo = async (url, { replace, state, routeTransition } = {}) => {
28370
+ const navTo = async (
28371
+ url,
28372
+ { replace, state, routeTransition, debounce } = {},
28373
+ ) => {
28076
28374
  handleRoutingTask(url, {
28077
28375
  reason: `navTo called with "${url}"`,
28078
28376
  navigationType: replace ? "replace" : "push",
28079
28377
  state,
28378
+ // Milliseconds the browser's address waits for this replace to settle
28379
+ // (see replaceAddressWhenSettled); said by the state being written
28380
+ // (stateSignal's `debounceUrl`), never assumed.
28381
+ debounce,
28080
28382
  // What this one navigation asks of a route transition, said by the call
28081
28383
  // that starts it rather than by an element — the programmatic half of
28082
28384
  // what a <Link routeTransition> says (see route_transition.jsx).
@@ -28089,8 +28391,8 @@ const setupBrowserIntegrationViaHistory = ({
28089
28391
  };
28090
28392
 
28091
28393
  const reload = () => {
28092
- const url = window.location.href;
28093
- const state = history.state;
28394
+ const url = readAddress();
28395
+ const state = readEntryState();
28094
28396
  handleRoutingTask(url, {
28095
28397
  reason: "reload called",
28096
28398
  navigationType: "reload",
@@ -28161,8 +28463,55 @@ const setupBrowserIntegrationViaHistory = ({
28161
28463
  visitedUrlsSignal,
28162
28464
  };
28163
28465
  };
28164
- const ADDRESS_WRITE_ADVICE = `An address is not a recording of a gesture: a value dragged per frame belongs to the gesture, and the state bound to the url is written once, on release.`;
28466
+ const ADDRESS_WRITE_ADVICE = `An address is not a recording of a gesture: a value dragged per frame belongs to the gesture, and the state bound to the url is written once, on release. A state whose every intermediate value is a valid address can declare debounceUrl (milliseconds) instead.`;
28467
+
28468
+ // The replace waiting for the state to settle, and the address this document
28469
+ // stands at meanwhile. Read by everything here that would otherwise read
28470
+ // window.location.href or window.history.state: the document is at the
28471
+ // pending address, the browser just does not say so yet.
28472
+ let pendingReplace = null;
28473
+ let pendingReplaceTimer = null;
28474
+ const readAddress = () => {
28475
+ return pendingReplace ? pendingReplace.url : window.location.href;
28476
+ };
28477
+ const readEntryState = () => {
28478
+ return pendingReplace ? pendingReplace.state : window.history.state;
28479
+ };
28480
+ const replaceAddressWhenSettled = (state, url, debounce) => {
28481
+ if (pendingReplaceTimer !== null) {
28482
+ clearTimeout(pendingReplaceTimer);
28483
+ }
28484
+ pendingReplace = { state, url };
28485
+ pendingReplaceTimer = setTimeout(flushPendingReplace, debounce);
28486
+ };
28487
+ const flushPendingReplace = () => {
28488
+ const pending = pendingReplace;
28489
+ dropPendingReplace();
28490
+ if (pending) {
28491
+ writeHistoryEntryNow("replace", pending.state, pending.url);
28492
+ }
28493
+ };
28494
+ const dropPendingReplace = () => {
28495
+ pendingReplace = null;
28496
+ if (pendingReplaceTimer !== null) {
28497
+ clearTimeout(pendingReplaceTimer);
28498
+ pendingReplaceTimer = null;
28499
+ }
28500
+ };
28165
28501
  const writeHistoryEntry = (navigationType, state, url) => {
28502
+ if (pendingReplace) {
28503
+ if (pendingReplace.url === url) {
28504
+ // This write stands where the pending one would: its state was built on
28505
+ // the pending state (see readEntryState), so it says everything the
28506
+ // pending write had to say.
28507
+ dropPendingReplace();
28508
+ } else {
28509
+ flushPendingReplace();
28510
+ }
28511
+ }
28512
+ writeHistoryEntryNow(navigationType, state, url);
28513
+ };
28514
+ const writeHistoryEntryNow = (navigationType, state, url) => {
28166
28515
  try {
28167
28516
  if (navigationType === "push") {
28168
28517
  window.history.pushState(state, null, url);
@@ -28180,10 +28529,14 @@ const writeHistoryEntry = (navigationType, state, url) => {
28180
28529
  }
28181
28530
  };
28182
28531
 
28532
+ // Read before the history is written, in both places that ask: a push or a
28533
+ // replace onto the url already displayed. Never a traverse — the browser has
28534
+ // already moved the address when a popstate is handled, so the comparison
28535
+ // would say "same" about every back and forward.
28183
28536
  const isStateOnlyNavigation = (
28184
28537
  url,
28185
28538
  { navigationType },
28186
- urlLeft = window.location.href,
28539
+ urlLeft = readAddress(),
28187
28540
  ) =>
28188
28541
  url === urlLeft &&
28189
28542
  (navigationType === "push" || navigationType === "replace");
@@ -28310,8 +28663,10 @@ setRouteIntegration(browserIntegration);
28310
28663
 
28311
28664
  const navIntegratedVia = browserIntegration.integration;
28312
28665
  const navTo = (target, options) => {
28313
- const url = new URL(target, window.location.href).href;
28666
+ // Resolved against the address this document says it is at, not the one the
28667
+ // browser shows: a replace may be on its way there (see via_history.js).
28314
28668
  const currentUrl = documentUrlSignal.peek();
28669
+ const url = new URL(target, currentUrl).href;
28315
28670
  if (url === currentUrl) {
28316
28671
  if (options?.state === undefined) {
28317
28672
  return null;
@@ -28445,7 +28800,7 @@ const useNavStateBasic = (
28445
28800
  return;
28446
28801
  }
28447
28802
  currentStateCopy[id] = value;
28448
- navTo(window.location.href, {
28803
+ navTo(documentUrlSignal.peek(), {
28449
28804
  replace: effectiveType !== "push",
28450
28805
  state: currentStateCopy,
28451
28806
  });
@@ -28479,12 +28834,12 @@ const useNavStateBasic = (
28479
28834
  }
28480
28835
  delete currentStateCopy[id];
28481
28836
  browserIntegration.navBack({
28482
- landOn: { url: window.location.href, state: currentStateCopy },
28837
+ landOn: { url: documentUrlSignal.peek(), state: currentStateCopy },
28483
28838
  });
28484
28839
  return;
28485
28840
  }
28486
28841
  delete currentStateCopy[id];
28487
- navTo(window.location.href, {
28842
+ navTo(documentUrlSignal.peek(), {
28488
28843
  replace: true,
28489
28844
  state: currentStateCopy,
28490
28845
  });
@@ -28538,7 +28893,7 @@ const useUrlSearchParam = (paramName, defaultValue) => {
28538
28893
  }
28539
28894
 
28540
28895
  const setSearchParamValue = (newValue, { replace = false } = {}) => {
28541
- const newUrlObject = new URL(window.location.href);
28896
+ const newUrlObject = new URL(documentUrlSignal.peek());
28542
28897
  newUrlObject.searchParams.set(paramName, newValue);
28543
28898
  const newUrl = newUrlObject.href;
28544
28899
  navTo(newUrl, { replace });
@@ -35979,7 +36334,9 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
35979
36334
  return;
35980
36335
  }
35981
36336
  writeInSignal(signal, closedValue, { history: "replace" });
35982
- navBack({ landOn: { url: window.location.href } });
36337
+ // The address this document says it is at: the one the browser shows may
36338
+ // be a replace behind it (see via_history.js).
36339
+ navBack({ landOn: { url: documentUrlSignal.peek() } });
35983
36340
  return;
35984
36341
  }
35985
36342
  writeInSignal(signal, closedValue, { history: "replace" });