@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.
- package/dist/dev/jsenv_navi.js +416 -43
- package/dist/dev/jsenv_navi.js.map +11 -9
- package/dist/jsenv_navi.js +393 -36
- package/dist/jsenv_navi.js.map +11 -9
- package/docs/AI_INSTRUCTIONS.md +4 -2
- package/docs/data_states.md +4 -1
- package/docs/navigation.md +41 -0
- package/docs/network_policy.md +5 -2
- package/docs/resource.md +95 -1
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -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
|
|
15912
|
-
//
|
|
15913
|
-
//
|
|
15914
|
-
//
|
|
15915
|
-
//
|
|
15916
|
-
//
|
|
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
|
|
15958
|
-
if (
|
|
15959
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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 = (
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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 ===
|
|
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 !==
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
28093
|
-
const 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 =
|
|
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
|
-
|
|
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(
|
|
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:
|
|
28837
|
+
landOn: { url: documentUrlSignal.peek(), state: currentStateCopy },
|
|
28483
28838
|
});
|
|
28484
28839
|
return;
|
|
28485
28840
|
}
|
|
28486
28841
|
delete currentStateCopy[id];
|
|
28487
|
-
navTo(
|
|
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(
|
|
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
|
-
|
|
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" });
|