@trackunit/react-components 1.25.4 → 1.26.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
@@ -11241,6 +11241,400 @@ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage,
11241
11241
  */
11242
11242
  const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
11243
11243
 
11244
+ /**
11245
+ * Pure utilities for reading and writing single named entries inside a URL
11246
+ * fragment (the part after `#`).
11247
+ *
11248
+ * # Format contract
11249
+ *
11250
+ * The fragment body (after the leading `#`) is treated as a sequence of
11251
+ * `&`-separated tokens. A token may be:
11252
+ * - **A named entry** — `<key>=<value>` (the first `=` separates key from
11253
+ * value; later `=` chars are part of the value).
11254
+ * - **Foreign content** — anything else (bare scroll anchors like
11255
+ * `section`, hash routes, third-party data, malformed tokens). Foreign
11256
+ * tokens are preserved byte-for-byte, in their original positions.
11257
+ *
11258
+ * # Single-key ownership
11259
+ *
11260
+ * Callers operate on **one** named key per invocation. Every token whose
11261
+ * key does not match the supplied key is foreign — the utility never
11262
+ * categorises or rewrites it. This mirrors how `useSearchParamSync`
11263
+ * treats a single named search param.
11264
+ *
11265
+ * # Value-encoding invariant
11266
+ *
11267
+ * Values passed to {@link writeHashKey} must not contain `&`, `=`, or `#`.
11268
+ * The standard producer for this codebase is `useCustomEncoding`, whose
11269
+ * base64url alphabet (`[A-Za-z0-9_-]`) satisfies the invariant by
11270
+ * construction. Keys are restricted by the existing persistence-key
11271
+ * schema (camelCase, ≤15 chars), so they are likewise safe.
11272
+ *
11273
+ * # Anchor-scroll caveat
11274
+ *
11275
+ * Browsers locate scroll targets by matching the entire fragment string
11276
+ * against element IDs. Appending a named entry to a fragment containing
11277
+ * a bare anchor (e.g. `#section` becoming `#section&userTableTp=...`)
11278
+ * therefore breaks anchor scrolling on that page. This is unavoidable
11279
+ * for any hash-backed shared state. Consumers that rely on scroll
11280
+ * anchors should not also persist state to the hash on the same route.
11281
+ */
11282
+ const TOKEN_SEPARATOR = "&";
11283
+ const KEY_VALUE_SEPARATOR = "=";
11284
+ const stripLeadingHash = (hash) => (hash.startsWith("#") ? hash.slice(1) : hash);
11285
+ const formatHash = (body) => (body.length === 0 ? "" : `#${body}`);
11286
+ /**
11287
+ * Reads the value of a single named entry from a URL fragment.
11288
+ *
11289
+ * @param hash - The fragment string, with or without the leading `#`.
11290
+ * @param key - The entry key to look up.
11291
+ * @returns {string | undefined} The raw value (everything after the first
11292
+ * `=` in the matching token), or `undefined` when the key is absent. An
11293
+ * entry whose token has no `=` is treated as foreign content and ignored.
11294
+ */
11295
+ const readHashKey = (hash, key) => {
11296
+ const body = stripLeadingHash(hash);
11297
+ if (body.length === 0) {
11298
+ return undefined;
11299
+ }
11300
+ for (const token of body.split(TOKEN_SEPARATOR)) {
11301
+ const eqIndex = token.indexOf(KEY_VALUE_SEPARATOR);
11302
+ if (eqIndex > 0 && token.slice(0, eqIndex) === key) {
11303
+ return token.slice(eqIndex + 1);
11304
+ }
11305
+ }
11306
+ return undefined;
11307
+ };
11308
+ /**
11309
+ * Returns a new fragment string with the named entry written, replaced,
11310
+ * or removed. Foreign tokens are preserved in their original order; the
11311
+ * named entry retains its position if it already exists, and is appended
11312
+ * to the end when newly inserted. Empty tokens (e.g. produced by
11313
+ * consecutive `&`) are dropped on the way out.
11314
+ *
11315
+ * @param hash - The current fragment string, with or without the leading `#`.
11316
+ * @param key - The entry key to write.
11317
+ * @param value - The value to set, or `undefined` to remove the entry.
11318
+ * @returns {string} The new fragment string, including a leading `#` when
11319
+ * non-empty, or an empty string when the result has no tokens left.
11320
+ */
11321
+ const writeHashKey = (hash, key, value) => {
11322
+ const body = stripLeadingHash(hash);
11323
+ const tokens = body.length === 0 ? [] : body.split(TOKEN_SEPARATOR);
11324
+ const next = [];
11325
+ let replaced = false;
11326
+ for (const token of tokens) {
11327
+ if (token.length === 0) {
11328
+ continue;
11329
+ }
11330
+ const eqIndex = token.indexOf(KEY_VALUE_SEPARATOR);
11331
+ if (eqIndex > 0 && token.slice(0, eqIndex) === key) {
11332
+ if (value !== undefined && !replaced) {
11333
+ next.push(`${key}${KEY_VALUE_SEPARATOR}${value}`);
11334
+ replaced = true;
11335
+ }
11336
+ // Else: drop the token (either removing the key or dropping a
11337
+ // duplicate occurrence after we already wrote the replacement).
11338
+ continue;
11339
+ }
11340
+ next.push(token);
11341
+ }
11342
+ if (value !== undefined && !replaced) {
11343
+ next.push(`${key}${KEY_VALUE_SEPARATOR}${value}`);
11344
+ }
11345
+ return formatHash(next.join(TOKEN_SEPARATOR));
11346
+ };
11347
+
11348
+ /**
11349
+ * Default maximum length of the resulting URL fragment after adding one
11350
+ * persisted entry. Writes are skipped (localStorage continues to hold the
11351
+ * full state) when adding the entry would push the fragment past this cap.
11352
+ *
11353
+ * The fragment is never sent to the server, so it is not subject to the
11354
+ * AWS WAF managed-rule `SizeRestrictions_QUERYSTRING` cap that applies to
11355
+ * search params (5000 bytes). 64 KB stays well below typical browser URL
11356
+ * limits (Chromium ≳ 100 KB, Firefox ≳ 64 KB on the address bar) while
11357
+ * still leaving generous headroom for any realistic table state.
11358
+ */
11359
+ const MAX_HASH_LENGTH = 64 * 1024;
11360
+ /**
11361
+ * Syncs an encoded string value with a single named entry inside the URL
11362
+ * fragment (`location.hash`) via Tanstack Router.
11363
+ *
11364
+ * Behaviour mirrors `useSearchParamSync`, with two differences:
11365
+ * - Reads from and writes to `location.hash` instead of `location.search`.
11366
+ * - Writes are guarded against {@link MAX_HASH_LENGTH} rather than the
11367
+ * AWS-WAF-driven search-param cap.
11368
+ *
11369
+ * The fragment is treated as a sequence of `&`-separated tokens via the
11370
+ * pure utilities in `./hashFragment`. Foreign tokens (bare anchors, other
11371
+ * named entries) are preserved byte-for-byte on every write.
11372
+ *
11373
+ * @param options - Configuration for the hash entry sync.
11374
+ * @param options.key - The named entry inside the fragment.
11375
+ * @param options.enabled - Set to `false` to disable all URL interaction (default `true`).
11376
+ * @param options.onExternalChange - Called when the entry changes externally
11377
+ * (e.g. browser back/forward, pasted link, foreign router push).
11378
+ * @param options.replace - `true` to always use `replaceState` instead of pushState.
11379
+ */
11380
+ const useHashParamSync = ({ key, enabled = true, onExternalChange, replace: replaceOption, }) => {
11381
+ const navigate = reactRouter.useNavigate();
11382
+ const location = reactRouter.useLocation();
11383
+ const router = reactRouter.useRouter();
11384
+ const lastWrittenRef = react.useRef(undefined);
11385
+ const pendingWriteRef = react.useRef(null);
11386
+ const onExternalChangeRef = react.useRef(onExternalChange);
11387
+ react.useEffect(() => {
11388
+ onExternalChangeRef.current = onExternalChange;
11389
+ }, [onExternalChange]);
11390
+ const currentHashValue = react.useMemo(() => {
11391
+ if (!enabled) {
11392
+ return undefined;
11393
+ }
11394
+ return readHashKey(location.hash, key);
11395
+ }, [enabled, location.hash, key]);
11396
+ react.useEffect(() => {
11397
+ if (!enabled) {
11398
+ return;
11399
+ }
11400
+ if (currentHashValue === undefined) {
11401
+ return;
11402
+ }
11403
+ if (currentHashValue === lastWrittenRef.current) {
11404
+ return;
11405
+ }
11406
+ if (lastWrittenRef.current === undefined) {
11407
+ return;
11408
+ }
11409
+ requestAnimationFrame(() => {
11410
+ onExternalChangeRef.current?.();
11411
+ });
11412
+ }, [currentHashValue, enabled]);
11413
+ // Writes are dispatched via a "pending write" queue rather than calling
11414
+ // `navigate` directly from inside the rAF callback. Tanstack Router's
11415
+ // `router.state.status === "pending"` is common during the first paint
11416
+ // of a route (loaders, suspense), and an earlier implementation that
11417
+ // simply returned in that case lost the write outright — the
11418
+ // URL-restoration path in `usePersistedState` is one-shot (guarded by a
11419
+ // ref), so when a localStorage-→-hash write coincided with a pending
11420
+ // router the canonical hash never got populated and users saw an
11421
+ // intermittent "hash not set on initial visit" bug.
11422
+ //
11423
+ // The queue + retry pattern below makes writes reliable:
11424
+ // - `updateHashParam` stores the latest attempted write in
11425
+ // `pendingWriteRef` and schedules an rAF flush.
11426
+ // - `flushPendingWrite` only navigates when the router is idle; while
11427
+ // pending it leaves the write queued so it can fire later.
11428
+ // - A dedicated effect watches `router.state.status` and re-attempts
11429
+ // the flush whenever the router settles, draining the queue.
11430
+ const flushPendingWrite = react.useCallback(() => {
11431
+ const pending = pendingWriteRef.current;
11432
+ if (pending === null) {
11433
+ return;
11434
+ }
11435
+ if (router.state.status === "pending") {
11436
+ // Keep the write queued; the status-watcher effect below will retry
11437
+ // when the router transitions back to idle.
11438
+ return;
11439
+ }
11440
+ pendingWriteRef.current = null;
11441
+ const { encodedValue, options } = pending;
11442
+ const nextHash = writeHashKey(location.hash, key, encodedValue);
11443
+ if (nextHash.length > MAX_HASH_LENGTH) {
11444
+ // Drop the write so the fragment never grows past the cap. The
11445
+ // localStorage write performed by `usePersistedState` still holds
11446
+ // the full state, so the layout survives a reload on the same
11447
+ // browser; only the shareable URL is degraded.
11448
+ return;
11449
+ }
11450
+ const shouldReplace = options?.replace ?? replaceOption ?? !Boolean(currentHashValue);
11451
+ // Tanstack Router's `navigate({ hash })` expects the fragment body
11452
+ // *without* the leading `#` — the router adds the delimiter itself
11453
+ // when serialising the URL. Forwarding the `#`-prefixed value from
11454
+ // `writeHashKey` produces a doubled `##` in the address bar
11455
+ // (visually broken, even though browsers still parse the fragment
11456
+ // correctly because URL fragments start at the *first* `#`).
11457
+ const navigateHash = nextHash.startsWith("#") ? nextHash.slice(1) : nextHash;
11458
+ void navigate({
11459
+ to: ".",
11460
+ // Returning the same `prev` reference leaves the search params
11461
+ // unchanged. We must still pass `search` so router types are happy.
11462
+ search: (prev) => prev,
11463
+ hash: navigateHash,
11464
+ replace: shouldReplace,
11465
+ });
11466
+ }, [router.state.status, location.hash, key, currentHashValue, navigate, replaceOption]);
11467
+ react.useEffect(() => {
11468
+ if (router.state.status === "pending") {
11469
+ return;
11470
+ }
11471
+ if (pendingWriteRef.current === null) {
11472
+ return;
11473
+ }
11474
+ flushPendingWrite();
11475
+ }, [router.state.status, flushPendingWrite]);
11476
+ // Drop any queued write when the consumer unmounts. The queue + retry
11477
+ // pattern above keeps writes pending across a `router.state.status ===
11478
+ // "pending"` phase, which means a write scheduled just before a route
11479
+ // change could otherwise drain onto the *next* route once it settles.
11480
+ // Cancelling here preserves the original "writes are scoped to the
11481
+ // owning route" contract that the pre-queue implementation got for
11482
+ // free by dropping the write outright.
11483
+ react.useEffect(() => () => {
11484
+ pendingWriteRef.current = null;
11485
+ }, []);
11486
+ const updateHashParam = react.useCallback((encodedValue, options) => {
11487
+ if (!enabled) {
11488
+ return;
11489
+ }
11490
+ // Dedupe against either the last *committed* write or the value
11491
+ // currently queued for the next rAF. Both represent the caller's
11492
+ // intent so we don't schedule redundant rAF callbacks for identical
11493
+ // requests.
11494
+ const inFlightOrLastWritten = pendingWriteRef.current?.encodedValue ?? lastWrittenRef.current;
11495
+ if (encodedValue !== undefined && encodedValue === inFlightOrLastWritten) {
11496
+ return;
11497
+ }
11498
+ // Prime `lastWrittenRef` here (not inside `flushPendingWrite`) so
11499
+ // the external-change detector treats this value as the caller's
11500
+ // authoritative state even when the flush is a no-op (URL already
11501
+ // matches) or stays queued during a pending router phase. Without
11502
+ // priming, the next "external" change would look identical to the
11503
+ // initial render (`lastWrittenRef === undefined`) and the callback
11504
+ // would be skipped.
11505
+ lastWrittenRef.current = encodedValue;
11506
+ if (currentHashValue === encodedValue) {
11507
+ return;
11508
+ }
11509
+ pendingWriteRef.current = { encodedValue, options };
11510
+ requestAnimationFrame(() => {
11511
+ flushPendingWrite();
11512
+ });
11513
+ }, [enabled, currentHashValue, flushPendingWrite]);
11514
+ return react.useMemo(() => ({ searchValue: currentHashValue, updateSearchParam: updateHashParam }), [currentHashValue, updateHashParam]);
11515
+ };
11516
+
11517
+ /**
11518
+ * One-shot migration: if the URL still carries a legacy `?<key>=...`
11519
+ * search param from before this hook moved table-style state to the
11520
+ * fragment, decode it, hand it to the caller via `onMigrated`, then strip
11521
+ * the search param via a `replace` navigation so back/forward history
11522
+ * isn't polluted.
11523
+ *
11524
+ * Invariants:
11525
+ * - The strip navigation fires at most once per mount (guarded by
11526
+ * `hasStrippedRef`) so we don't loop on the navigation we ourselves
11527
+ * triggered.
11528
+ * - `onMigrated` is also fired at most once per mount (guarded by
11529
+ * `hasMigratedRef`), but its gating is independent of the strip:
11530
+ * if `hashAlreadyHasValue` flips `true → false` after the strip
11531
+ * (e.g. an external actor clears the canonical hash mid-mount), the
11532
+ * next render still gets a chance to migrate the legacy value into
11533
+ * the hash. Conflating the two flags would have permanently lost
11534
+ * the legacy state in that race.
11535
+ * - Never throws — a decode/validate failure silently drops the value
11536
+ * and still strips the search param. The legacy key is owned by this
11537
+ * codebase's persistence convention (camelCase + `Tp` suffix), so
11538
+ * stripping unparseable values cannot destroy third-party data.
11539
+ * - When `hashAlreadyHasValue` is `true` the hash wins: the search
11540
+ * param is stripped but `onMigrated` is not fired.
11541
+ * - The `location.hash` is preserved verbatim across the strip
11542
+ * navigation so any concurrent hash content (including a freshly
11543
+ * written value) survives the cleanup.
11544
+ */
11545
+ const useMigrateLegacySearchParam = ({ key, enabled, decode, fromUrlValue, validate, hashAlreadyHasValue, onMigrated, }) => {
11546
+ const navigate = reactRouter.useNavigate();
11547
+ const location = reactRouter.useLocation();
11548
+ const search = reactRouter.useSearch({ strict: false, shouldThrow: false });
11549
+ // The three flags are tracked independently because they describe
11550
+ // distinct side effects with distinct lifetimes:
11551
+ //
11552
+ // - `hasStrippedRef` guards the cleanup navigation against looping
11553
+ // on the navigation we ourselves triggered.
11554
+ // - `hasMigratedRef` guards `onMigrated` against being called twice
11555
+ // with the same legacy value.
11556
+ // - `cachedRawValueRef` snapshots the legacy raw string the first
11557
+ // time we observe it. The strip navigation below removes the
11558
+ // value from `search` on the next render, so without a cache we
11559
+ // can never migrate it later. This matters in one specific edge
11560
+ // case: `hashAlreadyHasValue` is `true` on the first render (so
11561
+ // we skip `onMigrated` and just strip), then later flips to
11562
+ // `false` because another actor cleared the canonical hash. With
11563
+ // the cache the next render can still surface the legacy value.
11564
+ //
11565
+ // The previous implementation conflated all of this into a single
11566
+ // `hasRunRef`, which permanently lost the legacy state in that race.
11567
+ const hasStrippedRef = react.useRef(false);
11568
+ const hasMigratedRef = react.useRef(false);
11569
+ const cachedRawValueRef = react.useRef(undefined);
11570
+ const decodeRef = react.useRef(decode);
11571
+ const fromUrlValueRef = react.useRef(fromUrlValue);
11572
+ const validateRef = react.useRef(validate);
11573
+ const onMigratedRef = react.useRef(onMigrated);
11574
+ react.useEffect(() => {
11575
+ decodeRef.current = decode;
11576
+ fromUrlValueRef.current = fromUrlValue;
11577
+ validateRef.current = validate;
11578
+ onMigratedRef.current = onMigrated;
11579
+ }, [decode, fromUrlValue, validate, onMigrated]);
11580
+ react.useEffect(() => {
11581
+ if (!enabled) {
11582
+ return;
11583
+ }
11584
+ if (hasStrippedRef.current && hasMigratedRef.current) {
11585
+ return;
11586
+ }
11587
+ // Snapshot the raw value the first time we see it. After the strip
11588
+ // navigation below, `search?.[key]` is undefined, but a later
11589
+ // render that flips `hashAlreadyHasValue` back to `false` still
11590
+ // needs the original value to migrate.
11591
+ if (cachedRawValueRef.current === undefined) {
11592
+ const rawValue = search?.[key];
11593
+ if (typeof rawValue === "string" || typeof rawValue === "number" || typeof rawValue === "boolean") {
11594
+ cachedRawValueRef.current = String(rawValue);
11595
+ }
11596
+ }
11597
+ const rawString = cachedRawValueRef.current;
11598
+ if (rawString === undefined) {
11599
+ // Nothing legacy to migrate yet. Don't flip either flag — the
11600
+ // param may appear on a later render (e.g. router still hydrating).
11601
+ return;
11602
+ }
11603
+ if (!hasMigratedRef.current && !hashAlreadyHasValue) {
11604
+ let migratedState;
11605
+ try {
11606
+ const decoded = decodeRef.current(rawString);
11607
+ const transformed = fromUrlValueRef.current ? fromUrlValueRef.current(decoded) : decoded;
11608
+ migratedState = validateRef.current(transformed);
11609
+ }
11610
+ catch {
11611
+ migratedState = undefined;
11612
+ }
11613
+ // Invalid legacy data is silently dropped: the search param is
11614
+ // still stripped below, so the URL stays clean. Surfacing a
11615
+ // console warning here would require disabling a lint rule, and
11616
+ // the data loss is bounded — localStorage holds the working state
11617
+ // for the current session, and the user's next interaction
11618
+ // repopulates the hash. We still flip the flag in the invalid
11619
+ // case so we don't burn cycles re-decoding the same bad string
11620
+ // on every render.
11621
+ hasMigratedRef.current = true;
11622
+ if (migratedState !== undefined) {
11623
+ onMigratedRef.current(migratedState);
11624
+ }
11625
+ }
11626
+ if (!hasStrippedRef.current) {
11627
+ hasStrippedRef.current = true;
11628
+ void navigate({
11629
+ to: ".",
11630
+ search: (prev) => ({ ...prev, [key]: undefined }),
11631
+ hash: location.hash,
11632
+ replace: true,
11633
+ });
11634
+ }
11635
+ }, [enabled, key, hashAlreadyHasValue, search, navigate, location.hash]);
11636
+ };
11637
+
11244
11638
  /**
11245
11639
  * Default maximum full URL length after adding one persisted search param
11246
11640
  * before the URL write is skipped (localStorage still persists the full state).
@@ -11269,6 +11663,7 @@ const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: re
11269
11663
  const router = reactRouter.useRouter();
11270
11664
  const search = reactRouter.useSearch({ strict: false, shouldThrow: false });
11271
11665
  const lastWrittenRef = react.useRef(undefined);
11666
+ const pendingWriteRef = react.useRef(null);
11272
11667
  const onExternalChangeRef = react.useRef(onExternalChange);
11273
11668
  react.useEffect(() => {
11274
11669
  onExternalChangeRef.current = onExternalChange;
@@ -11312,46 +11707,96 @@ const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: re
11312
11707
  const urlBase = location.href.length - (location.searchStr.length || 0) + (location.hash.length || 0);
11313
11708
  return urlBase + otherParamsLength + 1 + paramKey.length + 1 + paramValue.length;
11314
11709
  }, [location]);
11710
+ // Writes are dispatched via a "pending write" queue rather than calling
11711
+ // `navigate` directly from inside the rAF callback. See the matching
11712
+ // comment block in `useHashParamSync` for the full rationale — in short,
11713
+ // navigating while `router.state.status === "pending"` was previously
11714
+ // dropped silently, which lost the URL-restoration write performed by
11715
+ // `usePersistedState` on routes that mount during a loader-driven
11716
+ // pending phase.
11717
+ const flushPendingWrite = react.useCallback(() => {
11718
+ const pending = pendingWriteRef.current;
11719
+ if (pending === null) {
11720
+ return;
11721
+ }
11722
+ if (router.state.status === "pending") {
11723
+ // Keep the write queued; the status-watcher effect below retries on
11724
+ // the next idle transition.
11725
+ return;
11726
+ }
11727
+ pendingWriteRef.current = null;
11728
+ const { encodedValue, options } = pending;
11729
+ const shouldReplace = options?.replace ?? replaceOption ?? !Boolean(currentSearchValue);
11730
+ void navigate({
11731
+ to: ".",
11732
+ search: (prev) => {
11733
+ if (getUrlLengthWithSearchParam(key, encodedValue || "", prev) <= MAX_URL_LENGTH) {
11734
+ return { ...prev, [key]: encodedValue };
11735
+ }
11736
+ else {
11737
+ return { ...prev, [key]: undefined };
11738
+ }
11739
+ },
11740
+ hash: location.hash,
11741
+ replace: shouldReplace,
11742
+ });
11743
+ }, [
11744
+ router.state.status,
11745
+ location.hash,
11746
+ key,
11747
+ currentSearchValue,
11748
+ navigate,
11749
+ replaceOption,
11750
+ getUrlLengthWithSearchParam,
11751
+ ]);
11752
+ react.useEffect(() => {
11753
+ if (router.state.status === "pending") {
11754
+ return;
11755
+ }
11756
+ if (pendingWriteRef.current === null) {
11757
+ return;
11758
+ }
11759
+ flushPendingWrite();
11760
+ }, [router.state.status, flushPendingWrite]);
11761
+ // Drop any queued write when the consumer unmounts. The queue + retry
11762
+ // pattern above keeps writes pending across a `router.state.status ===
11763
+ // "pending"` phase, which means a write scheduled just before a route
11764
+ // change could otherwise drain onto the *next* route once it settles
11765
+ // (e.g. a debounced filter write leaking into the destination route).
11766
+ // Cancelling here preserves the original "writes are scoped to the
11767
+ // owning route" contract that the pre-queue implementation got for
11768
+ // free by dropping the write outright.
11769
+ react.useEffect(() => () => {
11770
+ pendingWriteRef.current = null;
11771
+ }, []);
11315
11772
  const updateSearchParam = react.useCallback((encodedValue, options) => {
11316
11773
  if (!enabled) {
11317
11774
  return;
11318
11775
  }
11319
- if (encodedValue !== undefined && encodedValue === lastWrittenRef.current) {
11776
+ // Dedupe against either the last committed write or the value
11777
+ // currently queued for the next rAF — both represent the caller's
11778
+ // intent so we don't schedule redundant rAF callbacks for identical
11779
+ // requests.
11780
+ const inFlightOrLastWritten = pendingWriteRef.current?.encodedValue ?? lastWrittenRef.current;
11781
+ if (encodedValue !== undefined && encodedValue === inFlightOrLastWritten) {
11320
11782
  return;
11321
11783
  }
11784
+ // Prime `lastWrittenRef` here (not inside `flushPendingWrite`) so
11785
+ // the external-change detector treats this value as the caller's
11786
+ // authoritative state even when the flush is a no-op (URL already
11787
+ // matches) or stays queued during a pending router phase. Without
11788
+ // priming, the next "external" change would look identical to the
11789
+ // initial render (`lastWrittenRef === undefined`) and the callback
11790
+ // would be skipped.
11322
11791
  lastWrittenRef.current = encodedValue;
11323
11792
  if (currentSearchValue === encodedValue) {
11324
11793
  return;
11325
11794
  }
11795
+ pendingWriteRef.current = { encodedValue, options };
11326
11796
  requestAnimationFrame(() => {
11327
- if (router.state.status === "pending") {
11328
- return;
11329
- }
11330
- const shouldReplace = options?.replace ?? replaceOption ?? !Boolean(currentSearchValue);
11331
- void navigate({
11332
- to: ".",
11333
- search: (prev) => {
11334
- if (getUrlLengthWithSearchParam(key, encodedValue || "", prev) <= MAX_URL_LENGTH) {
11335
- return { ...prev, [key]: encodedValue };
11336
- }
11337
- else {
11338
- return { ...prev, [key]: undefined };
11339
- }
11340
- },
11341
- hash: location.hash,
11342
- replace: shouldReplace,
11343
- });
11797
+ flushPendingWrite();
11344
11798
  });
11345
- }, [
11346
- enabled,
11347
- navigate,
11348
- key,
11349
- replaceOption,
11350
- location.hash,
11351
- getUrlLengthWithSearchParam,
11352
- currentSearchValue,
11353
- router.state.status,
11354
- ]);
11799
+ }, [enabled, currentSearchValue, flushPendingWrite]);
11355
11800
  return react.useMemo(() => ({ searchValue: currentSearchValue, updateSearchParam }), [currentSearchValue, updateSearchParam]);
11356
11801
  };
11357
11802
 
@@ -11370,11 +11815,17 @@ const useStorageKey = (key, userId) => {
11370
11815
  };
11371
11816
 
11372
11817
  /**
11373
- * Generic persistence hook that loads state from URL search params (with
11374
- * localStorage fallback) and writes changes to both.
11818
+ * Generic persistence hook that loads state from a URL slot (search param
11819
+ * or hash fragment, depending on `urlLocation`) with localStorage fallback,
11820
+ * and writes changes to both.
11375
11821
  *
11376
11822
  * On mount the hook resolves the initial state as follows:
11377
- * - If a URL search param exists, decode it and pass through `validate`.
11823
+ * - If the canonical URL slot (search OR hash, per `urlLocation`) holds a
11824
+ * value, decode it and pass through `validate`.
11825
+ * - In `urlLocation: "hash"` mode, if the canonical hash slot is empty
11826
+ * but a **legacy** `?<key>=...` search param is present, decode and
11827
+ * validate that as a one-shot fallback. A migration effect then strips
11828
+ * the legacy search param via a `replace` navigation.
11378
11829
  * - If localStorage is enabled, read and validate its value.
11379
11830
  * - When `mergeWithStorageOnRead` is `false` (default): URL state wins
11380
11831
  * entirely when present, otherwise fall back to localStorage.
@@ -11382,32 +11833,61 @@ const useStorageKey = (key, userId) => {
11382
11833
  * localStorage so fields stripped by `toUrlValue` survive via storage.
11383
11834
  *
11384
11835
  * `persistState` writes the state to localStorage (via `serialize`, default
11385
- * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
11386
- * Duplicate writes where the state has not changed (deep equality) are skipped.
11836
+ * `storageSerializer.serialize`) and syncs to the canonical URL slot (via
11837
+ * `toUrlValue` + encoding). Duplicate writes where the state has not
11838
+ * changed (deep equality) are skipped.
11387
11839
  *
11388
11840
  * @param options - Configuration for the persisted state.
11389
- * @param options.key - Unique identifier used for both the URL search param and the localStorage key.
11841
+ * @param options.key - Unique identifier used for both the URL slot and the localStorage key.
11390
11842
  * @param options.validate - Called with the decoded/parsed value; must return `TState` or `undefined`.
11391
11843
  * @param options.serialize - Custom localStorage serialiser (default `storageSerializer.serialize`).
11392
11844
  * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
11393
11845
  * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
11394
11846
  * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
11395
11847
  * @param options.localStorageEnabled - When `false` localStorage is neither read nor written (default `true`).
11396
- * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
11397
- * @param options.replace - Forwarded to `useSearchParamSync`.
11848
+ * @param options.onExternalChange - Fired when the URL value changes externally (e.g. browser back).
11849
+ * @param options.replace - Forwarded to the underlying URL sync hook.
11398
11850
  * @param options.clientSideUserId - The user ID to use for the localStorage key.
11399
11851
  * @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
11400
11852
  * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
11401
11853
  * omits fields so they still survive via localStorage. Default `false`.
11854
+ * @param options.urlLocation - Which URL slot backs the value (`"search"` or `"hash"`). Default `"search"`.
11402
11855
  */
11403
- const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, }) => {
11856
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", }) => {
11404
11857
  const { encode, decode } = useCustomEncoding();
11405
- const { searchValue, updateSearchParam } = useSearchParamSync({
11858
+ const searchActive = enabled && urlLocation === "search";
11859
+ const hashActive = enabled && urlLocation === "hash";
11860
+ // Both URL-sync hooks are mounted unconditionally to satisfy the Rules of
11861
+ // Hooks; the inactive one is gated off via `enabled: false` and is a
11862
+ // no-op at runtime.
11863
+ const searchSync = useSearchParamSync({
11864
+ key,
11865
+ enabled: searchActive,
11866
+ onExternalChange,
11867
+ replace,
11868
+ });
11869
+ const hashSync = useHashParamSync({
11406
11870
  key,
11407
- enabled,
11871
+ enabled: hashActive,
11408
11872
  onExternalChange,
11409
11873
  replace,
11410
11874
  });
11875
+ const { searchValue, updateSearchParam } = hashActive ? hashSync : searchSync;
11876
+ // In hash mode, also read the raw search params so a legacy `?<key>=...`
11877
+ // value can hydrate the initial state once, before the migration effect
11878
+ // strips it. In search mode this stays `undefined` so the legacy fallback
11879
+ // is inert.
11880
+ const rawSearch = reactRouter.useSearch({ strict: false, shouldThrow: false });
11881
+ const legacySearchValue = react.useMemo(() => {
11882
+ if (!hashActive) {
11883
+ return undefined;
11884
+ }
11885
+ const raw = rawSearch?.[key];
11886
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
11887
+ return String(raw);
11888
+ }
11889
+ return undefined;
11890
+ }, [hashActive, rawSearch, key]);
11411
11891
  const storageKey = useStorageKey(key, clientSideUserId);
11412
11892
  const toUrlValueRef = react.useRef(toUrlValue);
11413
11893
  react.useEffect(() => {
@@ -11429,22 +11909,44 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
11429
11909
  react.useEffect(() => {
11430
11910
  searchValueRef.current = searchValue;
11431
11911
  }, [searchValue]);
11432
- const readPersistedState = react.useCallback(({ valueFromUrl, validateState, transformFromUrlValue, }) => {
11433
- let urlState;
11434
- if (enabled && valueFromUrl) {
11912
+ const legacySearchValueRef = react.useRef(legacySearchValue);
11913
+ react.useEffect(() => {
11914
+ legacySearchValueRef.current = legacySearchValue;
11915
+ }, [legacySearchValue]);
11916
+ const readPersistedState = react.useCallback(({ valueFromUrl, legacyValueFromUrl, validateState, transformFromUrlValue, }) => {
11917
+ const tryDecode = (raw) => {
11435
11918
  try {
11436
- const decoded = decode(valueFromUrl);
11919
+ const decoded = decode(raw);
11437
11920
  const transformed = transformFromUrlValue ? transformFromUrlValue(decoded) : decoded;
11438
- urlState = validateState(transformed);
11921
+ return validateState(transformed);
11439
11922
  }
11440
11923
  catch {
11441
- // fall through to localStorage
11924
+ return undefined;
11925
+ }
11926
+ };
11927
+ let urlState;
11928
+ let canonicalUrlValid = false;
11929
+ let usedLegacyUrl = false;
11930
+ if (enabled && valueFromUrl) {
11931
+ urlState = tryDecode(valueFromUrl);
11932
+ if (urlState !== undefined) {
11933
+ canonicalUrlValid = true;
11934
+ }
11935
+ }
11936
+ if (urlState === undefined && enabled && legacyValueFromUrl) {
11937
+ urlState = tryDecode(legacyValueFromUrl);
11938
+ if (urlState !== undefined) {
11939
+ usedLegacyUrl = true;
11442
11940
  }
11443
11941
  }
11444
11942
  // Without the merge option, URL state wins entirely when present —
11445
11943
  // this preserves the original "URL > localStorage" priority.
11446
11944
  if (urlState !== undefined && !mergeWithStorageOnRead) {
11447
- return { initialState: urlState, loadedFromStorage: false };
11945
+ return {
11946
+ initialState: urlState,
11947
+ canonicalUrlHasValidState: canonicalUrlValid,
11948
+ needsUrlRestore: usedLegacyUrl,
11949
+ };
11448
11950
  }
11449
11951
  let storageState;
11450
11952
  if (localStorageEnabled) {
@@ -11462,22 +11964,39 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
11462
11964
  if (urlState !== undefined && storageState !== undefined) {
11463
11965
  // Shallow merge — URL fields take precedence; storage fills in
11464
11966
  // whatever the URL omits (e.g. fields stripped by `toUrlValue`).
11465
- return { initialState: { ...storageState, ...urlState }, loadedFromStorage: false };
11967
+ return {
11968
+ initialState: { ...storageState, ...urlState },
11969
+ canonicalUrlHasValidState: canonicalUrlValid,
11970
+ needsUrlRestore: usedLegacyUrl,
11971
+ };
11466
11972
  }
11467
11973
  if (urlState !== undefined) {
11468
- return { initialState: urlState, loadedFromStorage: false };
11974
+ return {
11975
+ initialState: urlState,
11976
+ canonicalUrlHasValidState: canonicalUrlValid,
11977
+ needsUrlRestore: usedLegacyUrl,
11978
+ };
11469
11979
  }
11470
11980
  if (storageState !== undefined) {
11471
- return { initialState: storageState, loadedFromStorage: true };
11981
+ return {
11982
+ initialState: storageState,
11983
+ canonicalUrlHasValidState: false,
11984
+ needsUrlRestore: true,
11985
+ };
11472
11986
  }
11473
- return { initialState: undefined, loadedFromStorage: false };
11987
+ return {
11988
+ initialState: undefined,
11989
+ canonicalUrlHasValidState: false,
11990
+ needsUrlRestore: false,
11991
+ };
11474
11992
  }, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
11475
11993
  const [persistedStateSnapshot, setPersistedStateSnapshot] = react.useState(() => readPersistedState({
11476
11994
  valueFromUrl: searchValue,
11995
+ legacyValueFromUrl: legacySearchValue,
11477
11996
  validateState: validate,
11478
11997
  transformFromUrlValue: fromUrlValue,
11479
11998
  }));
11480
- const persistedStateReadKey = `${enabled}:${localStorageEnabled}:${mergeWithStorageOnRead}:${storageKey}`;
11999
+ const persistedStateReadKey = `${enabled}:${localStorageEnabled}:${mergeWithStorageOnRead}:${urlLocation}:${storageKey}`;
11481
12000
  const persistedStateReadKeyRef = react.useRef(persistedStateReadKey);
11482
12001
  react.useEffect(() => {
11483
12002
  if (persistedStateReadKeyRef.current === persistedStateReadKey) {
@@ -11486,11 +12005,39 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
11486
12005
  persistedStateReadKeyRef.current = persistedStateReadKey;
11487
12006
  setPersistedStateSnapshot(readPersistedState({
11488
12007
  valueFromUrl: searchValueRef.current,
12008
+ legacyValueFromUrl: legacySearchValueRef.current,
11489
12009
  validateState: validateRef.current,
11490
12010
  transformFromUrlValue: fromUrlValueRef.current,
11491
12011
  }));
11492
12012
  }, [persistedStateReadKey, readPersistedState]);
11493
- const { initialState, loadedFromStorage } = persistedStateSnapshot;
12013
+ const { initialState, canonicalUrlHasValidState, needsUrlRestore } = persistedStateSnapshot;
12014
+ // Late-hydration recovery: TanStack Router's `useSearch` can return an
12015
+ // empty object on the very first render of a route that's still in a
12016
+ // loader-driven pending phase. The `useState` initializer above runs
12017
+ // exactly once, so a legacy `?<key>=...` value that only becomes
12018
+ // visible on a later render would otherwise be missed entirely —
12019
+ // `initialState` would resolve to `undefined`, and the migration
12020
+ // effect would strip the legacy param without ever piping it through
12021
+ // `onMigrated`. We re-read here when:
12022
+ // - the snapshot has nothing usable yet (`initialState === undefined`
12023
+ // AND the canonical URL slot has no valid value), and
12024
+ // - a legacy value has just appeared.
12025
+ // Once `initialState` is populated, this effect's guard short-circuits
12026
+ // and the rest of the lifecycle (restore + migration) takes over.
12027
+ react.useEffect(() => {
12028
+ if (initialState !== undefined || canonicalUrlHasValidState) {
12029
+ return;
12030
+ }
12031
+ if (legacySearchValue === undefined) {
12032
+ return;
12033
+ }
12034
+ setPersistedStateSnapshot(readPersistedState({
12035
+ valueFromUrl: searchValueRef.current,
12036
+ legacyValueFromUrl: legacySearchValue,
12037
+ validateState: validateRef.current,
12038
+ transformFromUrlValue: fromUrlValueRef.current,
12039
+ }));
12040
+ }, [legacySearchValue, initialState, canonicalUrlHasValidState, readPersistedState]);
11494
12041
  const lastPersistedRef = react.useRef(initialState);
11495
12042
  react.useEffect(() => {
11496
12043
  lastPersistedRef.current = initialState;
@@ -11498,19 +12045,54 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
11498
12045
  const hasRestoredUrlRef = react.useRef(false);
11499
12046
  react.useEffect(() => {
11500
12047
  hasRestoredUrlRef.current = false;
11501
- }, [enabled, localStorageEnabled, storageKey]);
12048
+ }, [enabled, localStorageEnabled, storageKey, urlLocation]);
11502
12049
  react.useEffect(() => {
11503
12050
  if (hasRestoredUrlRef.current) {
11504
12051
  return;
11505
12052
  }
11506
- if (!enabled || !loadedFromStorage || initialState === undefined || searchValue !== undefined) {
12053
+ // Gate on `canonicalUrlHasValidState`, not on the raw `searchValue`:
12054
+ // an undecodable string sitting in the canonical slot must NOT block
12055
+ // the restore, otherwise a valid legacy/search/storage fallback ends
12056
+ // up silently dropped (the bad string survives the migration's strip
12057
+ // and is the only state left on the next reload).
12058
+ if (!enabled || !needsUrlRestore || initialState === undefined || canonicalUrlHasValidState) {
11507
12059
  return;
11508
12060
  }
11509
12061
  hasRestoredUrlRef.current = true;
11510
12062
  const urlValue = toUrlValueRef.current ? toUrlValueRef.current(initialState) : initialState;
11511
12063
  const encoded = encode(urlValue);
11512
12064
  updateSearchParam(encoded, { replace: true });
11513
- }, [enabled, loadedFromStorage, initialState, searchValue, updateSearchParam, encode]);
12065
+ }, [enabled, needsUrlRestore, initialState, canonicalUrlHasValidState, updateSearchParam, encode]);
12066
+ // Strip the legacy search param so reloads come from the canonical hash
12067
+ // slot and the URL stays tidy. The hook is a no-op outside hash mode.
12068
+ // `hashAlreadyHasValue` is `true` only when the canonical hash slot
12069
+ // already holds *valid* state — a raw but undecodable string must not
12070
+ // suppress `onMigrated`, otherwise a valid legacy value would be lost
12071
+ // on the next reload (see `canonicalUrlHasValidState` for the same
12072
+ // reasoning in the restore effect above).
12073
+ useMigrateLegacySearchParam({
12074
+ key,
12075
+ enabled: hashActive,
12076
+ decode,
12077
+ fromUrlValue,
12078
+ validate,
12079
+ hashAlreadyHasValue: canonicalUrlHasValidState,
12080
+ onMigrated: react.useCallback((migrated) => {
12081
+ if (dequal.dequal(lastPersistedRef.current, migrated)) {
12082
+ return;
12083
+ }
12084
+ lastPersistedRef.current = migrated;
12085
+ if (localStorageEnabled) {
12086
+ const serialized = serializeRef.current
12087
+ ? serializeRef.current(migrated)
12088
+ : storageSerializer.serialize(migrated);
12089
+ localStorage.setItem(storageKey, serialized);
12090
+ }
12091
+ const urlValue = toUrlValueRef.current ? toUrlValueRef.current(migrated) : migrated;
12092
+ const encoded = encode(urlValue);
12093
+ updateSearchParam(encoded, { replace: true });
12094
+ }, [encode, localStorageEnabled, storageKey, updateSearchParam]),
12095
+ });
11514
12096
  const persistState = react.useCallback((state) => {
11515
12097
  if (dequal.dequal(lastPersistedRef.current, state)) {
11516
12098
  return;
@@ -12712,6 +13294,7 @@ exports.KPICardSkeleton = KPICardSkeleton;
12712
13294
  exports.KPISkeleton = KPISkeleton;
12713
13295
  exports.List = List;
12714
13296
  exports.ListItem = ListItem;
13297
+ exports.MAX_HASH_LENGTH = MAX_HASH_LENGTH;
12715
13298
  exports.MAX_URL_LENGTH = MAX_URL_LENGTH;
12716
13299
  exports.MenuDivider = MenuDivider;
12717
13300
  exports.MenuItem = MenuItem;
@@ -12827,6 +13410,7 @@ exports.useDevicePixelRatio = useDevicePixelRatio;
12827
13410
  exports.useElevatedReducer = useElevatedReducer;
12828
13411
  exports.useElevatedState = useElevatedState;
12829
13412
  exports.useGridAreas = useGridAreas;
13413
+ exports.useHashParamSync = useHashParamSync;
12830
13414
  exports.useHover = useHover;
12831
13415
  exports.useInfiniteScroll = useInfiniteScroll;
12832
13416
  exports.useIsFirstRender = useIsFirstRender;