@trackunit/react-components 3.8.2-alpha-775ca1816d6.0 → 3.8.2
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/index.cjs.js +801 -321
- package/index.esm.js +800 -322
- package/package.json +6 -6
- package/src/hooks/persistence/useHashParamSync.d.ts +55 -0
- package/src/hooks/persistence/useMigrateLegacySearchParam.d.ts +62 -0
- package/src/hooks/persistence/usePersistedState.d.ts +44 -31
- package/src/hooks/persistence/useSearchParamSync.d.ts +26 -7
- package/src/index.d.ts +1 -0
- package/migrations/entry.js.map +0 -1
- package/migrations/utils/jsx-utils.js.map +0 -1
- package/migrations/v2-0-0/breadcrumb-remove-deprecated-props.js.map +0 -1
- package/migrations/v2-0-0/cardheader-onclickclose-to-actions.js.map +0 -1
- package/migrations/v2-0-0/iconbutton-add-required-name.js.map +0 -1
- package/migrations/v2-0-0/kpi-tooltiplabel-to-wrapper.js.map +0 -1
- package/migrations/v2-0-0/notice-tooltiplabel-to-wrapper.js.map +0 -1
- package/migrations/v2-0-0/pagination-add-required-titles.js.map +0 -1
- package/migrations/v2-0-0/tag-add-removetaglabel.js.map +0 -1
- package/migrations/v2-0-0/togglegroup-remove-item-style.js.map +0 -1
- package/migrations/v3-0-0/copyabletext-to-text-copybutton.js.map +0 -1
- package/migrations/v3-0-0/menulist-rename-to-menucontent.js.map +0 -1
- package/src/hooks/persistence/useUrlParamSync.d.ts +0 -71
package/index.esm.js
CHANGED
|
@@ -13480,90 +13480,6 @@ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage,
|
|
|
13480
13480
|
*/
|
|
13481
13481
|
const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
|
|
13482
13482
|
|
|
13483
|
-
/**
|
|
13484
|
-
* Pure utilities for stamping a URL-persisted value with the identity that
|
|
13485
|
-
* wrote it.
|
|
13486
|
-
*
|
|
13487
|
-
* A URL (hash or search param) is shared by every account that uses the same
|
|
13488
|
-
* browser: it survives login redirects, history and bookmarks, and it takes
|
|
13489
|
-
* precedence over per-user localStorage when a page loads. Without a stamp
|
|
13490
|
-
* there is no way to tell "a colleague shared this link" from "the previous
|
|
13491
|
-
* account on this PC left its layout in the address bar" — and the second
|
|
13492
|
-
* case silently replaces the current user's saved state (GLU-2396).
|
|
13493
|
-
*
|
|
13494
|
-
* The stamp is a short, non-reversible fingerprint of `clientSideUserId`.
|
|
13495
|
-
* It is not a secret and not a security boundary; it only lets a reader
|
|
13496
|
-
* recognise a value written by a *different* identity on the same device.
|
|
13497
|
-
*/
|
|
13498
|
-
/**
|
|
13499
|
-
* Wire shape of a stamped URL value: `{ o: <fingerprint>, v: <payload> }`.
|
|
13500
|
-
* Values written before stamping existed are bare payloads and decode as
|
|
13501
|
-
* unowned (see {@link unwrapUrlOwner}).
|
|
13502
|
-
*/
|
|
13503
|
-
const stampedUrlValueSchema = z.object({
|
|
13504
|
-
o: z.string(),
|
|
13505
|
-
v: z.unknown(),
|
|
13506
|
-
});
|
|
13507
|
-
const FNV_OFFSET_BASIS = 0x811c9dc5;
|
|
13508
|
-
const FNV_PRIME = 0x01000193;
|
|
13509
|
-
/**
|
|
13510
|
-
* 32-bit FNV-1a of `identity`, rendered in base36 (≤ 7 chars).
|
|
13511
|
-
*
|
|
13512
|
-
* Synchronous and dependency-free so it can run inside a `useState`
|
|
13513
|
-
* initializer. Collisions are possible in theory but irrelevant here: a
|
|
13514
|
-
* collision can only make a foreign value look like the reader's own, which
|
|
13515
|
-
* is today's behaviour for every value.
|
|
13516
|
-
*
|
|
13517
|
-
* @param identity - The client-side user id to fingerprint.
|
|
13518
|
-
* @returns {string} A short stable fingerprint of `identity`.
|
|
13519
|
-
*/
|
|
13520
|
-
const identityFingerprint = (identity) => {
|
|
13521
|
-
let hash = FNV_OFFSET_BASIS;
|
|
13522
|
-
for (let index = 0; index < identity.length; index++) {
|
|
13523
|
-
hash ^= identity.charCodeAt(index);
|
|
13524
|
-
hash = Math.imul(hash, FNV_PRIME) >>> 0;
|
|
13525
|
-
}
|
|
13526
|
-
return hash.toString(36);
|
|
13527
|
-
};
|
|
13528
|
-
/**
|
|
13529
|
-
* Wraps a URL payload with the fingerprint of the identity writing it.
|
|
13530
|
-
*
|
|
13531
|
-
* @param value - The payload that would otherwise be encoded into the URL.
|
|
13532
|
-
* @param owner - Fingerprint of the writing identity (see {@link identityFingerprint}).
|
|
13533
|
-
* @returns {StampedUrlValue} The payload wrapped in its ownership envelope.
|
|
13534
|
-
*/
|
|
13535
|
-
const wrapUrlOwner = (value, owner) => ({ o: owner, v: value });
|
|
13536
|
-
/**
|
|
13537
|
-
* Splits a decoded URL value into its owner fingerprint and payload.
|
|
13538
|
-
* Bare (pre-stamping) payloads come back with `owner: undefined`.
|
|
13539
|
-
*
|
|
13540
|
-
* @param decoded - The decoded URL value, stamped or bare.
|
|
13541
|
-
* @returns {{ owner: string | undefined; value: unknown }} The owner fingerprint (if any) and the payload.
|
|
13542
|
-
*/
|
|
13543
|
-
const unwrapUrlOwner = (decoded) => {
|
|
13544
|
-
const stamped = stampedUrlValueSchema.safeParse(decoded);
|
|
13545
|
-
// `z.unknown()` accepts a missing key, so `{ o: "x" }` parses without `v`.
|
|
13546
|
-
// Such an object is a payload of its own, not an envelope; the `in` check
|
|
13547
|
-
// is what keeps it unowned. Do not remove it as redundant.
|
|
13548
|
-
return stamped.success && "v" in stamped.data
|
|
13549
|
-
? { owner: stamped.data.o, value: stamped.data.v }
|
|
13550
|
-
: { owner: undefined, value: decoded };
|
|
13551
|
-
};
|
|
13552
|
-
|
|
13553
|
-
/**
|
|
13554
|
-
* Generates a localStorage key, optionally scoped to a specific user.
|
|
13555
|
-
*
|
|
13556
|
-
* When `userId` is provided the key is `"key-userId"`.
|
|
13557
|
-
* When omitted the key is returned as-is (unscoped).
|
|
13558
|
-
*
|
|
13559
|
-
* @param key - Unique persistence identifier.
|
|
13560
|
-
* @param userId - Client-side user id to scope storage per user.
|
|
13561
|
-
* @returns {string} The combined storage key.
|
|
13562
|
-
*/
|
|
13563
|
-
const useStorageKey = (key, userId) => {
|
|
13564
|
-
return useMemo(() => (userId ? `${key}-${userId}` : key), [key, userId]);
|
|
13565
|
-
};
|
|
13566
|
-
|
|
13567
13483
|
/**
|
|
13568
13484
|
* Pure utilities for reading and writing single named entries inside a URL
|
|
13569
13485
|
* fragment (the part after `#`).
|
|
@@ -13692,6 +13608,183 @@ const writeHashKey = (hash, key, value) => {
|
|
|
13692
13608
|
return formatHash(next.join(TOKEN_SEPARATOR));
|
|
13693
13609
|
};
|
|
13694
13610
|
|
|
13611
|
+
/**
|
|
13612
|
+
* Default maximum length of the resulting URL fragment after adding one
|
|
13613
|
+
* persisted entry. Oversized new entries are skipped; oversized replacements
|
|
13614
|
+
* remove the stale entry so localStorage remains authoritative.
|
|
13615
|
+
*
|
|
13616
|
+
* The fragment is never sent to the server, so it is not subject to the
|
|
13617
|
+
* AWS WAF managed-rule `SizeRestrictions_QUERYSTRING` cap that applies to
|
|
13618
|
+
* search params (5000 bytes). 64 KB stays well below typical browser URL
|
|
13619
|
+
* limits (Chromium ≳ 100 KB, Firefox ≳ 64 KB on the address bar) while
|
|
13620
|
+
* still leaving generous headroom for any realistic table state.
|
|
13621
|
+
*/
|
|
13622
|
+
const MAX_HASH_LENGTH = 64 * 1024;
|
|
13623
|
+
/**
|
|
13624
|
+
* Syncs an encoded string value with a single named entry inside the URL
|
|
13625
|
+
* fragment (`location.hash`) via Tanstack Router.
|
|
13626
|
+
*
|
|
13627
|
+
* Behaviour mirrors `useSearchParamSync`, with two differences:
|
|
13628
|
+
* - Reads from and writes to `location.hash` instead of `location.search`.
|
|
13629
|
+
* - Oversized replacements remove stale values using
|
|
13630
|
+
* {@link MAX_HASH_LENGTH}, rather than the AWS-WAF-driven search-param cap.
|
|
13631
|
+
*
|
|
13632
|
+
* The fragment is treated as a sequence of `&`-separated tokens via the
|
|
13633
|
+
* pure utilities in `./hashFragment`. Foreign tokens (bare anchors, other
|
|
13634
|
+
* named entries) are preserved byte-for-byte on every write.
|
|
13635
|
+
*
|
|
13636
|
+
* @param options - Configuration for the hash entry sync.
|
|
13637
|
+
* @param options.key - The named entry inside the fragment.
|
|
13638
|
+
* @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
|
|
13639
|
+
* @param options.onExternalChange - Called when the entry changes externally
|
|
13640
|
+
* (e.g. browser back/forward, pasted link, foreign router push).
|
|
13641
|
+
* @param options.replace - `true` to always use `replaceState` instead of pushState.
|
|
13642
|
+
*/
|
|
13643
|
+
const useHashParamSync = ({ key, enabled = true, onExternalChange, replace: replaceOption, }) => {
|
|
13644
|
+
const navigate = useNavigate();
|
|
13645
|
+
const location = useLocation();
|
|
13646
|
+
const router = useRouter();
|
|
13647
|
+
const lastWrittenRef = useRef(undefined);
|
|
13648
|
+
const pendingWriteRef = useRef(null);
|
|
13649
|
+
const onExternalChangeRef = useRef(onExternalChange);
|
|
13650
|
+
useEffect(() => {
|
|
13651
|
+
onExternalChangeRef.current = onExternalChange;
|
|
13652
|
+
}, [onExternalChange]);
|
|
13653
|
+
const currentHashValue = useMemo(() => {
|
|
13654
|
+
if (!enabled) {
|
|
13655
|
+
return undefined;
|
|
13656
|
+
}
|
|
13657
|
+
return readHashKey(location.hash, key);
|
|
13658
|
+
}, [enabled, location.hash, key]);
|
|
13659
|
+
useEffect(() => {
|
|
13660
|
+
if (!enabled) {
|
|
13661
|
+
return;
|
|
13662
|
+
}
|
|
13663
|
+
if (currentHashValue === undefined) {
|
|
13664
|
+
return;
|
|
13665
|
+
}
|
|
13666
|
+
if (currentHashValue === lastWrittenRef.current) {
|
|
13667
|
+
return;
|
|
13668
|
+
}
|
|
13669
|
+
if (lastWrittenRef.current === undefined) {
|
|
13670
|
+
return;
|
|
13671
|
+
}
|
|
13672
|
+
// A caller may have primed `lastWrittenRef` via `updateHashParam` while the
|
|
13673
|
+
// URL still carries the previous value (queued rAF / router pending). Treating
|
|
13674
|
+
// that stale URL as an external change reloads filter state from persistence
|
|
13675
|
+
// and can clear in-progress search input before the write lands.
|
|
13676
|
+
if (pendingWriteRef.current !== null) {
|
|
13677
|
+
return;
|
|
13678
|
+
}
|
|
13679
|
+
requestAnimationFrame(() => {
|
|
13680
|
+
onExternalChangeRef.current?.();
|
|
13681
|
+
});
|
|
13682
|
+
}, [currentHashValue, enabled]);
|
|
13683
|
+
// Writes are dispatched via a "pending write" queue rather than calling
|
|
13684
|
+
// `navigate` directly from inside the rAF callback. Tanstack Router's
|
|
13685
|
+
// `router.state.status === "pending"` is common during the first paint
|
|
13686
|
+
// of a route (loaders, suspense), and an earlier implementation that
|
|
13687
|
+
// simply returned in that case lost the write outright. A one-shot write
|
|
13688
|
+
// (a legacy search-param migrated into the hash, or the first edit)
|
|
13689
|
+
// would otherwise never land.
|
|
13690
|
+
//
|
|
13691
|
+
// The queue + retry pattern below makes writes reliable:
|
|
13692
|
+
// - `updateHashParam` stores the latest attempted write in
|
|
13693
|
+
// `pendingWriteRef` and schedules an rAF flush.
|
|
13694
|
+
// - `flushPendingWrite` only navigates when the router is idle; while
|
|
13695
|
+
// pending it leaves the write queued so it can fire later.
|
|
13696
|
+
// - A dedicated effect watches `router.state.status` and re-attempts
|
|
13697
|
+
// the flush whenever the router settles, draining the queue.
|
|
13698
|
+
const flushPendingWrite = useCallback(() => {
|
|
13699
|
+
const pending = pendingWriteRef.current;
|
|
13700
|
+
if (pending === null) {
|
|
13701
|
+
return;
|
|
13702
|
+
}
|
|
13703
|
+
if (router.state.status === "pending") {
|
|
13704
|
+
// Keep the write queued; the status-watcher effect below will retry
|
|
13705
|
+
// when the router transitions back to idle.
|
|
13706
|
+
return;
|
|
13707
|
+
}
|
|
13708
|
+
pendingWriteRef.current = null;
|
|
13709
|
+
const { encodedValue, options } = pending;
|
|
13710
|
+
let nextHash = writeHashKey(location.hash, key, encodedValue);
|
|
13711
|
+
let removedOversizedValue = false;
|
|
13712
|
+
if (nextHash.length > MAX_HASH_LENGTH) {
|
|
13713
|
+
if (currentHashValue === undefined) {
|
|
13714
|
+
return;
|
|
13715
|
+
}
|
|
13716
|
+
nextHash = writeHashKey(location.hash, key, undefined);
|
|
13717
|
+
removedOversizedValue = true;
|
|
13718
|
+
}
|
|
13719
|
+
const shouldReplace = removedOversizedValue
|
|
13720
|
+
? true
|
|
13721
|
+
: (options?.replace ?? replaceOption ?? !Boolean(currentHashValue));
|
|
13722
|
+
// Tanstack Router's `navigate({ hash })` expects the fragment body
|
|
13723
|
+
// *without* the leading `#` — the router adds the delimiter itself
|
|
13724
|
+
// when serialising the URL. Forwarding the `#`-prefixed value from
|
|
13725
|
+
// `writeHashKey` produces a doubled `##` in the address bar
|
|
13726
|
+
// (visually broken, even though browsers still parse the fragment
|
|
13727
|
+
// correctly because URL fragments start at the *first* `#`).
|
|
13728
|
+
const navigateHash = nextHash.startsWith("#") ? nextHash.slice(1) : nextHash;
|
|
13729
|
+
void navigate({
|
|
13730
|
+
to: ".",
|
|
13731
|
+
// Returning the same `prev` reference leaves the search params
|
|
13732
|
+
// unchanged. We must still pass `search` so router types are happy.
|
|
13733
|
+
search: (prev) => prev,
|
|
13734
|
+
hash: navigateHash,
|
|
13735
|
+
replace: shouldReplace,
|
|
13736
|
+
});
|
|
13737
|
+
}, [router.state.status, location.hash, key, currentHashValue, navigate, replaceOption]);
|
|
13738
|
+
useEffect(() => {
|
|
13739
|
+
if (router.state.status === "pending") {
|
|
13740
|
+
return;
|
|
13741
|
+
}
|
|
13742
|
+
if (pendingWriteRef.current === null) {
|
|
13743
|
+
return;
|
|
13744
|
+
}
|
|
13745
|
+
flushPendingWrite();
|
|
13746
|
+
}, [router.state.status, flushPendingWrite]);
|
|
13747
|
+
// Drop any queued write when the consumer unmounts. The queue + retry
|
|
13748
|
+
// pattern above keeps writes pending across a `router.state.status ===
|
|
13749
|
+
// "pending"` phase, which means a write scheduled just before a route
|
|
13750
|
+
// change could otherwise drain onto the *next* route once it settles.
|
|
13751
|
+
// Cancelling here preserves the original "writes are scoped to the
|
|
13752
|
+
// owning route" contract that the pre-queue implementation got for
|
|
13753
|
+
// free by dropping the write outright.
|
|
13754
|
+
useEffect(() => () => {
|
|
13755
|
+
pendingWriteRef.current = null;
|
|
13756
|
+
}, []);
|
|
13757
|
+
const updateHashParam = useCallback((encodedValue, options) => {
|
|
13758
|
+
if (!enabled) {
|
|
13759
|
+
return;
|
|
13760
|
+
}
|
|
13761
|
+
// Dedupe against either the last *committed* write or the value
|
|
13762
|
+
// currently queued for the next rAF. Both represent the caller's
|
|
13763
|
+
// intent so we don't schedule redundant rAF callbacks for identical
|
|
13764
|
+
// requests.
|
|
13765
|
+
const inFlightOrLastWritten = pendingWriteRef.current?.encodedValue ?? lastWrittenRef.current;
|
|
13766
|
+
if (encodedValue !== undefined && encodedValue === inFlightOrLastWritten) {
|
|
13767
|
+
return;
|
|
13768
|
+
}
|
|
13769
|
+
// Prime `lastWrittenRef` here (not inside `flushPendingWrite`) so
|
|
13770
|
+
// the external-change detector treats this value as the caller's
|
|
13771
|
+
// authoritative state even when the flush is a no-op (URL already
|
|
13772
|
+
// matches) or stays queued during a pending router phase. Without
|
|
13773
|
+
// priming, the next "external" change would look identical to the
|
|
13774
|
+
// initial render (`lastWrittenRef === undefined`) and the callback
|
|
13775
|
+
// would be skipped.
|
|
13776
|
+
lastWrittenRef.current = encodedValue;
|
|
13777
|
+
if (currentHashValue === encodedValue) {
|
|
13778
|
+
return;
|
|
13779
|
+
}
|
|
13780
|
+
pendingWriteRef.current = { encodedValue, options };
|
|
13781
|
+
requestAnimationFrame(() => {
|
|
13782
|
+
flushPendingWrite();
|
|
13783
|
+
});
|
|
13784
|
+
}, [enabled, currentHashValue, flushPendingWrite]);
|
|
13785
|
+
return useMemo(() => ({ searchValue: currentHashValue, updateSearchParam: updateHashParam }), [currentHashValue, updateHashParam]);
|
|
13786
|
+
};
|
|
13787
|
+
|
|
13695
13788
|
const searchRecordSchema = z.record(z.string(), z.unknown());
|
|
13696
13789
|
const primitiveSearchValueSchema = z.union([z.string(), z.number(), z.boolean()]);
|
|
13697
13790
|
/**
|
|
@@ -13714,187 +13807,373 @@ const readPrimitiveSearchParam = (search, key) => {
|
|
|
13714
13807
|
};
|
|
13715
13808
|
|
|
13716
13809
|
/**
|
|
13717
|
-
*
|
|
13718
|
-
*
|
|
13810
|
+
* Pure utilities for stamping a URL-persisted value with the identity that
|
|
13811
|
+
* wrote it.
|
|
13719
13812
|
*
|
|
13720
|
-
*
|
|
13721
|
-
*
|
|
13722
|
-
*
|
|
13813
|
+
* A URL (hash or search param) is shared by every account that uses the same
|
|
13814
|
+
* browser: it survives login redirects, history and bookmarks, and it takes
|
|
13815
|
+
* precedence over per-user localStorage when a page loads. Without a stamp
|
|
13816
|
+
* there is no way to tell "a colleague shared this link" from "the previous
|
|
13817
|
+
* account on this PC left its layout in the address bar" — and the second
|
|
13818
|
+
* case silently replaces the current user's saved state (GLU-2396).
|
|
13819
|
+
*
|
|
13820
|
+
* The stamp is a short, non-reversible fingerprint of `clientSideUserId`.
|
|
13821
|
+
* It is not a secret and not a security boundary; it only lets a reader
|
|
13822
|
+
* recognise a value written by a *different* identity on the same device.
|
|
13723
13823
|
*/
|
|
13724
|
-
const MAX_URL_LENGTH = 5000;
|
|
13725
13824
|
/**
|
|
13726
|
-
*
|
|
13727
|
-
*
|
|
13728
|
-
*
|
|
13825
|
+
* Wire shape of a stamped URL value: `{ o: <fingerprint>, v: <payload> }`.
|
|
13826
|
+
* Values written before stamping existed are bare payloads and decode as
|
|
13827
|
+
* unowned (see {@link unwrapUrlOwner}).
|
|
13828
|
+
*/
|
|
13829
|
+
const stampedUrlValueSchema = z.object({
|
|
13830
|
+
o: z.string(),
|
|
13831
|
+
v: z.unknown(),
|
|
13832
|
+
});
|
|
13833
|
+
const FNV_OFFSET_BASIS = 0x811c9dc5;
|
|
13834
|
+
const FNV_PRIME = 0x01000193;
|
|
13835
|
+
/**
|
|
13836
|
+
* 32-bit FNV-1a of `identity`, rendered in base36 (≤ 7 chars).
|
|
13729
13837
|
*
|
|
13730
|
-
*
|
|
13731
|
-
*
|
|
13732
|
-
*
|
|
13733
|
-
*
|
|
13838
|
+
* Synchronous and dependency-free so it can run inside a `useState`
|
|
13839
|
+
* initializer. Collisions are possible in theory but irrelevant here: a
|
|
13840
|
+
* collision can only make a foreign value look like the reader's own, which
|
|
13841
|
+
* is today's behaviour for every value.
|
|
13842
|
+
*
|
|
13843
|
+
* @param identity - The client-side user id to fingerprint.
|
|
13844
|
+
* @returns {string} A short stable fingerprint of `identity`.
|
|
13734
13845
|
*/
|
|
13735
|
-
const
|
|
13846
|
+
const identityFingerprint = (identity) => {
|
|
13847
|
+
let hash = FNV_OFFSET_BASIS;
|
|
13848
|
+
for (let index = 0; index < identity.length; index++) {
|
|
13849
|
+
hash ^= identity.charCodeAt(index);
|
|
13850
|
+
hash = Math.imul(hash, FNV_PRIME) >>> 0;
|
|
13851
|
+
}
|
|
13852
|
+
return hash.toString(36);
|
|
13853
|
+
};
|
|
13854
|
+
/**
|
|
13855
|
+
* Wraps a URL payload with the fingerprint of the identity writing it.
|
|
13856
|
+
*
|
|
13857
|
+
* @param value - The payload that would otherwise be encoded into the URL.
|
|
13858
|
+
* @param owner - Fingerprint of the writing identity (see {@link identityFingerprint}).
|
|
13859
|
+
* @returns {StampedUrlValue} The payload wrapped in its ownership envelope.
|
|
13860
|
+
*/
|
|
13861
|
+
const wrapUrlOwner = (value, owner) => ({ o: owner, v: value });
|
|
13862
|
+
/**
|
|
13863
|
+
* Splits a decoded URL value into its owner fingerprint and payload.
|
|
13864
|
+
* Bare (pre-stamping) payloads come back with `owner: undefined`.
|
|
13865
|
+
*
|
|
13866
|
+
* @param decoded - The decoded URL value, stamped or bare.
|
|
13867
|
+
* @returns {{ owner: string | undefined; value: unknown }} The owner fingerprint (if any) and the payload.
|
|
13868
|
+
*/
|
|
13869
|
+
const unwrapUrlOwner = (decoded) => {
|
|
13870
|
+
const stamped = stampedUrlValueSchema.safeParse(decoded);
|
|
13871
|
+
// `z.unknown()` accepts a missing key, so `{ o: "x" }` parses without `v`.
|
|
13872
|
+
// Such an object is a payload of its own, not an envelope; the `in` check
|
|
13873
|
+
// is what keeps it unowned. Do not remove it as redundant.
|
|
13874
|
+
return stamped.success && "v" in stamped.data
|
|
13875
|
+
? { owner: stamped.data.o, value: stamped.data.v }
|
|
13876
|
+
: { owner: undefined, value: decoded };
|
|
13877
|
+
};
|
|
13878
|
+
|
|
13879
|
+
/**
|
|
13880
|
+
* One-shot migration: if the URL still carries a legacy `?<key>=...`
|
|
13881
|
+
* search param from before this hook moved table-style state to the
|
|
13882
|
+
* fragment, decode it, hand it to the caller via `onMigrated`, then strip
|
|
13883
|
+
* the search param via a `replace` navigation so back/forward history
|
|
13884
|
+
* isn't polluted.
|
|
13885
|
+
*
|
|
13886
|
+
* Invariants:
|
|
13887
|
+
* - The strip navigation fires at most once per mount (guarded by
|
|
13888
|
+
* `hasStrippedRef`) so we don't loop on the navigation we ourselves
|
|
13889
|
+
* triggered.
|
|
13890
|
+
* - `onMigrated` is also fired at most once per mount (guarded by
|
|
13891
|
+
* `hasMigratedRef`), but its gating is independent of the strip:
|
|
13892
|
+
* if `hashAlreadyHasValue` flips `true → false` after the strip
|
|
13893
|
+
* (e.g. an external actor clears the canonical hash mid-mount), the
|
|
13894
|
+
* next render still gets a chance to migrate the legacy value into
|
|
13895
|
+
* the hash. Conflating the two flags would have permanently lost
|
|
13896
|
+
* the legacy state in that race.
|
|
13897
|
+
* - Never throws — a decode/validate failure silently drops the value
|
|
13898
|
+
* and still strips the search param. The legacy key is owned by this
|
|
13899
|
+
* codebase's persistence convention (camelCase + `Tp` suffix), so
|
|
13900
|
+
* stripping unparseable values cannot destroy third-party data.
|
|
13901
|
+
* - When `hashAlreadyHasValue` is `true` the hash wins: the search
|
|
13902
|
+
* param is stripped but `onMigrated` is not fired.
|
|
13903
|
+
* - The `location.hash` is preserved verbatim across the strip
|
|
13904
|
+
* navigation so any concurrent hash content (including a freshly
|
|
13905
|
+
* written value) survives the cleanup.
|
|
13906
|
+
*/
|
|
13907
|
+
const useMigrateLegacySearchParam = ({ key, enabled, decode, fromUrlValue, validate, hashAlreadyHasValue, onMigrated, }) => {
|
|
13908
|
+
const navigate = useNavigate();
|
|
13909
|
+
const location = useLocation();
|
|
13910
|
+
const search = useSearch({ strict: false, shouldThrow: false });
|
|
13911
|
+
// The three flags are tracked independently because they describe
|
|
13912
|
+
// distinct side effects with distinct lifetimes:
|
|
13913
|
+
//
|
|
13914
|
+
// - `hasStrippedRef` guards the cleanup navigation against looping
|
|
13915
|
+
// on the navigation we ourselves triggered.
|
|
13916
|
+
// - `hasMigratedRef` guards `onMigrated` against being called twice
|
|
13917
|
+
// with the same legacy value.
|
|
13918
|
+
// - `cachedRawValueRef` snapshots the legacy raw string the first
|
|
13919
|
+
// time we observe it. The strip navigation below removes the
|
|
13920
|
+
// value from `search` on the next render, so without a cache we
|
|
13921
|
+
// can never migrate it later. This matters in one specific edge
|
|
13922
|
+
// case: `hashAlreadyHasValue` is `true` on the first render (so
|
|
13923
|
+
// we skip `onMigrated` and just strip), then later flips to
|
|
13924
|
+
// `false` because another actor cleared the canonical hash. With
|
|
13925
|
+
// the cache the next render can still surface the legacy value.
|
|
13926
|
+
//
|
|
13927
|
+
// The previous implementation conflated all of this into a single
|
|
13928
|
+
// `hasRunRef`, which permanently lost the legacy state in that race.
|
|
13929
|
+
const hasStrippedRef = useRef(false);
|
|
13930
|
+
const hasMigratedRef = useRef(false);
|
|
13931
|
+
const cachedRawValueRef = useRef(undefined);
|
|
13932
|
+
const decodeRef = useRef(decode);
|
|
13933
|
+
const fromUrlValueRef = useRef(fromUrlValue);
|
|
13934
|
+
const validateRef = useRef(validate);
|
|
13935
|
+
const onMigratedRef = useRef(onMigrated);
|
|
13936
|
+
useEffect(() => {
|
|
13937
|
+
decodeRef.current = decode;
|
|
13938
|
+
fromUrlValueRef.current = fromUrlValue;
|
|
13939
|
+
validateRef.current = validate;
|
|
13940
|
+
onMigratedRef.current = onMigrated;
|
|
13941
|
+
}, [decode, fromUrlValue, validate, onMigrated]);
|
|
13942
|
+
useEffect(() => {
|
|
13943
|
+
if (!enabled) {
|
|
13944
|
+
return;
|
|
13945
|
+
}
|
|
13946
|
+
if (hasStrippedRef.current && hasMigratedRef.current) {
|
|
13947
|
+
return;
|
|
13948
|
+
}
|
|
13949
|
+
// Snapshot the raw value the first time we see it. After the strip
|
|
13950
|
+
// navigation below, `search?.[key]` is undefined, but a later
|
|
13951
|
+
// render that flips `hashAlreadyHasValue` back to `false` still
|
|
13952
|
+
// needs the original value to migrate.
|
|
13953
|
+
if (cachedRawValueRef.current === undefined) {
|
|
13954
|
+
cachedRawValueRef.current = readPrimitiveSearchParam(search, key);
|
|
13955
|
+
}
|
|
13956
|
+
const rawString = cachedRawValueRef.current;
|
|
13957
|
+
if (rawString === undefined) {
|
|
13958
|
+
// Nothing legacy to migrate yet. Don't flip either flag — the
|
|
13959
|
+
// param may appear on a later render (e.g. router still hydrating).
|
|
13960
|
+
return;
|
|
13961
|
+
}
|
|
13962
|
+
if (!hasMigratedRef.current && !hashAlreadyHasValue) {
|
|
13963
|
+
let migratedState;
|
|
13964
|
+
try {
|
|
13965
|
+
const decoded = decodeRef.current(rawString);
|
|
13966
|
+
const transformed = fromUrlValueRef.current ? fromUrlValueRef.current(decoded) : decoded;
|
|
13967
|
+
migratedState = validateRef.current(transformed);
|
|
13968
|
+
}
|
|
13969
|
+
catch {
|
|
13970
|
+
migratedState = undefined;
|
|
13971
|
+
}
|
|
13972
|
+
// Invalid legacy data is silently dropped: the search param is
|
|
13973
|
+
// still stripped below, so the URL stays clean. Surfacing a
|
|
13974
|
+
// console warning here would require disabling a lint rule, and
|
|
13975
|
+
// the data loss is bounded — localStorage holds the working state
|
|
13976
|
+
// for the current session, and the user's next interaction
|
|
13977
|
+
// repopulates the hash. We still flip the flag in the invalid
|
|
13978
|
+
// case so we don't burn cycles re-decoding the same bad string
|
|
13979
|
+
// on every render.
|
|
13980
|
+
hasMigratedRef.current = true;
|
|
13981
|
+
if (migratedState !== undefined) {
|
|
13982
|
+
onMigratedRef.current(migratedState);
|
|
13983
|
+
}
|
|
13984
|
+
}
|
|
13985
|
+
if (!hasStrippedRef.current) {
|
|
13986
|
+
hasStrippedRef.current = true;
|
|
13987
|
+
void navigate({
|
|
13988
|
+
to: ".",
|
|
13989
|
+
search: (prev) => ({ ...prev, [key]: undefined }),
|
|
13990
|
+
hash: location.hash,
|
|
13991
|
+
replace: true,
|
|
13992
|
+
});
|
|
13993
|
+
}
|
|
13994
|
+
}, [enabled, key, hashAlreadyHasValue, search, navigate, location.hash]);
|
|
13995
|
+
};
|
|
13996
|
+
|
|
13736
13997
|
/**
|
|
13737
|
-
*
|
|
13738
|
-
*
|
|
13739
|
-
*
|
|
13740
|
-
*
|
|
13741
|
-
*
|
|
13742
|
-
|
|
13743
|
-
|
|
13744
|
-
|
|
13745
|
-
*
|
|
13746
|
-
*
|
|
13747
|
-
*
|
|
13748
|
-
*
|
|
13749
|
-
*
|
|
13750
|
-
*
|
|
13751
|
-
*
|
|
13752
|
-
*
|
|
13753
|
-
*
|
|
13754
|
-
* it cannot drain onto the next route.
|
|
13755
|
-
*
|
|
13756
|
-
* @param options - Configuration for the URL entry sync.
|
|
13757
|
-
* @param options.key - The search param name or fragment entry name.
|
|
13758
|
-
* @param options.location - `"search"` or `"hash"`.
|
|
13998
|
+
* Default maximum full URL length after adding one persisted search param
|
|
13999
|
+
* before the URL write is skipped (localStorage still persists the full state).
|
|
14000
|
+
*
|
|
14001
|
+
* Sized to stay safely below the AWS WAF managed-rule default
|
|
14002
|
+
* `SizeRestrictions_QUERYSTRING` cap of 5000 bytes for the entire url host + path + query + hash string
|
|
14003
|
+
*/
|
|
14004
|
+
const MAX_URL_LENGTH = 5000;
|
|
14005
|
+
/**
|
|
14006
|
+
* Syncs an encoded string value with a URL search parameter via Tanstack Router.
|
|
14007
|
+
*
|
|
14008
|
+
* Provides a write function that updates the URL through the application
|
|
14009
|
+
* router (preserving other search params), guards against exceeding
|
|
14010
|
+
* {@link MAX_URL_LENGTH}, and detects external URL changes (browser
|
|
14011
|
+
* back/forward, shared links) via an optional callback.
|
|
14012
|
+
*
|
|
14013
|
+
* @param options - Configuration for the search param sync.
|
|
14014
|
+
* @param options.key - The URL search parameter name.
|
|
13759
14015
|
* @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
|
|
13760
|
-
* @param options.onExternalChange - Called when the
|
|
13761
|
-
* @param options.replace - `true` to always use `replaceState
|
|
13762
|
-
* Default: replace when the entry is being added, push when it already has a value.
|
|
14016
|
+
* @param options.onExternalChange - Called when the param changes externally.
|
|
14017
|
+
* @param options.replace - `true` to always use `replaceState`.
|
|
13763
14018
|
*/
|
|
13764
|
-
const
|
|
14019
|
+
const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: replaceOption, }) => {
|
|
13765
14020
|
const navigate = useNavigate();
|
|
13766
14021
|
const location = useLocation();
|
|
13767
14022
|
const router = useRouter();
|
|
13768
14023
|
const search = useSearch({ strict: false, shouldThrow: false });
|
|
13769
|
-
const isHash = urlLocation === "hash";
|
|
13770
14024
|
const lastWrittenRef = useRef(undefined);
|
|
13771
14025
|
const pendingWriteRef = useRef(null);
|
|
13772
14026
|
const onExternalChangeRef = useRef(onExternalChange);
|
|
13773
14027
|
useEffect(() => {
|
|
13774
14028
|
onExternalChangeRef.current = onExternalChange;
|
|
13775
14029
|
}, [onExternalChange]);
|
|
13776
|
-
const
|
|
14030
|
+
const currentSearchValue = useMemo(() => {
|
|
13777
14031
|
if (!enabled) {
|
|
13778
14032
|
return undefined;
|
|
13779
14033
|
}
|
|
13780
|
-
return
|
|
13781
|
-
}, [enabled,
|
|
14034
|
+
return readPrimitiveSearchParam(search, key);
|
|
14035
|
+
}, [enabled, search, key]);
|
|
13782
14036
|
useEffect(() => {
|
|
13783
|
-
if (!enabled
|
|
14037
|
+
if (!enabled) {
|
|
13784
14038
|
return;
|
|
13785
14039
|
}
|
|
13786
|
-
if (
|
|
14040
|
+
if (currentSearchValue === undefined) {
|
|
13787
14041
|
return;
|
|
13788
14042
|
}
|
|
13789
|
-
|
|
13790
|
-
|
|
13791
|
-
|
|
13792
|
-
|
|
14043
|
+
if (currentSearchValue === lastWrittenRef.current) {
|
|
14044
|
+
return;
|
|
14045
|
+
}
|
|
14046
|
+
if (lastWrittenRef.current === undefined) {
|
|
14047
|
+
return;
|
|
14048
|
+
}
|
|
14049
|
+
// A caller may have primed `lastWrittenRef` via `updateSearchParam` while the
|
|
14050
|
+
// URL still carries the previous value (queued rAF / router pending). Treating
|
|
14051
|
+
// that stale URL as an external change reloads filter state from persistence
|
|
14052
|
+
// and can clear in-progress search input before the write lands.
|
|
13793
14053
|
if (pendingWriteRef.current !== null) {
|
|
13794
14054
|
return;
|
|
13795
14055
|
}
|
|
13796
14056
|
requestAnimationFrame(() => {
|
|
13797
14057
|
onExternalChangeRef.current?.();
|
|
13798
14058
|
});
|
|
13799
|
-
}, [
|
|
13800
|
-
|
|
13801
|
-
// navigation; it only needs the location and current value at the moment it
|
|
13802
|
-
// flushes, which may be an animation frame after it was queued.
|
|
13803
|
-
const locationRef = useRef(location);
|
|
13804
|
-
const currentValueRef = useRef(currentValue);
|
|
13805
|
-
useEffect(() => {
|
|
13806
|
-
locationRef.current = location;
|
|
13807
|
-
currentValueRef.current = currentValue;
|
|
13808
|
-
}, [location, currentValue]);
|
|
13809
|
-
const getUrlLengthWithSearchParam = useCallback((paramValue, params) => {
|
|
13810
|
-
const { href, searchStr, hash } = locationRef.current;
|
|
14059
|
+
}, [currentSearchValue, enabled]);
|
|
14060
|
+
const getUrlLengthWithSearchParam = useCallback((paramKey, paramValue, params) => {
|
|
13811
14061
|
const otherParamsLength = objectKeys(params)
|
|
13812
|
-
.filter(k => k !==
|
|
14062
|
+
.filter(k => k !== paramKey)
|
|
13813
14063
|
.reduce((totalLength, k) => {
|
|
13814
14064
|
const kLen = encodeURIComponent(String(k)).length;
|
|
13815
14065
|
const v = params[k];
|
|
13816
14066
|
const vLen = v !== null && v !== undefined ? encodeURIComponent(String(v)).length : 0;
|
|
13817
14067
|
return totalLength + kLen + vLen + (totalLength > 0 ? 2 : 1);
|
|
13818
14068
|
}, 0);
|
|
13819
|
-
const urlBase = href.length - (searchStr.length || 0) + (hash.length || 0);
|
|
13820
|
-
return urlBase + otherParamsLength + 1 +
|
|
13821
|
-
}, [
|
|
14069
|
+
const urlBase = location.href.length - (location.searchStr.length || 0) + (location.hash.length || 0);
|
|
14070
|
+
return urlBase + otherParamsLength + 1 + paramKey.length + 1 + paramValue.length;
|
|
14071
|
+
}, [location]);
|
|
14072
|
+
// Writes are dispatched via a "pending write" queue rather than calling
|
|
14073
|
+
// `navigate` directly from inside the rAF callback. See the matching
|
|
14074
|
+
// comment block in `useHashParamSync` for the full rationale — in short,
|
|
14075
|
+
// navigating while `router.state.status === "pending"` was previously
|
|
14076
|
+
// dropped silently, which lost a one-shot write (a legacy search param
|
|
14077
|
+
// migrated into the URL, or the first edit) on routes that mount during
|
|
14078
|
+
// a loader-driven pending phase.
|
|
13822
14079
|
const flushPendingWrite = useCallback(() => {
|
|
13823
14080
|
const pending = pendingWriteRef.current;
|
|
13824
14081
|
if (pending === null) {
|
|
13825
14082
|
return;
|
|
13826
14083
|
}
|
|
13827
14084
|
if (router.state.status === "pending") {
|
|
13828
|
-
// Keep the write queued; the status-watcher effect retries
|
|
14085
|
+
// Keep the write queued; the status-watcher effect below retries on
|
|
14086
|
+
// the next idle transition.
|
|
13829
14087
|
return;
|
|
13830
14088
|
}
|
|
13831
14089
|
pendingWriteRef.current = null;
|
|
13832
|
-
const { encodedValue } = pending;
|
|
13833
|
-
const
|
|
13834
|
-
const valueInUrl = currentValueRef.current;
|
|
13835
|
-
const shouldReplace = replaceOption ?? !Boolean(valueInUrl);
|
|
13836
|
-
if (!isHash) {
|
|
13837
|
-
void navigate({
|
|
13838
|
-
to: ".",
|
|
13839
|
-
search: (prev) => getUrlLengthWithSearchParam(encodedValue ?? "", prev) <= MAX_URL_LENGTH
|
|
13840
|
-
? { ...prev, [key]: encodedValue }
|
|
13841
|
-
: { ...prev, [key]: undefined },
|
|
13842
|
-
hash,
|
|
13843
|
-
replace: shouldReplace,
|
|
13844
|
-
});
|
|
13845
|
-
return;
|
|
13846
|
-
}
|
|
13847
|
-
let nextHash = writeHashKey(hash, key, encodedValue);
|
|
13848
|
-
let removedOversizedValue = false;
|
|
13849
|
-
if (nextHash.length > MAX_HASH_LENGTH) {
|
|
13850
|
-
if (valueInUrl === undefined) {
|
|
13851
|
-
return;
|
|
13852
|
-
}
|
|
13853
|
-
nextHash = writeHashKey(hash, key, undefined);
|
|
13854
|
-
removedOversizedValue = true;
|
|
13855
|
-
}
|
|
14090
|
+
const { encodedValue, options } = pending;
|
|
14091
|
+
const shouldReplace = options?.replace ?? replaceOption ?? !Boolean(currentSearchValue);
|
|
13856
14092
|
void navigate({
|
|
13857
14093
|
to: ".",
|
|
13858
|
-
|
|
13859
|
-
|
|
13860
|
-
|
|
13861
|
-
|
|
13862
|
-
|
|
13863
|
-
|
|
14094
|
+
search: (prev) => {
|
|
14095
|
+
if (getUrlLengthWithSearchParam(key, encodedValue || "", prev) <= MAX_URL_LENGTH) {
|
|
14096
|
+
return { ...prev, [key]: encodedValue };
|
|
14097
|
+
}
|
|
14098
|
+
else {
|
|
14099
|
+
return { ...prev, [key]: undefined };
|
|
14100
|
+
}
|
|
14101
|
+
},
|
|
14102
|
+
hash: location.hash,
|
|
14103
|
+
replace: shouldReplace,
|
|
13864
14104
|
});
|
|
13865
|
-
}, [
|
|
14105
|
+
}, [
|
|
14106
|
+
router.state.status,
|
|
14107
|
+
location.hash,
|
|
14108
|
+
key,
|
|
14109
|
+
currentSearchValue,
|
|
14110
|
+
navigate,
|
|
14111
|
+
replaceOption,
|
|
14112
|
+
getUrlLengthWithSearchParam,
|
|
14113
|
+
]);
|
|
13866
14114
|
useEffect(() => {
|
|
13867
|
-
if (router.state.status === "pending"
|
|
14115
|
+
if (router.state.status === "pending") {
|
|
14116
|
+
return;
|
|
14117
|
+
}
|
|
14118
|
+
if (pendingWriteRef.current === null) {
|
|
13868
14119
|
return;
|
|
13869
14120
|
}
|
|
13870
14121
|
flushPendingWrite();
|
|
13871
14122
|
}, [router.state.status, flushPendingWrite]);
|
|
14123
|
+
// Drop any queued write when the consumer unmounts. The queue + retry
|
|
14124
|
+
// pattern above keeps writes pending across a `router.state.status ===
|
|
14125
|
+
// "pending"` phase, which means a write scheduled just before a route
|
|
14126
|
+
// change could otherwise drain onto the *next* route once it settles
|
|
14127
|
+
// (e.g. a debounced filter write leaking into the destination route).
|
|
14128
|
+
// Cancelling here preserves the original "writes are scoped to the
|
|
14129
|
+
// owning route" contract that the pre-queue implementation got for
|
|
14130
|
+
// free by dropping the write outright.
|
|
13872
14131
|
useEffect(() => () => {
|
|
13873
14132
|
pendingWriteRef.current = null;
|
|
13874
14133
|
}, []);
|
|
13875
|
-
const updateSearchParam = useCallback((encodedValue) => {
|
|
14134
|
+
const updateSearchParam = useCallback((encodedValue, options) => {
|
|
13876
14135
|
if (!enabled) {
|
|
13877
14136
|
return;
|
|
13878
14137
|
}
|
|
13879
|
-
// Dedupe against the last committed write or the value
|
|
13880
|
-
// both represent the caller's
|
|
14138
|
+
// Dedupe against either the last committed write or the value
|
|
14139
|
+
// currently queued for the next rAF — both represent the caller's
|
|
14140
|
+
// intent so we don't schedule redundant rAF callbacks for identical
|
|
14141
|
+
// requests.
|
|
13881
14142
|
const inFlightOrLastWritten = pendingWriteRef.current?.encodedValue ?? lastWrittenRef.current;
|
|
13882
14143
|
if (encodedValue !== undefined && encodedValue === inFlightOrLastWritten) {
|
|
13883
14144
|
return;
|
|
13884
14145
|
}
|
|
13885
|
-
// Prime here (not
|
|
13886
|
-
// this value as the caller's
|
|
13887
|
-
//
|
|
14146
|
+
// Prime `lastWrittenRef` here (not inside `flushPendingWrite`) so
|
|
14147
|
+
// the external-change detector treats this value as the caller's
|
|
14148
|
+
// authoritative state even when the flush is a no-op (URL already
|
|
14149
|
+
// matches) or stays queued during a pending router phase. Without
|
|
14150
|
+
// priming, the next "external" change would look identical to the
|
|
14151
|
+
// initial render (`lastWrittenRef === undefined`) and the callback
|
|
14152
|
+
// would be skipped.
|
|
13888
14153
|
lastWrittenRef.current = encodedValue;
|
|
13889
|
-
if (
|
|
14154
|
+
if (currentSearchValue === encodedValue) {
|
|
13890
14155
|
return;
|
|
13891
14156
|
}
|
|
13892
|
-
pendingWriteRef.current = { encodedValue };
|
|
14157
|
+
pendingWriteRef.current = { encodedValue, options };
|
|
13893
14158
|
requestAnimationFrame(() => {
|
|
13894
14159
|
flushPendingWrite();
|
|
13895
14160
|
});
|
|
13896
|
-
}, [enabled, flushPendingWrite]);
|
|
13897
|
-
return useMemo(() => ({ searchValue:
|
|
14161
|
+
}, [enabled, currentSearchValue, flushPendingWrite]);
|
|
14162
|
+
return useMemo(() => ({ searchValue: currentSearchValue, updateSearchParam }), [currentSearchValue, updateSearchParam]);
|
|
14163
|
+
};
|
|
14164
|
+
|
|
14165
|
+
/**
|
|
14166
|
+
* Generates a localStorage key, optionally scoped to a specific user.
|
|
14167
|
+
*
|
|
14168
|
+
* When `userId` is provided the key is `"key-userId"`.
|
|
14169
|
+
* When omitted the key is returned as-is (unscoped).
|
|
14170
|
+
*
|
|
14171
|
+
* @param key - Unique persistence identifier.
|
|
14172
|
+
* @param userId - Client-side user id to scope storage per user.
|
|
14173
|
+
* @returns {string} The combined storage key.
|
|
14174
|
+
*/
|
|
14175
|
+
const useStorageKey = (key, userId) => {
|
|
14176
|
+
return useMemo(() => (userId ? `${key}-${userId}` : key), [key, userId]);
|
|
13898
14177
|
};
|
|
13899
14178
|
|
|
13900
14179
|
/**
|
|
@@ -13916,39 +14195,47 @@ const rawLocalStorage = {
|
|
|
13916
14195
|
* and writes changes to both.
|
|
13917
14196
|
*
|
|
13918
14197
|
* On mount the hook resolves the initial state as follows:
|
|
13919
|
-
* - If the URL slot
|
|
14198
|
+
* - If the canonical URL slot (search OR hash, per `urlLocation`) holds a
|
|
14199
|
+
* value, decode it and pass through `validate`.
|
|
14200
|
+
* - In `urlLocation: "hash"` mode, if the canonical hash slot has no valid
|
|
14201
|
+
* value but a **legacy** `?<key>=...` search param is present, decode and
|
|
14202
|
+
* validate that as a one-shot fallback. The value is copied into the hash
|
|
14203
|
+
* and the search param is stripped via a `replace` navigation.
|
|
14204
|
+
* - A value that comes only from localStorage is not written to the URL.
|
|
14205
|
+
* Copying it on load left a layout in history, bookmarks, and login
|
|
14206
|
+
* redirects, which then replayed an older layout over the saved one
|
|
14207
|
+
* (GLU-2396). The URL is written when the user changes the state.
|
|
13920
14208
|
* - If localStorage is enabled, read and validate its value.
|
|
13921
14209
|
* - When `mergeWithStorageOnRead` is `false` (default): URL state wins
|
|
13922
14210
|
* entirely when present, otherwise fall back to localStorage.
|
|
13923
14211
|
* - When `mergeWithStorageOnRead` is `true`: shallow-merge URL fields over
|
|
13924
14212
|
* localStorage so fields stripped by `toUrlValue` survive via storage.
|
|
13925
14213
|
*
|
|
13926
|
-
*
|
|
13927
|
-
*
|
|
13928
|
-
*
|
|
13929
|
-
*
|
|
13930
|
-
*
|
|
13931
|
-
* `
|
|
13932
|
-
*
|
|
13933
|
-
*
|
|
13934
|
-
*
|
|
13935
|
-
*
|
|
13936
|
-
*
|
|
13937
|
-
*
|
|
13938
|
-
*
|
|
13939
|
-
*
|
|
13940
|
-
*
|
|
13941
|
-
*
|
|
13942
|
-
* The snapshot is re-read when the storage key or the storage/merge options
|
|
13943
|
-
* change, in the same render, so a write in that commit already sees the
|
|
13944
|
-
* state for the new key.
|
|
14214
|
+
* `persistState` writes the state to localStorage (via `serialize`, default
|
|
14215
|
+
* `storageSerializer.serialize`) and syncs to the canonical URL slot (via
|
|
14216
|
+
* `toUrlValue` + encoding). Duplicate writes where the state has not
|
|
14217
|
+
* changed (deep equality) are skipped.
|
|
14218
|
+
*
|
|
14219
|
+
* `commitHydratedState` writes the hydrated value to localStorage only,
|
|
14220
|
+
* when it came from the URL and differs from what this identity has stored.
|
|
14221
|
+
* It does not update the URL. It returns `false` while the snapshot is
|
|
14222
|
+
* still for a previous read, so the caller can retry after identity
|
|
14223
|
+
* resolution. The caller decides which identity is allowed to commit.
|
|
14224
|
+
*
|
|
14225
|
+
* `clearState` removes the entry from localStorage and from the URL slot,
|
|
14226
|
+
* rather than writing an empty value. Use it when the state a consumer
|
|
14227
|
+
* derives has become empty, so no leftover entry is stored for a value the
|
|
14228
|
+
* user has reset. It is idempotent — clearing an absent entry is a no-op.
|
|
13945
14229
|
*
|
|
13946
14230
|
* @param options - Configuration for the persisted state.
|
|
13947
14231
|
* @param options.key - Unique identifier used for both the URL slot and the localStorage key.
|
|
13948
14232
|
* @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
|
|
14233
|
+
* @param options.serialize - Custom localStorage serialiser (default `storageSerializer.serialize`).
|
|
13949
14234
|
* @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
|
|
13950
14235
|
* @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
|
|
14236
|
+
* @param options.enabled - When `false` the URL is neither read nor written (default `true`).
|
|
13951
14237
|
* @param options.localStorageEnabled - When `false` localStorage is neither read nor written (default `true`).
|
|
14238
|
+
* @param options.onExternalChange - Fired when the URL value changes externally (e.g. browser back).
|
|
13952
14239
|
* @param options.replace - Forwarded to the underlying URL sync hook.
|
|
13953
14240
|
* @param options.clientSideUserId - The user ID to use for the localStorage key.
|
|
13954
14241
|
* @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
|
|
@@ -13958,153 +14245,344 @@ const rawLocalStorage = {
|
|
|
13958
14245
|
* @param options.stampUrlWithIdentity - Stamp URL writes with the current identity so the login
|
|
13959
14246
|
* redirect can drop entries left by another account on this browser. Default `false`.
|
|
13960
14247
|
*/
|
|
13961
|
-
const usePersistedState = ({ key, validate, toUrlValue, fromUrlValue, localStorageEnabled = true, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", stampUrlWithIdentity = false, }) => {
|
|
14248
|
+
const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", stampUrlWithIdentity = false, }) => {
|
|
13962
14249
|
const { encode, decode } = useCustomEncoding();
|
|
13963
14250
|
// Fingerprint of the identity this hook writes URL values for. `undefined`
|
|
13964
14251
|
// while the identity is unresolved (or when stamping is off): writes are
|
|
13965
14252
|
// then unstamped.
|
|
13966
14253
|
const currentUrlOwner = useMemo(() => (stampUrlWithIdentity && clientSideUserId !== undefined ? identityFingerprint(clientSideUserId) : undefined), [stampUrlWithIdentity, clientSideUserId]);
|
|
13967
|
-
const
|
|
14254
|
+
const searchActive = enabled && urlLocation === "search";
|
|
14255
|
+
const hashActive = enabled && urlLocation === "hash";
|
|
14256
|
+
// Both URL-sync hooks are mounted unconditionally to satisfy the Rules of
|
|
14257
|
+
// Hooks; the inactive one is gated off via `enabled: false` and is a
|
|
14258
|
+
// no-op at runtime.
|
|
14259
|
+
const searchSync = useSearchParamSync({
|
|
14260
|
+
key,
|
|
14261
|
+
enabled: searchActive,
|
|
14262
|
+
onExternalChange,
|
|
14263
|
+
replace,
|
|
14264
|
+
});
|
|
14265
|
+
const hashSync = useHashParamSync({
|
|
13968
14266
|
key,
|
|
13969
|
-
|
|
14267
|
+
enabled: hashActive,
|
|
14268
|
+
onExternalChange,
|
|
13970
14269
|
replace,
|
|
13971
14270
|
});
|
|
14271
|
+
const { searchValue, updateSearchParam } = hashActive ? hashSync : searchSync;
|
|
14272
|
+
// In hash mode, also read the raw search params so a legacy `?<key>=...`
|
|
14273
|
+
// value can hydrate the initial state once, before the migration effect
|
|
14274
|
+
// strips it. In search mode this stays `undefined` so the legacy fallback
|
|
14275
|
+
// is inert.
|
|
14276
|
+
const rawSearch = useSearch({ strict: false, shouldThrow: false });
|
|
14277
|
+
const legacySearchValue = useMemo(() => {
|
|
14278
|
+
if (!hashActive) {
|
|
14279
|
+
return undefined;
|
|
14280
|
+
}
|
|
14281
|
+
return readPrimitiveSearchParam(rawSearch, key);
|
|
14282
|
+
}, [hashActive, rawSearch, key]);
|
|
13972
14283
|
const storageKey = useStorageKey(key, clientSideUserId);
|
|
13973
|
-
// `toUrlValue` is read through a ref so the write callbacks stay stable
|
|
13974
|
-
// when a consumer passes an inline function.
|
|
13975
14284
|
const toUrlValueRef = useRef(toUrlValue);
|
|
13976
14285
|
useEffect(() => {
|
|
13977
14286
|
toUrlValueRef.current = toUrlValue;
|
|
13978
14287
|
}, [toUrlValue]);
|
|
13979
|
-
const
|
|
13980
|
-
|
|
13981
|
-
|
|
13982
|
-
|
|
13983
|
-
|
|
13984
|
-
|
|
13985
|
-
|
|
14288
|
+
const serializeRef = useRef(serialize);
|
|
14289
|
+
useEffect(() => {
|
|
14290
|
+
serializeRef.current = serialize;
|
|
14291
|
+
}, [serialize]);
|
|
14292
|
+
const validateRef = useRef(validate);
|
|
14293
|
+
useEffect(() => {
|
|
14294
|
+
validateRef.current = validate;
|
|
14295
|
+
}, [validate]);
|
|
14296
|
+
const fromUrlValueRef = useRef(fromUrlValue);
|
|
14297
|
+
useEffect(() => {
|
|
14298
|
+
fromUrlValueRef.current = fromUrlValue;
|
|
14299
|
+
}, [fromUrlValue]);
|
|
14300
|
+
const searchValueRef = useRef(searchValue);
|
|
14301
|
+
useEffect(() => {
|
|
14302
|
+
searchValueRef.current = searchValue;
|
|
14303
|
+
}, [searchValue]);
|
|
14304
|
+
const legacySearchValueRef = useRef(legacySearchValue);
|
|
14305
|
+
useEffect(() => {
|
|
14306
|
+
legacySearchValueRef.current = legacySearchValue;
|
|
14307
|
+
}, [legacySearchValue]);
|
|
14308
|
+
const readPersistedState = useCallback(({ valueFromUrl, legacyValueFromUrl, validateState, transformFromUrlValue, }) => {
|
|
14309
|
+
// The ownership stamp is only read at the login redirect (see
|
|
14310
|
+
// `useStripForeignIdentityHash`); here it is unwrapped and discarded.
|
|
14311
|
+
const tryDecode = (raw) => {
|
|
14312
|
+
try {
|
|
14313
|
+
const { value } = unwrapUrlOwner(decode(raw));
|
|
14314
|
+
const transformed = transformFromUrlValue ? transformFromUrlValue(value) : value;
|
|
14315
|
+
const state = validateState(transformed);
|
|
14316
|
+
if (state === undefined) {
|
|
14317
|
+
return undefined;
|
|
14318
|
+
}
|
|
14319
|
+
return { state, payload: value };
|
|
14320
|
+
}
|
|
14321
|
+
catch {
|
|
14322
|
+
return undefined;
|
|
14323
|
+
}
|
|
14324
|
+
};
|
|
14325
|
+
const readStoredState = () => {
|
|
14326
|
+
if (!localStorageEnabled) {
|
|
13986
14327
|
return undefined;
|
|
13987
14328
|
}
|
|
13988
|
-
return validateState(storageSerializer.deserialize(raw));
|
|
13989
|
-
}
|
|
13990
|
-
catch {
|
|
13991
|
-
return undefined;
|
|
13992
|
-
}
|
|
13993
|
-
}, [localStorageEnabled, storageKey]);
|
|
13994
|
-
const readPersistedState = useCallback(({ valueFromUrl, validateState, transformFromUrlValue }) => {
|
|
13995
|
-
let urlState;
|
|
13996
|
-
if (valueFromUrl) {
|
|
13997
14329
|
try {
|
|
13998
|
-
|
|
13999
|
-
|
|
14000
|
-
|
|
14001
|
-
|
|
14330
|
+
const raw = rawLocalStorage.read(storageKey);
|
|
14331
|
+
if (!raw) {
|
|
14332
|
+
return undefined;
|
|
14333
|
+
}
|
|
14334
|
+
return validateState(storageSerializer.deserialize(raw));
|
|
14002
14335
|
}
|
|
14003
14336
|
catch {
|
|
14004
|
-
|
|
14337
|
+
return undefined;
|
|
14338
|
+
}
|
|
14339
|
+
};
|
|
14340
|
+
let urlState;
|
|
14341
|
+
let decodedPayload;
|
|
14342
|
+
let canonicalUrlValid = false;
|
|
14343
|
+
let usedLegacyUrl = false;
|
|
14344
|
+
if (enabled && valueFromUrl) {
|
|
14345
|
+
const decoded = tryDecode(valueFromUrl);
|
|
14346
|
+
if (decoded !== undefined) {
|
|
14347
|
+
urlState = decoded.state;
|
|
14348
|
+
decodedPayload = decoded.payload;
|
|
14349
|
+
canonicalUrlValid = true;
|
|
14005
14350
|
}
|
|
14006
14351
|
}
|
|
14007
|
-
|
|
14008
|
-
|
|
14009
|
-
|
|
14010
|
-
|
|
14011
|
-
|
|
14012
|
-
|
|
14013
|
-
hydrated = stored;
|
|
14352
|
+
if (urlState === undefined && enabled && legacyValueFromUrl) {
|
|
14353
|
+
const decoded = tryDecode(legacyValueFromUrl);
|
|
14354
|
+
if (decoded !== undefined) {
|
|
14355
|
+
urlState = decoded.state;
|
|
14356
|
+
usedLegacyUrl = true;
|
|
14357
|
+
}
|
|
14014
14358
|
}
|
|
14015
|
-
|
|
14016
|
-
|
|
14017
|
-
|
|
14018
|
-
|
|
14359
|
+
// Without the merge option, URL state wins entirely when present —
|
|
14360
|
+
// this preserves the original "URL > localStorage" priority.
|
|
14361
|
+
if (urlState !== undefined && !mergeWithStorageOnRead) {
|
|
14362
|
+
return {
|
|
14363
|
+
initialState: urlState,
|
|
14364
|
+
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14365
|
+
canonicalUrlPayload: decodedPayload,
|
|
14366
|
+
needsUrlRestore: usedLegacyUrl,
|
|
14367
|
+
};
|
|
14019
14368
|
}
|
|
14020
|
-
|
|
14021
|
-
|
|
14369
|
+
const storageState = readStoredState();
|
|
14370
|
+
if (urlState !== undefined && storageState !== undefined) {
|
|
14371
|
+
// Shallow merge — URL fields take precedence; storage fills in
|
|
14372
|
+
// whatever the URL omits (e.g. fields stripped by `toUrlValue`).
|
|
14373
|
+
return {
|
|
14374
|
+
initialState: { ...storageState, ...urlState },
|
|
14375
|
+
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14376
|
+
canonicalUrlPayload: decodedPayload,
|
|
14377
|
+
needsUrlRestore: usedLegacyUrl,
|
|
14378
|
+
};
|
|
14379
|
+
}
|
|
14380
|
+
if (urlState !== undefined) {
|
|
14381
|
+
return {
|
|
14382
|
+
initialState: urlState,
|
|
14383
|
+
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14384
|
+
canonicalUrlPayload: decodedPayload,
|
|
14385
|
+
needsUrlRestore: usedLegacyUrl,
|
|
14386
|
+
};
|
|
14387
|
+
}
|
|
14388
|
+
if (storageState !== undefined) {
|
|
14389
|
+
return {
|
|
14390
|
+
initialState: storageState,
|
|
14391
|
+
canonicalUrlHasValidState: false,
|
|
14392
|
+
canonicalUrlPayload: undefined,
|
|
14393
|
+
// Leave the URL alone. A storage-only load must not grow a hash,
|
|
14394
|
+
// or the next history/bookmark/login of this page replays it.
|
|
14395
|
+
needsUrlRestore: false,
|
|
14396
|
+
};
|
|
14022
14397
|
}
|
|
14023
|
-
return {
|
|
14024
|
-
|
|
14025
|
-
|
|
14398
|
+
return {
|
|
14399
|
+
initialState: undefined,
|
|
14400
|
+
canonicalUrlHasValidState: false,
|
|
14401
|
+
canonicalUrlPayload: undefined,
|
|
14402
|
+
needsUrlRestore: false,
|
|
14403
|
+
};
|
|
14404
|
+
}, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
|
|
14405
|
+
// Every URL write goes through here so the ownership stamp is applied
|
|
14406
|
+
// uniformly (user edits and legacy search-param migration).
|
|
14026
14407
|
const encodeForUrl = useCallback((state) => {
|
|
14027
|
-
const
|
|
14028
|
-
return encode(currentUrlOwner === undefined ?
|
|
14408
|
+
const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
|
|
14409
|
+
return encode(currentUrlOwner === undefined ? urlValue : wrapUrlOwner(urlValue, currentUrlOwner));
|
|
14029
14410
|
}, [encode, currentUrlOwner]);
|
|
14030
|
-
|
|
14031
|
-
|
|
14032
|
-
|
|
14033
|
-
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
14037
|
-
|
|
14038
|
-
|
|
14039
|
-
});
|
|
14040
|
-
const
|
|
14041
|
-
if (snapshot.readWith !== readPersistedState) {
|
|
14042
|
-
setSnapshot(readSnapshot());
|
|
14043
|
-
}
|
|
14044
|
-
const { initialState, storedState } = snapshot;
|
|
14045
|
-
// Last value written to each slot, for deduping. The URL side starts as the
|
|
14046
|
-
// hydrated value, so a consumer that echoes `initialState` on mount writes
|
|
14047
|
-
// nothing: a layout read from storage must not grow a URL entry (GLU-2396).
|
|
14048
|
-
// The storage side starts as what storage held, so persisting the hydrated
|
|
14049
|
-
// state after opening a link writes only when the URL changed something.
|
|
14050
|
-
// Both are re-seeded whenever the snapshot is re-read so the first write to
|
|
14051
|
-
// a newly scoped key is never skipped. Two refs, not one, because a consumer
|
|
14052
|
-
// may schedule the two writes differently (storage at once, URL debounced).
|
|
14053
|
-
const lastStoredRef = useRef(storedState);
|
|
14054
|
-
const lastUrlRef = useRef(initialState);
|
|
14411
|
+
const persistedStateReadKey = `${enabled}:${localStorageEnabled}:${mergeWithStorageOnRead}:${urlLocation}:${storageKey}`;
|
|
14412
|
+
const [persistedStateSnapshot, setPersistedStateSnapshot] = useState(() => ({
|
|
14413
|
+
...readPersistedState({
|
|
14414
|
+
valueFromUrl: searchValue,
|
|
14415
|
+
legacyValueFromUrl: legacySearchValue,
|
|
14416
|
+
validateState: validate,
|
|
14417
|
+
transformFromUrlValue: fromUrlValue,
|
|
14418
|
+
}),
|
|
14419
|
+
readKey: persistedStateReadKey,
|
|
14420
|
+
}));
|
|
14421
|
+
const persistedStateReadKeyRef = useRef(persistedStateReadKey);
|
|
14055
14422
|
useEffect(() => {
|
|
14056
|
-
|
|
14057
|
-
lastUrlRef.current = initialState;
|
|
14058
|
-
}, [initialState, storedState]);
|
|
14059
|
-
const persistToStorage = useCallback((state) => {
|
|
14060
|
-
if (!localStorageEnabled) {
|
|
14423
|
+
if (persistedStateReadKeyRef.current === persistedStateReadKey) {
|
|
14061
14424
|
return;
|
|
14062
14425
|
}
|
|
14063
|
-
|
|
14064
|
-
|
|
14065
|
-
|
|
14426
|
+
persistedStateReadKeyRef.current = persistedStateReadKey;
|
|
14427
|
+
setPersistedStateSnapshot({
|
|
14428
|
+
...readPersistedState({
|
|
14429
|
+
valueFromUrl: searchValueRef.current,
|
|
14430
|
+
legacyValueFromUrl: legacySearchValueRef.current,
|
|
14431
|
+
validateState: validateRef.current,
|
|
14432
|
+
transformFromUrlValue: fromUrlValueRef.current,
|
|
14433
|
+
}),
|
|
14434
|
+
readKey: persistedStateReadKey,
|
|
14435
|
+
});
|
|
14436
|
+
}, [persistedStateReadKey, readPersistedState]);
|
|
14437
|
+
const { initialState, canonicalUrlHasValidState, canonicalUrlPayload, needsUrlRestore } = persistedStateSnapshot;
|
|
14438
|
+
// Late-hydration recovery: TanStack Router's `useSearch` can return an
|
|
14439
|
+
// empty object on the very first render of a route that's still in a
|
|
14440
|
+
// loader-driven pending phase. The `useState` initializer above runs
|
|
14441
|
+
// exactly once, so a legacy `?<key>=...` value that only becomes
|
|
14442
|
+
// visible on a later render would otherwise be missed entirely —
|
|
14443
|
+
// `initialState` would resolve to `undefined`, and the migration
|
|
14444
|
+
// effect would strip the legacy param without ever piping it through
|
|
14445
|
+
// `onMigrated`. We re-read here when:
|
|
14446
|
+
// - the snapshot has nothing usable yet (`initialState === undefined`
|
|
14447
|
+
// AND the canonical URL slot has no valid value), and
|
|
14448
|
+
// - a legacy value has just appeared.
|
|
14449
|
+
// Once `initialState` is populated, this effect's guard short-circuits
|
|
14450
|
+
// and the rest of the lifecycle (restore + migration) takes over.
|
|
14451
|
+
useEffect(() => {
|
|
14452
|
+
if (initialState !== undefined || canonicalUrlHasValidState) {
|
|
14453
|
+
return;
|
|
14454
|
+
}
|
|
14455
|
+
if (legacySearchValue === undefined) {
|
|
14456
|
+
return;
|
|
14457
|
+
}
|
|
14458
|
+
setPersistedStateSnapshot({
|
|
14459
|
+
...readPersistedState({
|
|
14460
|
+
valueFromUrl: searchValueRef.current,
|
|
14461
|
+
legacyValueFromUrl: legacySearchValue,
|
|
14462
|
+
validateState: validateRef.current,
|
|
14463
|
+
transformFromUrlValue: fromUrlValueRef.current,
|
|
14464
|
+
}),
|
|
14465
|
+
readKey: persistedStateReadKey,
|
|
14466
|
+
});
|
|
14467
|
+
}, [legacySearchValue, initialState, canonicalUrlHasValidState, persistedStateReadKey, readPersistedState]);
|
|
14468
|
+
const lastPersistedRef = useRef(initialState);
|
|
14469
|
+
useEffect(() => {
|
|
14470
|
+
lastPersistedRef.current = initialState;
|
|
14471
|
+
}, [initialState, localStorageEnabled, storageKey]);
|
|
14472
|
+
const hasRestoredUrlRef = useRef(false);
|
|
14473
|
+
useEffect(() => {
|
|
14474
|
+
hasRestoredUrlRef.current = false;
|
|
14475
|
+
}, [enabled, localStorageEnabled, storageKey, urlLocation]);
|
|
14476
|
+
useEffect(() => {
|
|
14477
|
+
if (hasRestoredUrlRef.current) {
|
|
14478
|
+
return;
|
|
14479
|
+
}
|
|
14480
|
+
// Only a legacy search param is copied into the hash. Storage is not:
|
|
14481
|
+
// mirroring it on load is what put a layout into every history entry
|
|
14482
|
+
// (GLU-2396). Gate on `canonicalUrlHasValidState`, not on the raw
|
|
14483
|
+
// `searchValue`: an undecodable string in the hash slot must not block
|
|
14484
|
+
// the legacy copy, or the migration strips the search param and the
|
|
14485
|
+
// bad string is the only state left on the next reload.
|
|
14486
|
+
if (!enabled || !needsUrlRestore || initialState === undefined || canonicalUrlHasValidState) {
|
|
14066
14487
|
return;
|
|
14067
14488
|
}
|
|
14068
|
-
|
|
14489
|
+
hasRestoredUrlRef.current = true;
|
|
14490
|
+
updateSearchParam(encodeForUrl(initialState), { replace: true });
|
|
14491
|
+
}, [enabled, needsUrlRestore, initialState, canonicalUrlHasValidState, updateSearchParam, encodeForUrl]);
|
|
14492
|
+
// Strip the legacy search param so reloads come from the canonical hash
|
|
14493
|
+
// slot and the URL stays tidy. The hook is a no-op outside hash mode.
|
|
14494
|
+
// `hashAlreadyHasValue` is `true` only when the canonical hash slot
|
|
14495
|
+
// already holds *valid* state — a raw but undecodable string must not
|
|
14496
|
+
// suppress `onMigrated`, otherwise a valid legacy value would be lost
|
|
14497
|
+
// on the next reload (see `canonicalUrlHasValidState` for the same
|
|
14498
|
+
// reasoning in the restore effect above).
|
|
14499
|
+
useMigrateLegacySearchParam({
|
|
14500
|
+
key,
|
|
14501
|
+
enabled: hashActive,
|
|
14502
|
+
decode,
|
|
14503
|
+
fromUrlValue,
|
|
14504
|
+
validate,
|
|
14505
|
+
hashAlreadyHasValue: canonicalUrlHasValidState,
|
|
14506
|
+
onMigrated: useCallback((migrated) => {
|
|
14507
|
+
if (dequal(lastPersistedRef.current, migrated)) {
|
|
14508
|
+
return;
|
|
14509
|
+
}
|
|
14510
|
+
lastPersistedRef.current = migrated;
|
|
14511
|
+
if (localStorageEnabled) {
|
|
14512
|
+
const serialized = serializeRef.current
|
|
14513
|
+
? serializeRef.current(migrated)
|
|
14514
|
+
: storageSerializer.serialize(migrated);
|
|
14515
|
+
rawLocalStorage.write(storageKey, serialized);
|
|
14516
|
+
}
|
|
14517
|
+
updateSearchParam(encodeForUrl(migrated), { replace: true });
|
|
14518
|
+
}, [encodeForUrl, localStorageEnabled, storageKey, updateSearchParam]),
|
|
14519
|
+
});
|
|
14520
|
+
const persistState = useCallback((state) => {
|
|
14521
|
+
if (dequal(lastPersistedRef.current, state)) {
|
|
14069
14522
|
return;
|
|
14070
14523
|
}
|
|
14524
|
+
lastPersistedRef.current = state;
|
|
14525
|
+
if (localStorageEnabled) {
|
|
14526
|
+
const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
|
|
14527
|
+
rawLocalStorage.write(storageKey, serialized);
|
|
14528
|
+
}
|
|
14529
|
+
updateSearchParam(encodeForUrl(state));
|
|
14530
|
+
}, [storageKey, encodeForUrl, localStorageEnabled, updateSearchParam]);
|
|
14531
|
+
const clearState = useCallback(() => {
|
|
14532
|
+
lastPersistedRef.current = undefined;
|
|
14533
|
+
if (localStorageEnabled) {
|
|
14534
|
+
rawLocalStorage.remove(storageKey);
|
|
14535
|
+
}
|
|
14536
|
+
updateSearchParam(undefined);
|
|
14537
|
+
}, [localStorageEnabled, storageKey, updateSearchParam]);
|
|
14538
|
+
const snapshotIsCurrent = persistedStateSnapshot.readKey === persistedStateReadKey;
|
|
14539
|
+
// Opening a shared URL should become this identity's saved layout. The
|
|
14540
|
+
// caller decides which identity is allowed to commit: this only writes
|
|
14541
|
+
// when the snapshot was read for the current storage key, so a late
|
|
14542
|
+
// identity resolution cannot persist the pre-merge value and drop fields
|
|
14543
|
+
// that live only in localStorage. A hash that is only the shareable
|
|
14544
|
+
// projection of what is already stored is left alone, so reloading this
|
|
14545
|
+
// account's own link does not replace hidden columns with the shorter URL form.
|
|
14546
|
+
const commitHydratedState = useCallback(() => {
|
|
14547
|
+
if (!snapshotIsCurrent || !localStorageEnabled) {
|
|
14548
|
+
return false;
|
|
14549
|
+
}
|
|
14550
|
+
if (!canonicalUrlHasValidState || initialState === undefined) {
|
|
14551
|
+
return true;
|
|
14552
|
+
}
|
|
14553
|
+
let stored;
|
|
14071
14554
|
try {
|
|
14072
|
-
rawLocalStorage.
|
|
14073
|
-
|
|
14555
|
+
const raw = rawLocalStorage.read(storageKey);
|
|
14556
|
+
if (raw) {
|
|
14557
|
+
stored = validateRef.current(storageSerializer.deserialize(raw));
|
|
14558
|
+
}
|
|
14074
14559
|
}
|
|
14075
14560
|
catch {
|
|
14076
|
-
|
|
14561
|
+
stored = undefined;
|
|
14077
14562
|
}
|
|
14078
|
-
|
|
14079
|
-
|
|
14080
|
-
|
|
14081
|
-
|
|
14563
|
+
if (stored !== undefined) {
|
|
14564
|
+
const projected = toUrlValueRef.current ? toUrlValueRef.current(stored) : stored;
|
|
14565
|
+
if (dequal(projected, canonicalUrlPayload)) {
|
|
14566
|
+
return true;
|
|
14567
|
+
}
|
|
14082
14568
|
}
|
|
14083
|
-
|
|
14084
|
-
|
|
14085
|
-
|
|
14086
|
-
|
|
14087
|
-
|
|
14088
|
-
|
|
14089
|
-
}, [
|
|
14090
|
-
|
|
14569
|
+
const serialized = serializeRef.current
|
|
14570
|
+
? serializeRef.current(initialState)
|
|
14571
|
+
: storageSerializer.serialize(initialState);
|
|
14572
|
+
rawLocalStorage.write(storageKey, serialized);
|
|
14573
|
+
lastPersistedRef.current = initialState;
|
|
14574
|
+
return true;
|
|
14575
|
+
}, [
|
|
14576
|
+
canonicalUrlHasValidState,
|
|
14577
|
+
canonicalUrlPayload,
|
|
14578
|
+
initialState,
|
|
14579
|
+
localStorageEnabled,
|
|
14580
|
+
snapshotIsCurrent,
|
|
14581
|
+
storageKey,
|
|
14582
|
+
]);
|
|
14583
|
+
return useMemo(() => ({ initialState, persistState, clearState, commitHydratedState }), [initialState, persistState, clearState, commitHydratedState]);
|
|
14091
14584
|
};
|
|
14092
14585
|
|
|
14093
|
-
/**
|
|
14094
|
-
* Syncs an encoded string value with a URL search parameter via Tanstack Router.
|
|
14095
|
-
*
|
|
14096
|
-
* Thin wrapper over {@link useUrlParamSync} with `location: "search"`; see it
|
|
14097
|
-
* for the write queue, the size guard (`MAX_URL_LENGTH`), and external-change
|
|
14098
|
-
* detection.
|
|
14099
|
-
*
|
|
14100
|
-
* @param options - Configuration for the search param sync.
|
|
14101
|
-
* @param options.key - The URL search parameter name.
|
|
14102
|
-
* @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
|
|
14103
|
-
* @param options.onExternalChange - Called when the param changes externally.
|
|
14104
|
-
* @param options.replace - `true` to always use `replaceState`.
|
|
14105
|
-
*/
|
|
14106
|
-
const useSearchParamSync = (options) => useUrlParamSync({ ...options, location: "search" });
|
|
14107
|
-
|
|
14108
14586
|
/**
|
|
14109
14587
|
* Enumerates localStorage keys. Kept local so this file's exemption from the
|
|
14110
14588
|
* no-direct-storage-access rule is centralised, as in `usePersistedState`.
|
|
@@ -15430,4 +15908,4 @@ const useWindowActivity = ({ onFocus, onBlur, skip = false } = { onBlur: undefin
|
|
|
15430
15908
|
*/
|
|
15431
15909
|
setupLibraryTranslations();
|
|
15432
15910
|
|
|
15433
|
-
export { Alert, Badge, Breadcrumb, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyButton, CopyableValue, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, IdentityCard, IdentityCardSkeleton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, LabeledValue, LabeledValueList, List, ListItem, MAX_URL_LENGTH, MenuContent, MenuDivider, MenuItem, MenuTree, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SidebarContentLayout, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHold, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMenuSearchField, useMenuTree, useMergeRefs, useModifierKey, useOptionalPopoverContext, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useStripForeignIdentityHash, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
|
|
15911
|
+
export { Alert, Badge, Breadcrumb, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyButton, CopyableValue, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, IdentityCard, IdentityCardSkeleton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, LabeledValue, LabeledValueList, List, ListItem, MAX_HASH_LENGTH, MAX_URL_LENGTH, MenuContent, MenuDivider, MenuItem, MenuTree, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SidebarContentLayout, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHashParamSync, useHold, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMenuSearchField, useMenuTree, useMergeRefs, useModifierKey, useOptionalPopoverContext, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useStripForeignIdentityHash, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
|