@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 CHANGED
@@ -13481,90 +13481,6 @@ 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
-
13568
13484
  /**
13569
13485
  * Pure utilities for reading and writing single named entries inside a URL
13570
13486
  * fragment (the part after `#`).
@@ -13693,6 +13609,183 @@ const writeHashKey = (hash, key, value) => {
13693
13609
  return formatHash(next.join(TOKEN_SEPARATOR));
13694
13610
  };
13695
13611
 
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
+
13696
13789
  const searchRecordSchema = zod.z.record(zod.z.string(), zod.z.unknown());
13697
13790
  const primitiveSearchValueSchema = zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean()]);
13698
13791
  /**
@@ -13715,187 +13808,373 @@ const readPrimitiveSearchParam = (search, key) => {
13715
13808
  };
13716
13809
 
13717
13810
  /**
13718
- * Maximum full URL length after adding one persisted search param before the
13719
- * URL write is skipped (localStorage still persists the full state).
13811
+ * Pure utilities for stamping a URL-persisted value with the identity that
13812
+ * wrote it.
13720
13813
  *
13721
- * Sized to stay safely below the AWS WAF managed-rule default
13722
- * `SizeRestrictions_QUERYSTRING` cap of 5000 bytes for the entire
13723
- * host + path + query + hash string.
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.
13724
13824
  */
13725
- const MAX_URL_LENGTH = 5000;
13726
13825
  /**
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.
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).
13730
13838
  *
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.
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`.
13735
13846
  */
13736
- const MAX_HASH_LENGTH = 64 * 1024;
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
+
13737
13998
  /**
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"`.
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).
14001
+ *
14002
+ * Sized to stay safely below the AWS WAF managed-rule default
14003
+ * `SizeRestrictions_QUERYSTRING` cap of 5000 bytes for the entire url host + path + query + hash string
14004
+ */
14005
+ const MAX_URL_LENGTH = 5000;
14006
+ /**
14007
+ * Syncs an encoded string value with a URL search parameter via Tanstack Router.
14008
+ *
14009
+ * Provides a write function that updates the URL through the application
14010
+ * router (preserving other search params), guards against exceeding
14011
+ * {@link MAX_URL_LENGTH}, and detects external URL changes (browser
14012
+ * back/forward, shared links) via an optional callback.
14013
+ *
14014
+ * @param options - Configuration for the search param sync.
14015
+ * @param options.key - The URL search parameter name.
13760
14016
  * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
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.
14017
+ * @param options.onExternalChange - Called when the param changes externally.
14018
+ * @param options.replace - `true` to always use `replaceState`.
13764
14019
  */
13765
- const useUrlParamSync = ({ key, location: urlLocation, enabled = true, onExternalChange, replace: replaceOption, }) => {
14020
+ const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: replaceOption, }) => {
13766
14021
  const navigate = reactRouter.useNavigate();
13767
14022
  const location = reactRouter.useLocation();
13768
14023
  const router = reactRouter.useRouter();
13769
14024
  const search = reactRouter.useSearch({ strict: false, shouldThrow: false });
13770
- const isHash = urlLocation === "hash";
13771
14025
  const lastWrittenRef = react.useRef(undefined);
13772
14026
  const pendingWriteRef = react.useRef(null);
13773
14027
  const onExternalChangeRef = react.useRef(onExternalChange);
13774
14028
  react.useEffect(() => {
13775
14029
  onExternalChangeRef.current = onExternalChange;
13776
14030
  }, [onExternalChange]);
13777
- const currentValue = react.useMemo(() => {
14031
+ const currentSearchValue = react.useMemo(() => {
13778
14032
  if (!enabled) {
13779
14033
  return undefined;
13780
14034
  }
13781
- return isHash ? readHashKey(location.hash, key) : readPrimitiveSearchParam(search, key);
13782
- }, [enabled, isHash, location.hash, search, key]);
14035
+ return readPrimitiveSearchParam(search, key);
14036
+ }, [enabled, search, key]);
13783
14037
  react.useEffect(() => {
13784
- if (!enabled || currentValue === undefined) {
14038
+ if (!enabled) {
13785
14039
  return;
13786
14040
  }
13787
- if (currentValue === lastWrittenRef.current || lastWrittenRef.current === undefined) {
14041
+ if (currentSearchValue === undefined) {
13788
14042
  return;
13789
14043
  }
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.
14044
+ if (currentSearchValue === lastWrittenRef.current) {
14045
+ return;
14046
+ }
14047
+ if (lastWrittenRef.current === undefined) {
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.
13794
14054
  if (pendingWriteRef.current !== null) {
13795
14055
  return;
13796
14056
  }
13797
14057
  requestAnimationFrame(() => {
13798
14058
  onExternalChangeRef.current?.();
13799
14059
  });
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;
14060
+ }, [currentSearchValue, enabled]);
14061
+ const getUrlLengthWithSearchParam = react.useCallback((paramKey, paramValue, params) => {
13812
14062
  const otherParamsLength = sharedUtils.objectKeys(params)
13813
- .filter(k => k !== key)
14063
+ .filter(k => k !== paramKey)
13814
14064
  .reduce((totalLength, k) => {
13815
14065
  const kLen = encodeURIComponent(String(k)).length;
13816
14066
  const v = params[k];
13817
14067
  const vLen = v !== null && v !== undefined ? encodeURIComponent(String(v)).length : 0;
13818
14068
  return totalLength + kLen + vLen + (totalLength > 0 ? 2 : 1);
13819
14069
  }, 0);
13820
- const urlBase = href.length - (searchStr.length || 0) + (hash.length || 0);
13821
- return urlBase + otherParamsLength + 1 + key.length + 1 + paramValue.length;
13822
- }, [key]);
14070
+ const urlBase = location.href.length - (location.searchStr.length || 0) + (location.hash.length || 0);
14071
+ return urlBase + otherParamsLength + 1 + paramKey.length + 1 + paramValue.length;
14072
+ }, [location]);
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.
13823
14080
  const flushPendingWrite = react.useCallback(() => {
13824
14081
  const pending = pendingWriteRef.current;
13825
14082
  if (pending === null) {
13826
14083
  return;
13827
14084
  }
13828
14085
  if (router.state.status === "pending") {
13829
- // Keep the write queued; the status-watcher effect retries once idle.
14086
+ // Keep the write queued; the status-watcher effect below retries on
14087
+ // the next idle transition.
13830
14088
  return;
13831
14089
  }
13832
14090
  pendingWriteRef.current = null;
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
- }
14091
+ const { encodedValue, options } = pending;
14092
+ const shouldReplace = options?.replace ?? replaceOption ?? !Boolean(currentSearchValue);
13857
14093
  void navigate({
13858
14094
  to: ".",
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,
14095
+ search: (prev) => {
14096
+ if (getUrlLengthWithSearchParam(key, encodedValue || "", prev) <= MAX_URL_LENGTH) {
14097
+ return { ...prev, [key]: encodedValue };
14098
+ }
14099
+ else {
14100
+ return { ...prev, [key]: undefined };
14101
+ }
14102
+ },
14103
+ hash: location.hash,
14104
+ replace: shouldReplace,
13865
14105
  });
13866
- }, [router.state.status, replaceOption, isHash, navigate, getUrlLengthWithSearchParam, key]);
14106
+ }, [
14107
+ router.state.status,
14108
+ location.hash,
14109
+ key,
14110
+ currentSearchValue,
14111
+ navigate,
14112
+ replaceOption,
14113
+ getUrlLengthWithSearchParam,
14114
+ ]);
13867
14115
  react.useEffect(() => {
13868
- if (router.state.status === "pending" || pendingWriteRef.current === null) {
14116
+ if (router.state.status === "pending") {
14117
+ return;
14118
+ }
14119
+ if (pendingWriteRef.current === null) {
13869
14120
  return;
13870
14121
  }
13871
14122
  flushPendingWrite();
13872
14123
  }, [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.
13873
14132
  react.useEffect(() => () => {
13874
14133
  pendingWriteRef.current = null;
13875
14134
  }, []);
13876
- const updateSearchParam = react.useCallback((encodedValue) => {
14135
+ const updateSearchParam = react.useCallback((encodedValue, options) => {
13877
14136
  if (!enabled) {
13878
14137
  return;
13879
14138
  }
13880
- // Dedupe against the last committed write or the value already queued;
13881
- // both represent the caller's intent.
14139
+ // Dedupe against either the last committed write or the value
14140
+ // currently queued for the next rAF — both represent the caller's
14141
+ // intent so we don't schedule redundant rAF callbacks for identical
14142
+ // requests.
13882
14143
  const inFlightOrLastWritten = pendingWriteRef.current?.encodedValue ?? lastWrittenRef.current;
13883
14144
  if (encodedValue !== undefined && encodedValue === inFlightOrLastWritten) {
13884
14145
  return;
13885
14146
  }
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.
14147
+ // Prime `lastWrittenRef` here (not inside `flushPendingWrite`) so
14148
+ // the external-change detector treats this value as the caller's
14149
+ // authoritative state even when the flush is a no-op (URL already
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.
13889
14154
  lastWrittenRef.current = encodedValue;
13890
- if (currentValueRef.current === encodedValue) {
14155
+ if (currentSearchValue === encodedValue) {
13891
14156
  return;
13892
14157
  }
13893
- pendingWriteRef.current = { encodedValue };
14158
+ pendingWriteRef.current = { encodedValue, options };
13894
14159
  requestAnimationFrame(() => {
13895
14160
  flushPendingWrite();
13896
14161
  });
13897
- }, [enabled, flushPendingWrite]);
13898
- return react.useMemo(() => ({ searchValue: currentValue, updateSearchParam }), [currentValue, updateSearchParam]);
14162
+ }, [enabled, currentSearchValue, flushPendingWrite]);
14163
+ return react.useMemo(() => ({ searchValue: currentSearchValue, updateSearchParam }), [currentSearchValue, updateSearchParam]);
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]);
13899
14178
  };
13900
14179
 
13901
14180
  /**
@@ -13917,39 +14196,47 @@ const rawLocalStorage = {
13917
14196
  * and writes changes to both.
13918
14197
  *
13919
14198
  * On mount the hook resolves the initial state as follows:
13920
- * - If the URL slot holds a value, decode it and pass through `validate`.
14199
+ * - If the canonical URL slot (search OR hash, per `urlLocation`) holds a
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.
13921
14209
  * - If localStorage is enabled, read and validate its value.
13922
14210
  * - When `mergeWithStorageOnRead` is `false` (default): URL state wins
13923
14211
  * entirely when present, otherwise fall back to localStorage.
13924
14212
  * - When `mergeWithStorageOnRead` is `true`: shallow-merge URL fields over
13925
14213
  * localStorage so fields stripped by `toUrlValue` survive via storage.
13926
14214
  *
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 deep-equal to its last write. The URL side starts
13935
- * from the hydrated state, so echoing `initialState` writes no URL entry. The
13936
- * storage side starts from what localStorage held, so a consumer that persists
13937
- * `initialState` after opening a link saves that link's state for the account
13938
- * that opened it, and a reload of the account's own link writes nothing.
13939
- * `persistToStorage` and `persistToUrl` are the same two halves exposed
13940
- * separately for consumers that schedule them differently; passing `undefined`
13941
- * removes the entry rather than writing an empty value.
13942
- *
13943
- * The snapshot is re-read when the storage key or the storage/merge options
13944
- * change, in the same render, so a write in that commit already sees the
13945
- * state for the new key.
14215
+ * `persistState` writes the state to localStorage (via `serialize`, default
14216
+ * `storageSerializer.serialize`) and syncs to the canonical URL slot (via
14217
+ * `toUrlValue` + encoding). Duplicate writes where the state has not
14218
+ * changed (deep equality) are skipped.
14219
+ *
14220
+ * `commitHydratedState` writes the hydrated value to localStorage only,
14221
+ * when it came from the URL and differs from what this identity has stored.
14222
+ * It does not update the URL. It returns `false` while the snapshot is
14223
+ * still for a previous read, so the caller can retry after identity
14224
+ * resolution. The caller decides which identity is allowed to commit.
14225
+ *
14226
+ * `clearState` removes the entry from localStorage and from the URL slot,
14227
+ * rather than writing an empty value. Use it when the state a consumer
14228
+ * derives has become empty, so no leftover entry is stored for a value the
14229
+ * user has reset. It is idempotent — clearing an absent entry is a no-op.
13946
14230
  *
13947
14231
  * @param options - Configuration for the persisted state.
13948
14232
  * @param options.key - Unique identifier used for both the URL slot and the localStorage key.
13949
14233
  * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
14234
+ * @param options.serialize - Custom localStorage serialiser (default `storageSerializer.serialize`).
13950
14235
  * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
13951
14236
  * @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`).
13952
14238
  * @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).
13953
14240
  * @param options.replace - Forwarded to the underlying URL sync hook.
13954
14241
  * @param options.clientSideUserId - The user ID to use for the localStorage key.
13955
14242
  * @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
@@ -13959,153 +14246,344 @@ const rawLocalStorage = {
13959
14246
  * @param options.stampUrlWithIdentity - Stamp URL writes with the current identity so the login
13960
14247
  * redirect can drop entries left by another account on this browser. Default `false`.
13961
14248
  */
13962
- const usePersistedState = ({ key, validate, toUrlValue, fromUrlValue, localStorageEnabled = true, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", stampUrlWithIdentity = false, }) => {
14249
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", stampUrlWithIdentity = false, }) => {
13963
14250
  const { encode, decode } = useCustomEncoding();
13964
14251
  // Fingerprint of the identity this hook writes URL values for. `undefined`
13965
14252
  // while the identity is unresolved (or when stamping is off): writes are
13966
14253
  // then unstamped.
13967
14254
  const currentUrlOwner = react.useMemo(() => (stampUrlWithIdentity && clientSideUserId !== undefined ? identityFingerprint(clientSideUserId) : undefined), [stampUrlWithIdentity, clientSideUserId]);
13968
- const { searchValue: urlValue, updateSearchParam: updateUrlValue } = useUrlParamSync({
14255
+ const searchActive = enabled && urlLocation === "search";
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({
13969
14267
  key,
13970
- location: urlLocation,
14268
+ enabled: hashActive,
14269
+ onExternalChange,
13971
14270
  replace,
13972
14271
  });
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]);
13973
14284
  const storageKey = useStorageKey(key, clientSideUserId);
13974
- // `toUrlValue` is read through a ref so the write callbacks stay stable
13975
- // when a consumer passes an inline function.
13976
14285
  const toUrlValueRef = react.useRef(toUrlValue);
13977
14286
  react.useEffect(() => {
13978
14287
  toUrlValueRef.current = toUrlValue;
13979
14288
  }, [toUrlValue]);
13980
- const readStoredState = react.useCallback((validateState) => {
13981
- if (!localStorageEnabled) {
13982
- return undefined;
13983
- }
13984
- try {
13985
- const raw = rawLocalStorage.read(storageKey);
13986
- if (!raw) {
14289
+ const serializeRef = react.useRef(serialize);
14290
+ react.useEffect(() => {
14291
+ serializeRef.current = serialize;
14292
+ }, [serialize]);
14293
+ const validateRef = react.useRef(validate);
14294
+ react.useEffect(() => {
14295
+ validateRef.current = validate;
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) {
13987
14328
  return undefined;
13988
14329
  }
13989
- return validateState(sharedUtils.storageSerializer.deserialize(raw));
13990
- }
13991
- catch {
13992
- return undefined;
13993
- }
13994
- }, [localStorageEnabled, storageKey]);
13995
- const readPersistedState = react.useCallback(({ valueFromUrl, validateState, transformFromUrlValue }) => {
13996
- let urlState;
13997
- if (valueFromUrl) {
13998
14330
  try {
13999
- // The ownership stamp is only read at the login redirect (see
14000
- // `useStripForeignIdentityHash`); here it is unwrapped and discarded.
14001
- const { value } = unwrapUrlOwner(decode(valueFromUrl));
14002
- urlState = validateState(transformFromUrlValue ? transformFromUrlValue(value) : value);
14331
+ const raw = rawLocalStorage.read(storageKey);
14332
+ if (!raw) {
14333
+ return undefined;
14334
+ }
14335
+ return validateState(sharedUtils.storageSerializer.deserialize(raw));
14003
14336
  }
14004
14337
  catch {
14005
- urlState = undefined;
14338
+ return undefined;
14339
+ }
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;
14006
14351
  }
14007
14352
  }
14008
- // Storage is read even when the URL wins outright: it seeds the storage
14009
- // dedupe, so persisting the hydrated state writes only when the URL
14010
- // changed something.
14011
- const stored = readStoredState(validateState);
14012
- let hydrated;
14013
- if (urlState === undefined) {
14014
- hydrated = stored;
14353
+ if (urlState === undefined && enabled && legacyValueFromUrl) {
14354
+ const decoded = tryDecode(legacyValueFromUrl);
14355
+ if (decoded !== undefined) {
14356
+ urlState = decoded.state;
14357
+ usedLegacyUrl = true;
14358
+ }
14015
14359
  }
14016
- else if (mergeWithStorageOnRead && stored !== undefined) {
14017
- // URL fields take precedence; storage fills in whatever the URL
14018
- // omits (e.g. fields stripped by `toUrlValue`).
14019
- hydrated = { ...stored, ...urlState };
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
+ };
14020
14369
  }
14021
- else {
14022
- hydrated = urlState;
14370
+ const storageState = readStoredState();
14371
+ if (urlState !== undefined && storageState !== undefined) {
14372
+ // Shallow merge — URL fields take precedence; storage fills in
14373
+ // whatever the URL omits (e.g. fields stripped by `toUrlValue`).
14374
+ return {
14375
+ initialState: { ...storageState, ...urlState },
14376
+ canonicalUrlHasValidState: canonicalUrlValid,
14377
+ canonicalUrlPayload: decodedPayload,
14378
+ needsUrlRestore: usedLegacyUrl,
14379
+ };
14380
+ }
14381
+ if (urlState !== undefined) {
14382
+ return {
14383
+ initialState: urlState,
14384
+ canonicalUrlHasValidState: canonicalUrlValid,
14385
+ canonicalUrlPayload: decodedPayload,
14386
+ needsUrlRestore: usedLegacyUrl,
14387
+ };
14388
+ }
14389
+ if (storageState !== undefined) {
14390
+ return {
14391
+ initialState: storageState,
14392
+ canonicalUrlHasValidState: false,
14393
+ canonicalUrlPayload: undefined,
14394
+ // Leave the URL alone. A storage-only load must not grow a hash,
14395
+ // or the next history/bookmark/login of this page replays it.
14396
+ needsUrlRestore: false,
14397
+ };
14023
14398
  }
14024
- return { initialState: hydrated, storedState: stored };
14025
- }, [decode, mergeWithStorageOnRead, readStoredState]);
14026
- // Every URL write goes through here so the ownership stamp is applied uniformly.
14399
+ return {
14400
+ initialState: undefined,
14401
+ canonicalUrlHasValidState: false,
14402
+ canonicalUrlPayload: undefined,
14403
+ needsUrlRestore: false,
14404
+ };
14405
+ }, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
14406
+ // Every URL write goes through here so the ownership stamp is applied
14407
+ // uniformly (user edits and legacy search-param migration).
14027
14408
  const encodeForUrl = react.useCallback((state) => {
14028
- const value = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
14029
- return encode(currentUrlOwner === undefined ? value : wrapUrlOwner(value, currentUrlOwner));
14409
+ const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
14410
+ return encode(currentUrlOwner === undefined ? urlValue : wrapUrlOwner(urlValue, currentUrlOwner));
14030
14411
  }, [encode, currentUrlOwner]);
14031
- // `readPersistedState` changes identity exactly when an input that changes
14032
- // what a read returns does (storage key, storage/merge options), so the
14033
- // snapshot is re-read then and only then — synchronously, in the render
14034
- // where the input changed, by deriving state from the changed input. A URL
14035
- // change after mount or an inline `validate` deliberately does not
14036
- // re-hydrate `initialState`: `useUrlParamSync` owns URL changes.
14037
- const readSnapshot = () => ({
14038
- readWith: readPersistedState,
14039
- ...readPersistedState({ valueFromUrl: urlValue, validateState: validate, transformFromUrlValue: fromUrlValue }),
14040
- });
14041
- const [snapshot, setSnapshot] = react.useState(readSnapshot);
14042
- if (snapshot.readWith !== readPersistedState) {
14043
- setSnapshot(readSnapshot());
14044
- }
14045
- const { initialState, storedState } = snapshot;
14046
- // Last value written to each slot, for deduping. The URL side starts as the
14047
- // hydrated value, so a consumer that echoes `initialState` on mount writes
14048
- // nothing: a layout read from storage must not grow a URL entry (GLU-2396).
14049
- // The storage side starts as what storage held, so persisting the hydrated
14050
- // state after opening a link writes only when the URL changed something.
14051
- // Both are re-seeded whenever the snapshot is re-read so the first write to
14052
- // a newly scoped key is never skipped. Two refs, not one, because a consumer
14053
- // may schedule the two writes differently (storage at once, URL debounced).
14054
- const lastStoredRef = react.useRef(storedState);
14055
- const lastUrlRef = react.useRef(initialState);
14412
+ const persistedStateReadKey = `${enabled}:${localStorageEnabled}:${mergeWithStorageOnRead}:${urlLocation}:${storageKey}`;
14413
+ const [persistedStateSnapshot, setPersistedStateSnapshot] = react.useState(() => ({
14414
+ ...readPersistedState({
14415
+ valueFromUrl: searchValue,
14416
+ legacyValueFromUrl: legacySearchValue,
14417
+ validateState: validate,
14418
+ transformFromUrlValue: fromUrlValue,
14419
+ }),
14420
+ readKey: persistedStateReadKey,
14421
+ }));
14422
+ const persistedStateReadKeyRef = react.useRef(persistedStateReadKey);
14056
14423
  react.useEffect(() => {
14057
- lastStoredRef.current = storedState;
14058
- lastUrlRef.current = initialState;
14059
- }, [initialState, storedState]);
14060
- const persistToStorage = react.useCallback((state) => {
14061
- if (!localStorageEnabled) {
14424
+ if (persistedStateReadKeyRef.current === persistedStateReadKey) {
14062
14425
  return;
14063
14426
  }
14064
- if (state === undefined) {
14065
- lastStoredRef.current = undefined;
14066
- rawLocalStorage.remove(storageKey);
14427
+ persistedStateReadKeyRef.current = persistedStateReadKey;
14428
+ setPersistedStateSnapshot({
14429
+ ...readPersistedState({
14430
+ valueFromUrl: searchValueRef.current,
14431
+ legacyValueFromUrl: legacySearchValueRef.current,
14432
+ validateState: validateRef.current,
14433
+ transformFromUrlValue: fromUrlValueRef.current,
14434
+ }),
14435
+ readKey: persistedStateReadKey,
14436
+ });
14437
+ }, [persistedStateReadKey, readPersistedState]);
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]);
14477
+ react.useEffect(() => {
14478
+ if (hasRestoredUrlRef.current) {
14479
+ return;
14480
+ }
14481
+ // Only a legacy search param is copied into the hash. Storage is not:
14482
+ // mirroring it on load is what put a layout into every history entry
14483
+ // (GLU-2396). Gate on `canonicalUrlHasValidState`, not on the raw
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) {
14067
14488
  return;
14068
14489
  }
14069
- if (dequal.dequal(lastStoredRef.current, state)) {
14490
+ hasRestoredUrlRef.current = true;
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)) {
14070
14523
  return;
14071
14524
  }
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;
14072
14555
  try {
14073
- rawLocalStorage.write(storageKey, sharedUtils.storageSerializer.serialize(state));
14074
- lastStoredRef.current = state;
14556
+ const raw = rawLocalStorage.read(storageKey);
14557
+ if (raw) {
14558
+ stored = validateRef.current(sharedUtils.storageSerializer.deserialize(raw));
14559
+ }
14075
14560
  }
14076
14561
  catch {
14077
- // Quota or a disabled store: the next write retries; the URL is unaffected.
14562
+ stored = undefined;
14078
14563
  }
14079
- }, [localStorageEnabled, storageKey]);
14080
- const persistToUrl = react.useCallback((state) => {
14081
- if (state !== undefined && dequal.dequal(lastUrlRef.current, state)) {
14082
- return;
14564
+ if (stored !== undefined) {
14565
+ const projected = toUrlValueRef.current ? toUrlValueRef.current(stored) : stored;
14566
+ if (dequal.dequal(projected, canonicalUrlPayload)) {
14567
+ return true;
14568
+ }
14083
14569
  }
14084
- lastUrlRef.current = state;
14085
- updateUrlValue(state === undefined ? undefined : encodeForUrl(state));
14086
- }, [encodeForUrl, updateUrlValue]);
14087
- const persistState = react.useCallback((state) => {
14088
- persistToStorage(state);
14089
- persistToUrl(state);
14090
- }, [persistToStorage, persistToUrl]);
14091
- return react.useMemo(() => ({ initialState, persistState, persistToStorage, persistToUrl }), [initialState, persistState, persistToStorage, persistToUrl]);
14570
+ const serialized = serializeRef.current
14571
+ ? serializeRef.current(initialState)
14572
+ : sharedUtils.storageSerializer.serialize(initialState);
14573
+ rawLocalStorage.write(storageKey, serialized);
14574
+ lastPersistedRef.current = initialState;
14575
+ return true;
14576
+ }, [
14577
+ canonicalUrlHasValidState,
14578
+ canonicalUrlPayload,
14579
+ initialState,
14580
+ localStorageEnabled,
14581
+ snapshotIsCurrent,
14582
+ storageKey,
14583
+ ]);
14584
+ return react.useMemo(() => ({ initialState, persistState, clearState, commitHydratedState }), [initialState, persistState, clearState, commitHydratedState]);
14092
14585
  };
14093
14586
 
14094
- /**
14095
- * Syncs an encoded string value with a URL search parameter via Tanstack Router.
14096
- *
14097
- * Thin wrapper over {@link useUrlParamSync} with `location: "search"`; see it
14098
- * for the write queue, the size guard (`MAX_URL_LENGTH`), and external-change
14099
- * detection.
14100
- *
14101
- * @param options - Configuration for the search param sync.
14102
- * @param options.key - The URL search parameter name.
14103
- * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
14104
- * @param options.onExternalChange - Called when the param changes externally.
14105
- * @param options.replace - `true` to always use `replaceState`.
14106
- */
14107
- const useSearchParamSync = (options) => useUrlParamSync({ ...options, location: "search" });
14108
-
14109
14587
  /**
14110
14588
  * Enumerates localStorage keys. Kept local so this file's exemption from the
14111
14589
  * no-direct-storage-access rule is centralised, as in `usePersistedState`.
@@ -15469,6 +15947,7 @@ exports.LabeledValue = LabeledValue;
15469
15947
  exports.LabeledValueList = LabeledValueList;
15470
15948
  exports.List = List;
15471
15949
  exports.ListItem = ListItem;
15950
+ exports.MAX_HASH_LENGTH = MAX_HASH_LENGTH;
15472
15951
  exports.MAX_URL_LENGTH = MAX_URL_LENGTH;
15473
15952
  exports.MenuContent = MenuContent;
15474
15953
  exports.MenuDivider = MenuDivider;
@@ -15585,6 +16064,7 @@ exports.useDevicePixelRatio = useDevicePixelRatio;
15585
16064
  exports.useElevatedReducer = useElevatedReducer;
15586
16065
  exports.useElevatedState = useElevatedState;
15587
16066
  exports.useGridAreas = useGridAreas;
16067
+ exports.useHashParamSync = useHashParamSync;
15588
16068
  exports.useHold = useHold;
15589
16069
  exports.useHover = useHover;
15590
16070
  exports.useInfiniteScroll = useInfiniteScroll;