@trackunit/react-components 3.8.4-alpha-7afddbbe4cc.0 → 3.8.4

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