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