@trackunit/react-components 3.8.4 → 3.8.5-alpha-4acfba00274.0

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