@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.
@@ -16177,6 +16177,219 @@ const getParamScope = (params) => {
16177
16177
  return newParamScope;
16178
16178
  };
16179
16179
 
16180
+ /*
16181
+ * The rows a resource's GET landed with, mirrored into a signal the app hands
16182
+ * over — a `stateSignal` with `persists` for a copy that survives a reload, so
16183
+ * that the reload starts on the "refresh over a known answer" line of
16184
+ * docs/data_states.md rather than on the first-load one. Where the signal
16185
+ * keeps its value is the signal's business; this module only decides what the
16186
+ * copy contains, when it enters the store, and when it is rewritten.
16187
+ *
16188
+ * What the signal holds: one item per params the GET was asked with (`""` for
16189
+ * a GET without params), as the store holds it with its relations written
16190
+ * inline — `user: { … }`, `my_games: [ … ]`. That is the shape the GET
16191
+ * callback itself returns, so the copy re-enters the store through the same
16192
+ * setters a real answer goes through and its relations normalize into their
16193
+ * own stores.
16194
+ *
16195
+ * It re-enters at the first run of the GET asking for it, never at
16196
+ * declaration: relations are declared after `resource()` returns, and a row
16197
+ * upserted before them keeps its relation values as plain data.
16198
+ *
16199
+ * The write follows the store, not the callback: an effect reads the item and
16200
+ * every row it reaches through a relation, so a PUT on the item or a list
16201
+ * upserting one of its children rewrites the copy. The signal turning
16202
+ * `undefined` from outside is "forget": nothing is written until a GET lands
16203
+ * again.
16204
+ */
16205
+ const createResourcePersistence = (
16206
+ store,
16207
+ { idKey, name, signal: persistedSignal, when },
16208
+ ) => {
16209
+ if (
16210
+ !persistedSignal ||
16211
+ typeof persistedSignal !== "object" ||
16212
+ typeof persistedSignal.subscribe !== "function"
16213
+ ) {
16214
+ throw new TypeError(
16215
+ `resource("${name}").persist needs a signal to write the copy into, received ${persistedSignal}`,
16216
+ );
16217
+ }
16218
+
16219
+ // paramsKey → item the signal holds that is not in the store yet
16220
+ const storedItemMap = new Map();
16221
+ // paramsKey → id of the row the GET holds for these params
16222
+ const entryMapSignal = signal(new Map());
16223
+ const recordItem = (paramsKey, itemId) => {
16224
+ const entryMap = entryMapSignal.peek();
16225
+ if (entryMap.get(paramsKey) === itemId) {
16226
+ return;
16227
+ }
16228
+ const entryMapUpdated = new Map(entryMap);
16229
+ entryMapUpdated.set(paramsKey, itemId);
16230
+ entryMapSignal.value = entryMapUpdated;
16231
+ };
16232
+ const isAllowed = () => {
16233
+ return when ? Boolean(when()) : true;
16234
+ };
16235
+
16236
+ // An empty signal means "nothing is known": whoever emptied it — sign-out
16237
+ // writing undefined, or `when` saying no — nothing is written again until a
16238
+ // GET lands.
16239
+ const forget = () => {
16240
+ storedItemMap.clear();
16241
+ if (entryMapSignal.peek().size > 0) {
16242
+ entryMapSignal.value = new Map();
16243
+ }
16244
+ };
16245
+ let lastWritten;
16246
+ const write = (value) => {
16247
+ if (compareTwoJsValues(value, lastWritten)) {
16248
+ return;
16249
+ }
16250
+ lastWritten = value;
16251
+ persistedSignal.value = value;
16252
+ };
16253
+ // Runs at once with what the signal already holds (the copy read from
16254
+ // storage), then on every write — the module's own ones are recognized
16255
+ // and left alone.
16256
+ persistedSignal.subscribe((value) => {
16257
+ if (value === lastWritten) {
16258
+ return;
16259
+ }
16260
+ lastWritten = value;
16261
+ storedItemMap.clear();
16262
+ if (value === undefined || value === null) {
16263
+ forget();
16264
+ return;
16265
+ }
16266
+ if (typeof value !== "object") {
16267
+ return;
16268
+ }
16269
+ for (const paramsKey of Object.keys(value)) {
16270
+ storedItemMap.set(paramsKey, value[paramsKey]);
16271
+ }
16272
+ });
16273
+
16274
+ const findItem = (params) => {
16275
+ const paramsKey = getParamsKey(params);
16276
+ return untracked(() => {
16277
+ const itemId = entryMapSignal.peek().get(paramsKey);
16278
+ if (itemId !== undefined) {
16279
+ const item = store.select(itemId);
16280
+ if (item) {
16281
+ return item;
16282
+ }
16283
+ }
16284
+ if (!isAllowed()) {
16285
+ return null;
16286
+ }
16287
+ const storedItem = storedItemMap.get(paramsKey);
16288
+ if (!storedItem || typeof storedItem !== "object") {
16289
+ return null;
16290
+ }
16291
+ storedItemMap.delete(paramsKey);
16292
+ const item = store.upsert(storedItem);
16293
+ recordItem(paramsKey, item[idKey]);
16294
+ return item;
16295
+ });
16296
+ };
16297
+ const recordGetItem = (params, itemId) => {
16298
+ recordItem(getParamsKey(params), itemId);
16299
+ };
16300
+
16301
+ effect(() => {
16302
+ const entryMap = entryMapSignal.value;
16303
+ if (!isAllowed()) {
16304
+ write(undefined);
16305
+ forget();
16306
+ return;
16307
+ }
16308
+ if (entryMap.size === 0) {
16309
+ // Nothing is known yet: the copy is what a GET will draw, it must not
16310
+ // be erased by a page that has not asked anything.
16311
+ return;
16312
+ }
16313
+ const stored = {};
16314
+ for (const [paramsKey, storedItem] of storedItemMap) {
16315
+ stored[paramsKey] = storedItem;
16316
+ }
16317
+ for (const [paramsKey, itemId] of entryMap) {
16318
+ const item = store.select(itemId);
16319
+ if (!item) {
16320
+ continue;
16321
+ }
16322
+ stored[paramsKey] = serializeItem(item, new Set());
16323
+ }
16324
+ write(stored);
16325
+ });
16326
+
16327
+ return {
16328
+ findItem,
16329
+ recordGetItem,
16330
+ };
16331
+ };
16332
+
16333
+ const getParamsKey = (params) => {
16334
+ if (params === undefined || params === NO_PARAMS) {
16335
+ return "";
16336
+ }
16337
+ return stableStringify(params);
16338
+ };
16339
+
16340
+ const stableStringify = (value) => {
16341
+ if (value === null || typeof value !== "object") {
16342
+ return JSON.stringify(value);
16343
+ }
16344
+ if (Array.isArray(value)) {
16345
+ return `[${value.map(stableStringify).join(",")}]`;
16346
+ }
16347
+ const keys = Object.keys(value).sort();
16348
+ const parts = [];
16349
+ for (const key of keys) {
16350
+ parts.push(`${JSON.stringify(key)}:${stableStringify(value[key])}`);
16351
+ }
16352
+ return `{${parts.join(",")}}`;
16353
+ };
16354
+
16355
+ // The item with its relations inline: a relation value stands for what its
16356
+ // signal holds (the child row, the child rows, or nothing), a row reached
16357
+ // twice on the same path is written as its id — the setters accept both.
16358
+ const serializeItem = (item, ancestorSet) => {
16359
+ ancestorSet.add(item);
16360
+ const serialized = {};
16361
+ for (const key of Object.keys(item)) {
16362
+ serialized[key] = serializeValue(item[key], ancestorSet);
16363
+ }
16364
+ ancestorSet.delete(item);
16365
+ return serialized;
16366
+ };
16367
+ const serializeValue = (value, ancestorSet) => {
16368
+ if (value === null || typeof value !== "object") {
16369
+ return value;
16370
+ }
16371
+ if (Array.isArray(value)) {
16372
+ return value.map((entry) => serializeValue(entry, ancestorSet));
16373
+ }
16374
+ const objectSignal = value[SYMBOL_OBJECT_SIGNAL];
16375
+ if (objectSignal) {
16376
+ const held = objectSignal.value;
16377
+ if (held === undefined || held === null) {
16378
+ return null;
16379
+ }
16380
+ return serializeValue(held, ancestorSet);
16381
+ }
16382
+ if (typeof value.toJSON === "function") {
16383
+ return value;
16384
+ }
16385
+ if (ancestorSet.has(value)) {
16386
+ return Object.hasOwn(value, SYMBOL_IDENTITY)
16387
+ ? value[SYMBOL_IDENTITY]
16388
+ : null;
16389
+ }
16390
+ return serializeItem(value, ancestorSet);
16391
+ };
16392
+
16180
16393
  /*
16181
16394
  * GET_RANGE: reading a resource one slice at a time, for a list that draws its
16182
16395
  * rows as it goes (`<List.Items itemsAction>`).
@@ -16427,6 +16640,20 @@ const debug$3 = (...args) => {
16427
16640
  * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, GET_RANGE, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
16428
16641
  * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"`
16429
16642
  * @param {string[]} [restCallbacks.uniqueKeys] - alternate keys the store can find an item by (e.g. `"username"`)
16643
+ * @param {{ signal: import("@preact/signals").Signal, when?: () => boolean }} [restCallbacks.persist] -
16644
+ * mirrors the rows `GET` lands with into `signal` — a `stateSignal` with
16645
+ * `persists: true` and `type: "object"` keeps them across a reload, whose
16646
+ * first `GET` run then draws the last known row while the request is out (a
16647
+ * refresh, not a first load — see docs/data_states.md); the request goes out
16648
+ * as usual and its answer replaces the row. The copy follows the store: a
16649
+ * `PUT`, a list upserting a child, a relation changing rewrite it. Put
16650
+ * whatever makes a stored copy unusable in the signal's `id` (the deployed
16651
+ * version, say): a new id starts clean. `when` is read whenever the copy is
16652
+ * about to be read or written: `false` neither reads it nor writes it, and
16653
+ * empties the signal. Writing `undefined` into the signal (sign-out) forgets
16654
+ * the copy until a `GET` lands again. One row is kept per params the GET was
16655
+ * asked with, so it is meant for a singleton or a handful of rows, not for a
16656
+ * resource whose GET is asked for every id.
16430
16657
  * @see docs/resource.md — relationships, callback return contracts, decision table
16431
16658
  *
16432
16659
  * @example
@@ -16445,6 +16672,7 @@ const resource = (
16445
16672
  uniqueKeys = [],
16446
16673
  rerunOn,
16447
16674
  dependencies,
16675
+ persist,
16448
16676
 
16449
16677
  GET,
16450
16678
  GET_MANY,
@@ -16502,15 +16730,20 @@ const resource = (
16502
16730
  return item;
16503
16731
  },
16504
16732
  });
16733
+ const persistence = persist
16734
+ ? createResourcePersistence(store, { idKey, name, ...persist })
16735
+ : null;
16505
16736
  // The row a GET designates, when the store already holds it — by its params:
16506
16737
  // the value under idKey may be the id or any unique key (a route opening a
16507
16738
  // user by id or by slug names both `id`), and a unique key may be given under
16508
- // its own name. findItemByParams is what a GET draws while its request is in
16509
- // flight (the action's provisionalValue). findItemInStore adds, when the
16510
- // params name no row (a GET without params, or whose params carry no key),
16511
- // the row the action last completed with: that fallback answers a GET under
16512
- // a network policy (see applyNetworkPolicy) and only there — a GET of a row
16513
- // the store lacks must draw its skeleton, never the row read just before.
16739
+ // its own name, or, on a persisted resource, as the row kept for these
16740
+ // params (a GET without params has one row). findItemForGet is what a GET
16741
+ // draws while its request is in flight (the action's provisionalValue).
16742
+ // findItemInStore adds, when the params name no row (a GET without params,
16743
+ // or whose params carry no key), the row the action last completed with:
16744
+ // that fallback answers a GET under a network policy (see
16745
+ // applyNetworkPolicy) and only there — a GET of a row the store lacks must
16746
+ // draw its skeleton, never the row read just before.
16514
16747
  const selectByAnyKey = (value) => {
16515
16748
  const item = store.select(value);
16516
16749
  if (item) {
@@ -16549,11 +16782,21 @@ const resource = (
16549
16782
  }
16550
16783
  return null;
16551
16784
  };
16785
+ const findItemForGet = (params) => {
16786
+ const itemByParams = findItemByParams(params);
16787
+ if (itemByParams) {
16788
+ return itemByParams;
16789
+ }
16790
+ if (persistence) {
16791
+ return persistence.findItem(params);
16792
+ }
16793
+ return null;
16794
+ };
16552
16795
  const findItemInStore = (params, action) => {
16553
16796
  return untracked(() => {
16554
- const itemByParams = findItemByParams(params);
16555
- if (itemByParams) {
16556
- return itemByParams;
16797
+ const itemForGet = findItemForGet(params);
16798
+ if (itemForGet) {
16799
+ return itemForGet;
16557
16800
  }
16558
16801
  const lastItemId = getLastGetItemId(action);
16559
16802
  if (lastItemId === undefined) {
@@ -16565,7 +16808,8 @@ const resource = (
16565
16808
  const createRestActionForRoot = createRestActionFactoryForRoot(name, {
16566
16809
  idKey,
16567
16810
  store,
16568
- findItemByParams,
16811
+ findItemForGet,
16812
+ persistence,
16569
16813
  declarationSite,
16570
16814
  });
16571
16815
  return createResource(name, {
@@ -17663,7 +17907,8 @@ const createRestActionFactoryForRoot = (
17663
17907
  {
17664
17908
  idKey,
17665
17909
  store, // see array_signal_store.js
17666
- findItemByParams,
17910
+ findItemForGet,
17911
+ persistence,
17667
17912
  declarationSite,
17668
17913
  },
17669
17914
  ) => {
@@ -17730,6 +17975,9 @@ const createRestActionFactoryForRoot = (
17730
17975
  recordGetResultProperties(action, Object.keys(result));
17731
17976
  const itemId = applyResultToValue(result);
17732
17977
  lastGetItemIdWeakMap.set(action, itemId);
17978
+ if (persistence) {
17979
+ persistence.recordGetItem(action.params, itemId);
17980
+ }
17733
17981
  return itemId;
17734
17982
  }
17735
17983
  return applyResultToValue(result);
@@ -17741,7 +17989,7 @@ const createRestActionFactoryForRoot = (
17741
17989
  provisionalValue:
17742
17990
  verb === "GET"
17743
17991
  ? (params) => {
17744
- const item = untracked(() => findItemByParams(params));
17992
+ const item = untracked(() => findItemForGet(params));
17745
17993
  return item ? item[idKey] : undefined;
17746
17994
  }
17747
17995
  : undefined,
@@ -18144,7 +18392,15 @@ const generateSignalId = () => {
18144
18392
  * refuse an address written too often (Safari: 100 writes per 10 s, then a
18145
18393
  * SecurityError). A value one DRAGS is written at 60 Hz: keep it in the
18146
18394
  * gesture while the finger is down and write the state once, on release —
18147
- * an address is not a recording of a gesture.
18395
+ * an address is not a recording of a gesture. When every intermediate value
18396
+ * IS a valid address (a map centre), see `debounceUrl`.
18397
+ * @param {number} [options.debounceUrl=0] - Milliseconds the browser's address
18398
+ * waits for this state to settle before a replace is written: writes closer
18399
+ * together than that make one write, with the last value. The routes, the
18400
+ * document url and the renders still move on every write; only
18401
+ * `window.location` is behind. For a state legitimately written per frame
18402
+ * (the centre of a panned map, the hour of a dragged sun), e.g. 200. A push
18403
+ * is never debounced.
18148
18404
  * @param {boolean} [options.debug=false] - Enable debug logging for this signal's operations
18149
18405
  * @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.
18150
18406
  *
@@ -18219,6 +18475,7 @@ const stateSignal = (defaultValue, options = {}) => {
18219
18475
  autoFix,
18220
18476
  weak = false,
18221
18477
  history = "replace",
18478
+ debounceUrl = 0,
18222
18479
  } = options;
18223
18480
 
18224
18481
  if (history !== "replace" && history !== "push") {
@@ -18226,6 +18483,11 @@ const stateSignal = (defaultValue, options = {}) => {
18226
18483
  `stateSignal "${id}": history must be "replace" or "push", got ${history}.`,
18227
18484
  );
18228
18485
  }
18486
+ if (typeof debounceUrl !== "number" || debounceUrl < 0) {
18487
+ throw new TypeError(
18488
+ `stateSignal "${id}": debounceUrl must be a number of milliseconds, got ${debounceUrl}.`,
18489
+ );
18490
+ }
18229
18491
  if (weak && persists) {
18230
18492
  throw new TypeError(
18231
18493
  `stateSignal "${id}": weak and persists are contradictory — a weak param qualifies one visit, it cannot be restored from a previous session.`,
@@ -18581,6 +18843,7 @@ const stateSignal = (defaultValue, options = {}) => {
18581
18843
  ...options,
18582
18844
  history,
18583
18845
  getHistory: () => historyForCurrentWrite || history,
18846
+ debounceUrl,
18584
18847
  };
18585
18848
  globalSignalRegistry.set(signalIdString, {
18586
18849
  signal: facadeSignal,
@@ -26853,7 +27116,10 @@ const route = (
26853
27116
  const routeUrl = route.buildUrl(params);
26854
27117
  return integration.navTo(routeUrl, options);
26855
27118
  };
26856
- route.redirectTo = (params, { callReason, history = "replace" } = {}) => {
27119
+ route.redirectTo = (
27120
+ params,
27121
+ { callReason, history = "replace", debounceUrl } = {},
27122
+ ) => {
26857
27123
  if (!integration) {
26858
27124
  {
26859
27125
  console.warn(
@@ -26865,12 +27131,13 @@ const route = (
26865
27131
  const routeUrl = route.buildUrl(params);
26866
27132
  return integration.navTo(routeUrl, {
26867
27133
  replace: history !== "push",
27134
+ debounce: debounceUrl,
26868
27135
  callReason,
26869
27136
  });
26870
27137
  };
26871
27138
  route.replaceParams = (
26872
27139
  newParams,
26873
- { callReason, isSignalChange, history = "replace" } = {},
27140
+ { callReason, isSignalChange, history = "replace", debounceUrl } = {},
26874
27141
  ) => {
26875
27142
  const matching = route.matchingSignal.peek();
26876
27143
  if (!matching) {
@@ -26925,6 +27192,7 @@ const route = (
26925
27192
  return mostSpecificRoute.redirectTo(newParams, {
26926
27193
  callReason: `replaceParams delegation from ${route} to ${mostSpecificRoute} (original reason: ${callReason})`,
26927
27194
  history,
27195
+ debounceUrl,
26928
27196
  });
26929
27197
  }
26930
27198
 
@@ -26946,6 +27214,7 @@ const route = (
26946
27214
  );
26947
27215
  return integration.navTo(targetUrl, {
26948
27216
  replace: history !== "push",
27217
+ debounce: debounceUrl,
26949
27218
  callReason,
26950
27219
  });
26951
27220
  };
@@ -27023,6 +27292,9 @@ const route = (
27023
27292
  // which is what a param qualifying a screen is.
27024
27293
  const historyOfWrite = () =>
27025
27294
  paramSignal.options?.getHistory?.() || "replace";
27295
+ // Milliseconds the browser's address waits for this state to settle
27296
+ // (see stateSignal's `debounceUrl`); 0 writes at once. A plain signal says nothing.
27297
+ const debounceUrl = paramSignal.options?.debounceUrl || 0;
27026
27298
  if (debug) {
27027
27299
  console.debug(
27028
27300
  `[route] connecting url param "${paramName}" to signal`,
@@ -27066,6 +27338,7 @@ const route = (
27066
27338
  callReason: `${paramName} signal change on ${route}`,
27067
27339
  isSignalChange: true,
27068
27340
  history: historyOfWrite(),
27341
+ debounceUrl,
27069
27342
  },
27070
27343
  );
27071
27344
  return;
@@ -27084,6 +27357,7 @@ const route = (
27084
27357
  callReason: `${paramName} signal reset to default on ${route}`,
27085
27358
  isSignalChange: true,
27086
27359
  history: historyOfWrite(),
27360
+ debounceUrl,
27087
27361
  },
27088
27362
  );
27089
27363
  return;
@@ -27104,6 +27378,7 @@ const route = (
27104
27378
  callReason: `${paramName} signal change on ${route}`,
27105
27379
  isSignalChange: true,
27106
27380
  history: historyOfWrite(),
27381
+ debounceUrl,
27107
27382
  },
27108
27383
  );
27109
27384
  });
@@ -28415,8 +28690,6 @@ const setupBrowserIntegrationViaHistory = ({
28415
28690
  applyRouting,
28416
28691
  isRouting,
28417
28692
  }) => {
28418
- const { history } = window;
28419
-
28420
28693
  let globalAbortController = new AbortController();
28421
28694
  const triggerGlobalAbort = (reason) => {
28422
28695
  globalAbortController.abort(reason);
@@ -28434,7 +28707,8 @@ const setupBrowserIntegrationViaHistory = ({
28434
28707
  setActionDispatcher(dispatchActions);
28435
28708
 
28436
28709
  const getDocumentState = () => {
28437
- return window.history.state ? { ...window.history.state } : null;
28710
+ const entryState = readEntryState();
28711
+ return entryState ? { ...entryState } : null;
28438
28712
  };
28439
28713
 
28440
28714
  const historyStartAtStart = getDocumentState();
@@ -28448,7 +28722,7 @@ const setupBrowserIntegrationViaHistory = ({
28448
28722
  const visitedUrlsSignal = signal(0);
28449
28723
 
28450
28724
  const isVisited = (url) => {
28451
- url = new URL(url, window.location.href).href;
28725
+ url = new URL(url, readAddress()).href;
28452
28726
  return visitedUrlSet.has(url);
28453
28727
  };
28454
28728
  const markUrlAsVisited = (url) => {
@@ -28510,20 +28784,20 @@ const setupBrowserIntegrationViaHistory = ({
28510
28784
  let abortController = null;
28511
28785
  const handleRoutingTask = (target, options) => {
28512
28786
  // Everything below this line reasons on the URL as a whole: it is compared
28513
- // to window.location.href, looked up in the history stack, written into the
28787
+ // to the address, looked up in the history stack, written into the
28514
28788
  // document url signal and parsed there. A relative target ("/", "../x")
28515
28789
  // would silently lose every one of those — the browser would still resolve
28516
28790
  // it in pushState, but nothing else here would. So it is resolved once, at
28517
28791
  // the single door every navigation goes through, rather than by each caller
28518
28792
  // (navBack's fallback in particular arrives here raw).
28519
- const url = new URL(target, window.location.href).href;
28793
+ const url = new URL(target, readAddress()).href;
28520
28794
  // An address that only sends elsewhere never becomes anything here: asked
28521
28795
  // before the announcement, before the history write and before the routes,
28522
28796
  // so that what follows is entirely about where the reader is going (see
28523
28797
  // resolveRouteRedirection).
28524
28798
  const redirectionUrl = resolveRouteRedirection(url);
28525
28799
  if (redirectionUrl) {
28526
- if (redirectionUrl === window.location.href) {
28800
+ if (redirectionUrl === readAddress()) {
28527
28801
  // Asked to go where we already are: an address that redirects is never
28528
28802
  // the one being displayed, so there is nothing to go to and nothing to
28529
28803
  // stack on the history.
@@ -28542,7 +28816,7 @@ const setupBrowserIntegrationViaHistory = ({
28542
28816
  if (
28543
28817
  options.navigationType === "push" &&
28544
28818
  options.state === undefined &&
28545
- url !== window.location.href
28819
+ url !== readAddress()
28546
28820
  ) {
28547
28821
  const delta = adjacentEntryDelta(url);
28548
28822
  if (delta === -1) {
@@ -28583,12 +28857,13 @@ const setupBrowserIntegrationViaHistory = ({
28583
28857
  const applyRoutingTask = (url, options) => {
28584
28858
  // Read before the history is written: the url the reader is being taken
28585
28859
  // away from, which is what decides where a push lands (see startAtTop).
28586
- const urlLeft = window.location.href;
28860
+ const urlLeft = readAddress();
28587
28861
  const {
28588
28862
  reason,
28589
28863
  navigationType, // "load", "reload", "replace", "push", "traverse"
28590
28864
  redirected,
28591
28865
  landOn,
28866
+ debounce,
28592
28867
  } = options;
28593
28868
  let { state } = options;
28594
28869
 
@@ -28607,7 +28882,14 @@ const setupBrowserIntegrationViaHistory = ({
28607
28882
  [NAV_DEPTH_STATE_KEY]: getNavDepth(),
28608
28883
  },
28609
28884
  });
28610
- writeHistoryEntry(navigationType, effectiveState, url);
28885
+ if (navigationType === "replace" && debounce && state === undefined) {
28886
+ // Nothing of its own for the entry: the address moves, the state is
28887
+ // carried over. The one kind of write allowed to lag, and only when
28888
+ // the state written asked for it (see replaceAddressWhenSettled).
28889
+ replaceAddressWhenSettled(effectiveState, url, debounce);
28890
+ } else {
28891
+ writeHistoryEntry(navigationType, effectiveState, url);
28892
+ }
28611
28893
  rememberEntryIsOfThisDocument();
28612
28894
  updateDocumentUrl(url);
28613
28895
  updateDocumentState(effectiveState);
@@ -28798,7 +29080,20 @@ const setupBrowserIntegrationViaHistory = ({
28798
29080
  // being left must be recorded from the first pixel scrolled.
28799
29081
  installScrollRestoration();
28800
29082
 
29083
+ // The document is leaving, or may be killed in the background: what the
29084
+ // entry says about it has to be what the document says (see
29085
+ // replaceAddressWhenSettled).
29086
+ window.addEventListener("pagehide", flushPendingReplace);
29087
+ document.addEventListener("visibilitychange", () => {
29088
+ if (document.visibilityState === "hidden") {
29089
+ flushPendingReplace();
29090
+ }
29091
+ });
29092
+
28801
29093
  window.addEventListener("popstate", (popstateEvent) => {
29094
+ // The entry a pending replace was for is no longer the current one, and
29095
+ // the History API has no way to write it (see replaceAddressWhenSettled).
29096
+ dropPendingReplace();
28802
29097
  const url = window.location.href;
28803
29098
  const state = popstateEvent.state;
28804
29099
  const landOn = landOnPending;
@@ -28827,11 +29122,18 @@ const setupBrowserIntegrationViaHistory = ({
28827
29122
  updateDocumentUrl(window.location.href);
28828
29123
  });
28829
29124
 
28830
- const navTo = async (url, { replace, state, routeTransition } = {}) => {
29125
+ const navTo = async (
29126
+ url,
29127
+ { replace, state, routeTransition, debounce } = {},
29128
+ ) => {
28831
29129
  handleRoutingTask(url, {
28832
29130
  reason: `navTo called with "${url}"`,
28833
29131
  navigationType: replace ? "replace" : "push",
28834
29132
  state,
29133
+ // Milliseconds the browser's address waits for this replace to settle
29134
+ // (see replaceAddressWhenSettled); said by the state being written
29135
+ // (stateSignal's `debounceUrl`), never assumed.
29136
+ debounce,
28835
29137
  // What this one navigation asks of a route transition, said by the call
28836
29138
  // that starts it rather than by an element — the programmatic half of
28837
29139
  // what a <Link routeTransition> says (see route_transition.jsx).
@@ -28844,8 +29146,8 @@ const setupBrowserIntegrationViaHistory = ({
28844
29146
  };
28845
29147
 
28846
29148
  const reload = () => {
28847
- const url = window.location.href;
28848
- const state = history.state;
29149
+ const url = readAddress();
29150
+ const state = readEntryState();
28849
29151
  handleRoutingTask(url, {
28850
29152
  reason: "reload called",
28851
29153
  navigationType: "reload",
@@ -28917,20 +29219,83 @@ const setupBrowserIntegrationViaHistory = ({
28917
29219
  };
28918
29220
  };
28919
29221
 
28920
- // Read before the history is written, in both places that ask: a push or a
28921
- // replace onto the url already displayed. Never a traverse — the browser has
28922
- // already moved window.location.href when a popstate is handled, so the
28923
- // comparison would say "same" about every back and forward.
28924
29222
  // Browsers refuse an address written too often: WebKit throws a SecurityError
28925
29223
  // past 100 writes per 10 seconds, Chromium and Firefox drop the write. A state
28926
29224
  // bound to the url and written per frame gets there in under two seconds, and
28927
- // a developer working in Chromium never sees it. Every write goes through here
28928
- // so the rate is measured against the strictest budget before any browser
28929
- // refuses, and so a refusal names its cause rather than `replaceState`.
29225
+ // a developer working in Chromium never sees it. Every write goes through
29226
+ // writeHistoryEntry so the rate is measured against the strictest budget
29227
+ // before any browser refuses, and so a refusal names its cause rather than
29228
+ // `replaceState`.
29229
+ //
29230
+ // The address is written when the state is, and a state written per frame is
29231
+ // the app's to hold during the gesture (an address is not a recording of a
29232
+ // gesture). The one exception is asked for by the state itself (stateSignal's
29233
+ // `debounceUrl`): a value whose every intermediate IS a valid address — the
29234
+ // centre of a panned map. Its replaces are debounced: the browser is written
29235
+ // once the state has stayed still for that long, with the last value. Nothing
29236
+ // else in the document waits for it: the routes, the signals and the renders
29237
+ // have moved on the spot, and only the browser's copy of the address
29238
+ // (`window.location`) is behind — which is why everything here reads the
29239
+ // address through readAddress / readEntryState rather than from the browser.
29240
+ //
29241
+ // What never waits: a push (an entry the reader can come back to, which must
29242
+ // stand on top of the address as it is, so a pending replace is written
29243
+ // first), a write that puts something INTO the entry (a state written by
29244
+ // useNavState, a landOn — a record, not a mirror), and the writes a traversal
29245
+ // makes on the entry it lands on. A traversal DROPS a pending replace: the
29246
+ // browser has already moved to another entry, and the History API cannot
29247
+ // write the one just left. The address kept on it is the last one written.
28930
29248
  const HISTORY_WRITE_BUDGET = 100;
28931
29249
  const HISTORY_WRITE_WINDOW_MS = 10_000;
28932
- 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.`;
29250
+ 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.`;
29251
+
29252
+ // The replace waiting for the state to settle, and the address this document
29253
+ // stands at meanwhile. Read by everything here that would otherwise read
29254
+ // window.location.href or window.history.state: the document is at the
29255
+ // pending address, the browser just does not say so yet.
29256
+ let pendingReplace = null;
29257
+ let pendingReplaceTimer = null;
29258
+ const readAddress = () => {
29259
+ return pendingReplace ? pendingReplace.url : window.location.href;
29260
+ };
29261
+ const readEntryState = () => {
29262
+ return pendingReplace ? pendingReplace.state : window.history.state;
29263
+ };
29264
+ const replaceAddressWhenSettled = (state, url, debounce) => {
29265
+ if (pendingReplaceTimer !== null) {
29266
+ clearTimeout(pendingReplaceTimer);
29267
+ }
29268
+ pendingReplace = { state, url };
29269
+ pendingReplaceTimer = setTimeout(flushPendingReplace, debounce);
29270
+ };
29271
+ const flushPendingReplace = () => {
29272
+ const pending = pendingReplace;
29273
+ dropPendingReplace();
29274
+ if (pending) {
29275
+ writeHistoryEntryNow("replace", pending.state, pending.url);
29276
+ }
29277
+ };
29278
+ const dropPendingReplace = () => {
29279
+ pendingReplace = null;
29280
+ if (pendingReplaceTimer !== null) {
29281
+ clearTimeout(pendingReplaceTimer);
29282
+ pendingReplaceTimer = null;
29283
+ }
29284
+ };
28933
29285
  const writeHistoryEntry = (navigationType, state, url) => {
29286
+ if (pendingReplace) {
29287
+ if (pendingReplace.url === url) {
29288
+ // This write stands where the pending one would: its state was built on
29289
+ // the pending state (see readEntryState), so it says everything the
29290
+ // pending write had to say.
29291
+ dropPendingReplace();
29292
+ } else {
29293
+ flushPendingReplace();
29294
+ }
29295
+ }
29296
+ writeHistoryEntryNow(navigationType, state, url);
29297
+ };
29298
+ const writeHistoryEntryNow = (navigationType, state, url) => {
28934
29299
  noteHistoryWrite(url);
28935
29300
  try {
28936
29301
  if (navigationType === "push") {
@@ -28972,10 +29337,14 @@ const noteHistoryWrite = (() => {
28972
29337
  })()
28973
29338
  ;
28974
29339
 
29340
+ // Read before the history is written, in both places that ask: a push or a
29341
+ // replace onto the url already displayed. Never a traverse — the browser has
29342
+ // already moved the address when a popstate is handled, so the comparison
29343
+ // would say "same" about every back and forward.
28975
29344
  const isStateOnlyNavigation = (
28976
29345
  url,
28977
29346
  { navigationType },
28978
- urlLeft = window.location.href,
29347
+ urlLeft = readAddress(),
28979
29348
  ) =>
28980
29349
  url === urlLeft &&
28981
29350
  (navigationType === "push" || navigationType === "replace");
@@ -29102,8 +29471,10 @@ setRouteIntegration(browserIntegration);
29102
29471
 
29103
29472
  const navIntegratedVia = browserIntegration.integration;
29104
29473
  const navTo = (target, options) => {
29105
- const url = new URL(target, window.location.href).href;
29474
+ // Resolved against the address this document says it is at, not the one the
29475
+ // browser shows: a replace may be on its way there (see via_history.js).
29106
29476
  const currentUrl = documentUrlSignal.peek();
29477
+ const url = new URL(target, currentUrl).href;
29107
29478
  if (url === currentUrl) {
29108
29479
  if (options?.state === undefined) {
29109
29480
  return null;
@@ -29314,7 +29685,7 @@ const useNavStateBasic = (
29314
29685
  return;
29315
29686
  }
29316
29687
  currentStateCopy[id] = value;
29317
- navTo(window.location.href, {
29688
+ navTo(documentUrlSignal.peek(), {
29318
29689
  replace: effectiveType !== "push",
29319
29690
  state: currentStateCopy,
29320
29691
  });
@@ -29348,12 +29719,12 @@ const useNavStateBasic = (
29348
29719
  }
29349
29720
  delete currentStateCopy[id];
29350
29721
  browserIntegration.navBack({
29351
- landOn: { url: window.location.href, state: currentStateCopy },
29722
+ landOn: { url: documentUrlSignal.peek(), state: currentStateCopy },
29352
29723
  });
29353
29724
  return;
29354
29725
  }
29355
29726
  delete currentStateCopy[id];
29356
- navTo(window.location.href, {
29727
+ navTo(documentUrlSignal.peek(), {
29357
29728
  replace: true,
29358
29729
  state: currentStateCopy,
29359
29730
  });
@@ -29408,7 +29779,7 @@ const useUrlSearchParam = (paramName, defaultValue) => {
29408
29779
  }
29409
29780
 
29410
29781
  const setSearchParamValue = (newValue, { replace = false } = {}) => {
29411
- const newUrlObject = new URL(window.location.href);
29782
+ const newUrlObject = new URL(documentUrlSignal.peek());
29412
29783
  newUrlObject.searchParams.set(paramName, newValue);
29413
29784
  const newUrl = newUrlObject.href;
29414
29785
  navTo(newUrl, { replace });
@@ -36910,7 +37281,9 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
36910
37281
  return;
36911
37282
  }
36912
37283
  writeInSignal(signal, closedValue, { history: "replace" });
36913
- navBack({ landOn: { url: window.location.href } });
37284
+ // The address this document says it is at: the one the browser shows may
37285
+ // be a replace behind it (see via_history.js).
37286
+ navBack({ landOn: { url: documentUrlSignal.peek() } });
36914
37287
  return;
36915
37288
  }
36916
37289
  writeInSignal(signal, closedValue, { history: "replace" });