@trackunit/react-components 1.25.3 → 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/migrations/entry.js.map +1 -0
- package/migrations/utils/jsx-utils.js.map +1 -0
- package/migrations/v2-0-0/breadcrumb-remove-deprecated-props.js.map +1 -0
- package/migrations/v2-0-0/cardheader-onclickclose-to-actions.js.map +1 -0
- package/migrations/v2-0-0/iconbutton-add-required-name.js.map +1 -0
- package/migrations/v2-0-0/kpi-tooltiplabel-to-wrapper.js.map +1 -0
- package/migrations/v2-0-0/notice-tooltiplabel-to-wrapper.js.map +1 -0
- package/migrations/v2-0-0/pagination-add-required-titles.js.map +1 -0
- package/migrations/v2-0-0/tag-add-removetaglabel.js.map +1 -0
- package/migrations/v2-0-0/togglegroup-remove-item-style.js.map +1 -0
- 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/translation.cjs.js +4 -1
- package/translation.cjs10.js +4 -1
- package/translation.cjs11.js +4 -1
- package/translation.cjs12.js +4 -1
- package/translation.cjs13.js +4 -1
- package/translation.cjs14.js +4 -1
- package/translation.cjs15.js +4 -1
- package/translation.cjs16.js +4 -1
- package/translation.cjs17.js +4 -1
- package/translation.cjs2.js +4 -1
- package/translation.cjs3.js +4 -1
- package/translation.cjs4.js +4 -1
- package/translation.cjs5.js +4 -1
- package/translation.cjs6.js +4 -1
- package/translation.cjs7.js +4 -1
- package/translation.cjs8.js +4 -1
- package/translation.cjs9.js +4 -1
- package/translation.esm.js +4 -1
- package/translation.esm10.js +4 -1
- package/translation.esm11.js +4 -1
- package/translation.esm12.js +4 -1
- package/translation.esm13.js +4 -1
- package/translation.esm14.js +4 -1
- package/translation.esm15.js +4 -1
- package/translation.esm16.js +4 -1
- package/translation.esm17.js +4 -1
- package/translation.esm2.js +4 -1
- package/translation.esm3.js +4 -1
- package/translation.esm4.js +4 -1
- package/translation.esm5.js +4 -1
- package/translation.esm6.js +4 -1
- package/translation.esm7.js +4 -1
- package/translation.esm8.js +4 -1
- package/translation.esm9.js +4 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
11374
|
-
*
|
|
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
|
|
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
|
-
* `
|
|
11386
|
-
* Duplicate writes where the state has not
|
|
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
|
|
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
|
|
11397
|
-
* @param options.replace - Forwarded to
|
|
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
|
|
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
|
|
11433
|
-
|
|
11434
|
-
|
|
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(
|
|
11919
|
+
const decoded = decode(raw);
|
|
11437
11920
|
const transformed = transformFromUrlValue ? transformFromUrlValue(decoded) : decoded;
|
|
11438
|
-
|
|
11921
|
+
return validateState(transformed);
|
|
11439
11922
|
}
|
|
11440
11923
|
catch {
|
|
11441
|
-
|
|
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 {
|
|
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 {
|
|
11967
|
+
return {
|
|
11968
|
+
initialState: { ...storageState, ...urlState },
|
|
11969
|
+
canonicalUrlHasValidState: canonicalUrlValid,
|
|
11970
|
+
needsUrlRestore: usedLegacyUrl,
|
|
11971
|
+
};
|
|
11466
11972
|
}
|
|
11467
11973
|
if (urlState !== undefined) {
|
|
11468
|
-
return {
|
|
11974
|
+
return {
|
|
11975
|
+
initialState: urlState,
|
|
11976
|
+
canonicalUrlHasValidState: canonicalUrlValid,
|
|
11977
|
+
needsUrlRestore: usedLegacyUrl,
|
|
11978
|
+
};
|
|
11469
11979
|
}
|
|
11470
11980
|
if (storageState !== undefined) {
|
|
11471
|
-
return {
|
|
11981
|
+
return {
|
|
11982
|
+
initialState: storageState,
|
|
11983
|
+
canonicalUrlHasValidState: false,
|
|
11984
|
+
needsUrlRestore: true,
|
|
11985
|
+
};
|
|
11472
11986
|
}
|
|
11473
|
-
return {
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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;
|