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