@trackunit/react-components 3.5.2-alpha-34e3e456eda.0 → 3.5.3-alpha-b0d834f66ac.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 +140 -80
- package/index.esm.js +140 -81
- package/package.json +10 -10
- package/src/hooks/persistence/hashFragment.d.ts +8 -0
- package/src/hooks/persistence/usePersistedState.d.ts +13 -18
- package/src/hooks/persistence/useStripForeignIdentityHash.d.ts +23 -0
- package/src/index.d.ts +1 -0
package/index.cjs.js
CHANGED
|
@@ -13456,6 +13456,30 @@ const readHashKey = (hash, key) => {
|
|
|
13456
13456
|
}
|
|
13457
13457
|
return undefined;
|
|
13458
13458
|
};
|
|
13459
|
+
/**
|
|
13460
|
+
* Lists the key of every named entry in a URL fragment, in order of first
|
|
13461
|
+
* appearance. Foreign tokens (no `=`) are skipped and duplicates collapse.
|
|
13462
|
+
*
|
|
13463
|
+
* @param hash - The fragment string, with or without the leading `#`.
|
|
13464
|
+
* @returns {Array<string>} The distinct entry keys.
|
|
13465
|
+
*/
|
|
13466
|
+
const listHashKeys = (hash) => {
|
|
13467
|
+
const body = stripLeadingHash(hash);
|
|
13468
|
+
if (body.length === 0) {
|
|
13469
|
+
return [];
|
|
13470
|
+
}
|
|
13471
|
+
const keys = [];
|
|
13472
|
+
for (const token of body.split(TOKEN_SEPARATOR)) {
|
|
13473
|
+
const eqIndex = token.indexOf(KEY_VALUE_SEPARATOR);
|
|
13474
|
+
if (eqIndex > 0) {
|
|
13475
|
+
const key = token.slice(0, eqIndex);
|
|
13476
|
+
if (!keys.includes(key)) {
|
|
13477
|
+
keys.push(key);
|
|
13478
|
+
}
|
|
13479
|
+
}
|
|
13480
|
+
}
|
|
13481
|
+
return keys;
|
|
13482
|
+
};
|
|
13459
13483
|
/**
|
|
13460
13484
|
* Returns a new fragment string with the named entry written, replaced,
|
|
13461
13485
|
* or removed. Foreign tokens are preserved in their original order; the
|
|
@@ -14078,41 +14102,6 @@ const rawLocalStorage = {
|
|
|
14078
14102
|
read: (key) => localStorage.getItem(key),
|
|
14079
14103
|
write: (key, value) => localStorage.setItem(key, value),
|
|
14080
14104
|
remove: (key) => localStorage.removeItem(key),
|
|
14081
|
-
keys: () => {
|
|
14082
|
-
const keys = [];
|
|
14083
|
-
for (let index = 0; index < localStorage.length; index++) {
|
|
14084
|
-
const key = localStorage.key(index);
|
|
14085
|
-
if (key !== null) {
|
|
14086
|
-
keys.push(key);
|
|
14087
|
-
}
|
|
14088
|
-
}
|
|
14089
|
-
return keys;
|
|
14090
|
-
},
|
|
14091
|
-
};
|
|
14092
|
-
/* eslint-enable no-restricted-globals */
|
|
14093
|
-
/**
|
|
14094
|
-
* Whether some *other* identity that has used this browser wrote the URL
|
|
14095
|
-
* value: `owner` is the fingerprint of a user id that has state stored under
|
|
14096
|
-
* `key` in this localStorage. Storage keys are `<key>-<clientSideUserId>`
|
|
14097
|
-
* (see `useStorageKey`), so the ids can be recovered from the keys.
|
|
14098
|
-
*
|
|
14099
|
-
* A value with no owner, or one written on another device (no matching
|
|
14100
|
-
* storage here), is not foreign: that is a link someone shared.
|
|
14101
|
-
*/
|
|
14102
|
-
const isUrlOwnedByOtherLocalIdentity = (key, owner, currentOwner) => {
|
|
14103
|
-
if (owner === undefined || currentOwner === undefined || owner === currentOwner) {
|
|
14104
|
-
return false;
|
|
14105
|
-
}
|
|
14106
|
-
const prefix = `${key}-`;
|
|
14107
|
-
try {
|
|
14108
|
-
return rawLocalStorage
|
|
14109
|
-
.keys()
|
|
14110
|
-
.filter(storageKey => storageKey.startsWith(prefix))
|
|
14111
|
-
.some(storageKey => identityFingerprint(storageKey.slice(prefix.length)) === owner);
|
|
14112
|
-
}
|
|
14113
|
-
catch {
|
|
14114
|
-
return false;
|
|
14115
|
-
}
|
|
14116
14105
|
};
|
|
14117
14106
|
/**
|
|
14118
14107
|
* Generic persistence hook that loads state from a URL slot (search param
|
|
@@ -14157,16 +14146,15 @@ const isUrlOwnedByOtherLocalIdentity = (key, owner, currentOwner) => {
|
|
|
14157
14146
|
* state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
|
|
14158
14147
|
* omits fields so they still survive via localStorage. Default `false`.
|
|
14159
14148
|
* @param options.urlLocation - Which URL slot backs the value (`"search"` or `"hash"`). Default `"search"`.
|
|
14160
|
-
* @param options.
|
|
14161
|
-
*
|
|
14149
|
+
* @param options.stampUrlWithIdentity - Stamp URL writes with the current identity so the login
|
|
14150
|
+
* redirect can drop entries left by another account on this browser. Default `false`.
|
|
14162
14151
|
*/
|
|
14163
|
-
const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search",
|
|
14152
|
+
const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", stampUrlWithIdentity = false, }) => {
|
|
14164
14153
|
const { encode, decode } = useCustomEncoding();
|
|
14165
14154
|
// Fingerprint of the identity this hook writes URL values for. `undefined`
|
|
14166
|
-
// while the identity is unresolved (or when
|
|
14167
|
-
// unstamped
|
|
14168
|
-
|
|
14169
|
-
const currentUrlOwner = react.useMemo(() => (scopeUrlToIdentity && clientSideUserId !== undefined ? identityFingerprint(clientSideUserId) : undefined), [scopeUrlToIdentity, clientSideUserId]);
|
|
14155
|
+
// while the identity is unresolved (or when stamping is off): writes are
|
|
14156
|
+
// then unstamped.
|
|
14157
|
+
const currentUrlOwner = react.useMemo(() => (stampUrlWithIdentity && clientSideUserId !== undefined ? identityFingerprint(clientSideUserId) : undefined), [stampUrlWithIdentity, clientSideUserId]);
|
|
14170
14158
|
const searchActive = enabled && urlLocation === "search";
|
|
14171
14159
|
const hashActive = enabled && urlLocation === "hash";
|
|
14172
14160
|
// Both URL-sync hooks are mounted unconditionally to satisfy the Rules of
|
|
@@ -14222,14 +14210,16 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14222
14210
|
legacySearchValueRef.current = legacySearchValue;
|
|
14223
14211
|
}, [legacySearchValue]);
|
|
14224
14212
|
const readPersistedState = react.useCallback(({ valueFromUrl, legacyValueFromUrl, validateState, transformFromUrlValue, }) => {
|
|
14213
|
+
// The ownership stamp is only read at the login redirect (see
|
|
14214
|
+
// `useStripForeignIdentityHash`); here it is unwrapped and discarded.
|
|
14225
14215
|
const tryDecode = (raw) => {
|
|
14226
14216
|
try {
|
|
14227
|
-
const {
|
|
14217
|
+
const { value } = unwrapUrlOwner(decode(raw));
|
|
14228
14218
|
const transformed = transformFromUrlValue ? transformFromUrlValue(value) : value;
|
|
14229
|
-
return
|
|
14219
|
+
return validateState(transformed);
|
|
14230
14220
|
}
|
|
14231
14221
|
catch {
|
|
14232
|
-
return
|
|
14222
|
+
return undefined;
|
|
14233
14223
|
}
|
|
14234
14224
|
};
|
|
14235
14225
|
const readStoredState = () => {
|
|
@@ -14250,30 +14240,14 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14250
14240
|
let urlState;
|
|
14251
14241
|
let canonicalUrlValid = false;
|
|
14252
14242
|
let usedLegacyUrl = false;
|
|
14253
|
-
let rejectedAsForeign = false;
|
|
14254
14243
|
if (enabled && valueFromUrl) {
|
|
14255
|
-
|
|
14256
|
-
// Another account on this browser left its state in the URL, or an
|
|
14257
|
-
// unstamped bookmark/history entry would override a layout this user
|
|
14258
|
-
// already saved. Treat the slot as empty so this user's storage wins
|
|
14259
|
-
// and the restore effect below overwrites the stale entry.
|
|
14260
|
-
const unstampedOverSavedState = scopeUrlToIdentity &&
|
|
14261
|
-
decoded.owner === undefined &&
|
|
14262
|
-
decoded.state !== undefined &&
|
|
14263
|
-
currentUrlOwner !== undefined &&
|
|
14264
|
-
readStoredState() !== undefined;
|
|
14265
|
-
rejectedAsForeign =
|
|
14266
|
-
unstampedOverSavedState ||
|
|
14267
|
-
(scopeUrlToIdentity && isUrlOwnedByOtherLocalIdentity(key, decoded.owner, currentUrlOwner));
|
|
14268
|
-
urlState = rejectedAsForeign ? undefined : decoded.state;
|
|
14244
|
+
urlState = tryDecode(valueFromUrl);
|
|
14269
14245
|
if (urlState !== undefined) {
|
|
14270
14246
|
canonicalUrlValid = true;
|
|
14271
14247
|
}
|
|
14272
14248
|
}
|
|
14273
|
-
|
|
14274
|
-
|
|
14275
|
-
if (urlState === undefined && !rejectedAsForeign && enabled && legacyValueFromUrl) {
|
|
14276
|
-
urlState = tryDecode(legacyValueFromUrl).state;
|
|
14249
|
+
if (urlState === undefined && enabled && legacyValueFromUrl) {
|
|
14250
|
+
urlState = tryDecode(legacyValueFromUrl);
|
|
14277
14251
|
if (urlState !== undefined) {
|
|
14278
14252
|
usedLegacyUrl = true;
|
|
14279
14253
|
}
|
|
@@ -14285,7 +14259,6 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14285
14259
|
initialState: urlState,
|
|
14286
14260
|
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14287
14261
|
needsUrlRestore: usedLegacyUrl,
|
|
14288
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14289
14262
|
};
|
|
14290
14263
|
}
|
|
14291
14264
|
const storageState = readStoredState();
|
|
@@ -14296,7 +14269,6 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14296
14269
|
initialState: { ...storageState, ...urlState },
|
|
14297
14270
|
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14298
14271
|
needsUrlRestore: usedLegacyUrl,
|
|
14299
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14300
14272
|
};
|
|
14301
14273
|
}
|
|
14302
14274
|
if (urlState !== undefined) {
|
|
@@ -14304,7 +14276,6 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14304
14276
|
initialState: urlState,
|
|
14305
14277
|
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14306
14278
|
needsUrlRestore: usedLegacyUrl,
|
|
14307
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14308
14279
|
};
|
|
14309
14280
|
}
|
|
14310
14281
|
if (storageState !== undefined) {
|
|
@@ -14312,16 +14283,14 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14312
14283
|
initialState: storageState,
|
|
14313
14284
|
canonicalUrlHasValidState: false,
|
|
14314
14285
|
needsUrlRestore: true,
|
|
14315
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14316
14286
|
};
|
|
14317
14287
|
}
|
|
14318
14288
|
return {
|
|
14319
14289
|
initialState: undefined,
|
|
14320
14290
|
canonicalUrlHasValidState: false,
|
|
14321
14291
|
needsUrlRestore: false,
|
|
14322
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14323
14292
|
};
|
|
14324
|
-
}, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead
|
|
14293
|
+
}, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
|
|
14325
14294
|
// Every URL write goes through here so the ownership stamp is applied
|
|
14326
14295
|
// uniformly (user edits, storage→URL restore, legacy search-param migration).
|
|
14327
14296
|
const encodeForUrl = react.useCallback((state) => {
|
|
@@ -14348,7 +14317,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14348
14317
|
transformFromUrlValue: fromUrlValueRef.current,
|
|
14349
14318
|
}));
|
|
14350
14319
|
}, [persistedStateReadKey, readPersistedState]);
|
|
14351
|
-
const { initialState, canonicalUrlHasValidState, needsUrlRestore
|
|
14320
|
+
const { initialState, canonicalUrlHasValidState, needsUrlRestore } = persistedStateSnapshot;
|
|
14352
14321
|
// Late-hydration recovery: TanStack Router's `useSearch` can return an
|
|
14353
14322
|
// empty object on the very first render of a route that's still in a
|
|
14354
14323
|
// loader-driven pending phase. The `useState` initializer above runs
|
|
@@ -14359,13 +14328,11 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14359
14328
|
// `onMigrated`. We re-read here when:
|
|
14360
14329
|
// - the snapshot has nothing usable yet (`initialState === undefined`
|
|
14361
14330
|
// AND the canonical URL slot has no valid value), and
|
|
14362
|
-
// - a legacy value has just appeared
|
|
14363
|
-
// - the canonical slot was not rejected (a legacy value next to a foreign
|
|
14364
|
-
// or unstamped-over-saved hash is ignored by the read anyway).
|
|
14331
|
+
// - a legacy value has just appeared.
|
|
14365
14332
|
// Once `initialState` is populated, this effect's guard short-circuits
|
|
14366
14333
|
// and the rest of the lifecycle (restore + migration) takes over.
|
|
14367
14334
|
react.useEffect(() => {
|
|
14368
|
-
if (initialState !== undefined || canonicalUrlHasValidState
|
|
14335
|
+
if (initialState !== undefined || canonicalUrlHasValidState) {
|
|
14369
14336
|
return;
|
|
14370
14337
|
}
|
|
14371
14338
|
if (legacySearchValue === undefined) {
|
|
@@ -14377,7 +14344,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14377
14344
|
validateState: validateRef.current,
|
|
14378
14345
|
transformFromUrlValue: fromUrlValueRef.current,
|
|
14379
14346
|
}));
|
|
14380
|
-
}, [legacySearchValue, initialState, canonicalUrlHasValidState,
|
|
14347
|
+
}, [legacySearchValue, initialState, canonicalUrlHasValidState, readPersistedState]);
|
|
14381
14348
|
const lastPersistedRef = react.useRef(initialState);
|
|
14382
14349
|
react.useEffect(() => {
|
|
14383
14350
|
lastPersistedRef.current = initialState;
|
|
@@ -14408,16 +14375,13 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14408
14375
|
// suppress `onMigrated`, otherwise a valid legacy value would be lost
|
|
14409
14376
|
// on the next reload (see `canonicalUrlHasValidState` for the same
|
|
14410
14377
|
// reasoning in the restore effect above).
|
|
14411
|
-
// A rejected hash counts as "already has a value" too. The legacy param
|
|
14412
|
-
// beside a foreign stamp or an unstamped hash that lost to saved state is
|
|
14413
|
-
// stripped without being written into this user's storage or hash.
|
|
14414
14378
|
useMigrateLegacySearchParam({
|
|
14415
14379
|
key,
|
|
14416
14380
|
enabled: hashActive,
|
|
14417
14381
|
decode,
|
|
14418
14382
|
fromUrlValue,
|
|
14419
14383
|
validate,
|
|
14420
|
-
hashAlreadyHasValue: canonicalUrlHasValidState
|
|
14384
|
+
hashAlreadyHasValue: canonicalUrlHasValidState,
|
|
14421
14385
|
onMigrated: react.useCallback((migrated) => {
|
|
14422
14386
|
if (dequal.dequal(lastPersistedRef.current, migrated)) {
|
|
14423
14387
|
return;
|
|
@@ -14453,6 +14417,101 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14453
14417
|
return react.useMemo(() => ({ initialState, persistState, clearState }), [initialState, persistState, clearState]);
|
|
14454
14418
|
};
|
|
14455
14419
|
|
|
14420
|
+
/**
|
|
14421
|
+
* Enumerates localStorage keys. Kept local so this file's exemption from the
|
|
14422
|
+
* no-direct-storage-access rule is centralised, as in `usePersistedState`.
|
|
14423
|
+
*/
|
|
14424
|
+
/* eslint-disable no-restricted-globals */
|
|
14425
|
+
const localStorageKeys = () => {
|
|
14426
|
+
const keys = [];
|
|
14427
|
+
for (let index = 0; index < localStorage.length; index++) {
|
|
14428
|
+
const key = localStorage.key(index);
|
|
14429
|
+
if (key !== null) {
|
|
14430
|
+
keys.push(key);
|
|
14431
|
+
}
|
|
14432
|
+
}
|
|
14433
|
+
return keys;
|
|
14434
|
+
};
|
|
14435
|
+
/* eslint-enable no-restricted-globals */
|
|
14436
|
+
/**
|
|
14437
|
+
* Whether some *other* identity that has used this browser wrote the URL
|
|
14438
|
+
* value: `owner` is the fingerprint of a user id that has state stored under
|
|
14439
|
+
* `key` in this localStorage. Storage keys are `<key>-<clientSideUserId>`
|
|
14440
|
+
* (see `useStorageKey`), so the ids can be recovered from the keys.
|
|
14441
|
+
*
|
|
14442
|
+
* A value with no owner, or one written on another device (no matching
|
|
14443
|
+
* storage here), is not foreign: that is a link someone shared.
|
|
14444
|
+
*/
|
|
14445
|
+
const isOwnedByOtherLocalIdentity = (key, owner, currentOwner) => {
|
|
14446
|
+
if (owner === undefined || owner === currentOwner) {
|
|
14447
|
+
return false;
|
|
14448
|
+
}
|
|
14449
|
+
const prefix = `${key}-`;
|
|
14450
|
+
try {
|
|
14451
|
+
return localStorageKeys()
|
|
14452
|
+
.filter(storageKey => storageKey.startsWith(prefix))
|
|
14453
|
+
.some(storageKey => identityFingerprint(storageKey.slice(prefix.length)) === owner);
|
|
14454
|
+
}
|
|
14455
|
+
catch {
|
|
14456
|
+
return false;
|
|
14457
|
+
}
|
|
14458
|
+
};
|
|
14459
|
+
/**
|
|
14460
|
+
* Removes a URL's hash entries that were written by another account on this
|
|
14461
|
+
* browser, so a login redirect does not hand one account's persisted state
|
|
14462
|
+
* (e.g. an asset list layout) to the next account that signs in.
|
|
14463
|
+
*
|
|
14464
|
+
* `usePersistedState` stamps every hash it writes with a fingerprint of the
|
|
14465
|
+
* writing `clientSideUserId` (see `stampUrlWithIdentity`). On load the hash
|
|
14466
|
+
* always wins, so links and bookmarks work for anyone. The URL also survives
|
|
14467
|
+
* the login redirect (`redirectToLogin(location.href)`), which is how the
|
|
14468
|
+
* previous account's layout reached the next account on a shared PC
|
|
14469
|
+
* (GLU-2396). This hook is applied at that boundary only.
|
|
14470
|
+
*
|
|
14471
|
+
* An entry is removed when it is stamped by a *different* identity that has
|
|
14472
|
+
* state stored in this browser under the same key. Unstamped entries, entries
|
|
14473
|
+
* stamped by the reader, and entries whose author has no storage here (a
|
|
14474
|
+
* link shared from another device) are kept.
|
|
14475
|
+
*
|
|
14476
|
+
* @returns {(url: string | undefined, clientSideUserId: string | undefined) => string}
|
|
14477
|
+
* Returns `url` with the foreign entries removed. A missing url comes back
|
|
14478
|
+
* as `""`. The URL is returned as-is when it has no hash or when
|
|
14479
|
+
* `clientSideUserId` is unknown.
|
|
14480
|
+
*/
|
|
14481
|
+
const useStripForeignIdentityHash = () => {
|
|
14482
|
+
const { decode } = useCustomEncoding();
|
|
14483
|
+
return react.useCallback((url, clientSideUserId) => {
|
|
14484
|
+
if (typeof url !== "string") {
|
|
14485
|
+
return "";
|
|
14486
|
+
}
|
|
14487
|
+
const hashIndex = url.indexOf("#");
|
|
14488
|
+
if (clientSideUserId === undefined || hashIndex === -1) {
|
|
14489
|
+
return url;
|
|
14490
|
+
}
|
|
14491
|
+
const base = url.slice(0, hashIndex);
|
|
14492
|
+
const hash = url.slice(hashIndex);
|
|
14493
|
+
const currentOwner = identityFingerprint(clientSideUserId);
|
|
14494
|
+
let next = hash;
|
|
14495
|
+
for (const key of listHashKeys(hash)) {
|
|
14496
|
+
const raw = readHashKey(hash, key);
|
|
14497
|
+
if (raw === undefined) {
|
|
14498
|
+
continue;
|
|
14499
|
+
}
|
|
14500
|
+
let owner;
|
|
14501
|
+
try {
|
|
14502
|
+
owner = unwrapUrlOwner(decode(raw)).owner;
|
|
14503
|
+
}
|
|
14504
|
+
catch {
|
|
14505
|
+
continue;
|
|
14506
|
+
}
|
|
14507
|
+
if (isOwnedByOtherLocalIdentity(key, owner, currentOwner)) {
|
|
14508
|
+
next = writeHashKey(next, key, undefined);
|
|
14509
|
+
}
|
|
14510
|
+
}
|
|
14511
|
+
return `${base}${next}`;
|
|
14512
|
+
}, [decode]);
|
|
14513
|
+
};
|
|
14514
|
+
|
|
14456
14515
|
const OVERSCAN = 10;
|
|
14457
14516
|
const DEFAULT_ROW_HEIGHT = 50;
|
|
14458
14517
|
/**
|
|
@@ -15873,6 +15932,7 @@ exports.useSessionStorageReducer = useSessionStorageReducer;
|
|
|
15873
15932
|
exports.useSheet = useSheet;
|
|
15874
15933
|
exports.useSheetSnap = useSheetSnap;
|
|
15875
15934
|
exports.useStorageKey = useStorageKey;
|
|
15935
|
+
exports.useStripForeignIdentityHash = useStripForeignIdentityHash;
|
|
15876
15936
|
exports.useTextSearch = useTextSearch;
|
|
15877
15937
|
exports.useTimeout = useTimeout;
|
|
15878
15938
|
exports.useViewportBreakpoints = useViewportBreakpoints;
|
package/index.esm.js
CHANGED
|
@@ -13455,6 +13455,30 @@ const readHashKey = (hash, key) => {
|
|
|
13455
13455
|
}
|
|
13456
13456
|
return undefined;
|
|
13457
13457
|
};
|
|
13458
|
+
/**
|
|
13459
|
+
* Lists the key of every named entry in a URL fragment, in order of first
|
|
13460
|
+
* appearance. Foreign tokens (no `=`) are skipped and duplicates collapse.
|
|
13461
|
+
*
|
|
13462
|
+
* @param hash - The fragment string, with or without the leading `#`.
|
|
13463
|
+
* @returns {Array<string>} The distinct entry keys.
|
|
13464
|
+
*/
|
|
13465
|
+
const listHashKeys = (hash) => {
|
|
13466
|
+
const body = stripLeadingHash(hash);
|
|
13467
|
+
if (body.length === 0) {
|
|
13468
|
+
return [];
|
|
13469
|
+
}
|
|
13470
|
+
const keys = [];
|
|
13471
|
+
for (const token of body.split(TOKEN_SEPARATOR)) {
|
|
13472
|
+
const eqIndex = token.indexOf(KEY_VALUE_SEPARATOR);
|
|
13473
|
+
if (eqIndex > 0) {
|
|
13474
|
+
const key = token.slice(0, eqIndex);
|
|
13475
|
+
if (!keys.includes(key)) {
|
|
13476
|
+
keys.push(key);
|
|
13477
|
+
}
|
|
13478
|
+
}
|
|
13479
|
+
}
|
|
13480
|
+
return keys;
|
|
13481
|
+
};
|
|
13458
13482
|
/**
|
|
13459
13483
|
* Returns a new fragment string with the named entry written, replaced,
|
|
13460
13484
|
* or removed. Foreign tokens are preserved in their original order; the
|
|
@@ -14077,41 +14101,6 @@ const rawLocalStorage = {
|
|
|
14077
14101
|
read: (key) => localStorage.getItem(key),
|
|
14078
14102
|
write: (key, value) => localStorage.setItem(key, value),
|
|
14079
14103
|
remove: (key) => localStorage.removeItem(key),
|
|
14080
|
-
keys: () => {
|
|
14081
|
-
const keys = [];
|
|
14082
|
-
for (let index = 0; index < localStorage.length; index++) {
|
|
14083
|
-
const key = localStorage.key(index);
|
|
14084
|
-
if (key !== null) {
|
|
14085
|
-
keys.push(key);
|
|
14086
|
-
}
|
|
14087
|
-
}
|
|
14088
|
-
return keys;
|
|
14089
|
-
},
|
|
14090
|
-
};
|
|
14091
|
-
/* eslint-enable no-restricted-globals */
|
|
14092
|
-
/**
|
|
14093
|
-
* Whether some *other* identity that has used this browser wrote the URL
|
|
14094
|
-
* value: `owner` is the fingerprint of a user id that has state stored under
|
|
14095
|
-
* `key` in this localStorage. Storage keys are `<key>-<clientSideUserId>`
|
|
14096
|
-
* (see `useStorageKey`), so the ids can be recovered from the keys.
|
|
14097
|
-
*
|
|
14098
|
-
* A value with no owner, or one written on another device (no matching
|
|
14099
|
-
* storage here), is not foreign: that is a link someone shared.
|
|
14100
|
-
*/
|
|
14101
|
-
const isUrlOwnedByOtherLocalIdentity = (key, owner, currentOwner) => {
|
|
14102
|
-
if (owner === undefined || currentOwner === undefined || owner === currentOwner) {
|
|
14103
|
-
return false;
|
|
14104
|
-
}
|
|
14105
|
-
const prefix = `${key}-`;
|
|
14106
|
-
try {
|
|
14107
|
-
return rawLocalStorage
|
|
14108
|
-
.keys()
|
|
14109
|
-
.filter(storageKey => storageKey.startsWith(prefix))
|
|
14110
|
-
.some(storageKey => identityFingerprint(storageKey.slice(prefix.length)) === owner);
|
|
14111
|
-
}
|
|
14112
|
-
catch {
|
|
14113
|
-
return false;
|
|
14114
|
-
}
|
|
14115
14104
|
};
|
|
14116
14105
|
/**
|
|
14117
14106
|
* Generic persistence hook that loads state from a URL slot (search param
|
|
@@ -14156,16 +14145,15 @@ const isUrlOwnedByOtherLocalIdentity = (key, owner, currentOwner) => {
|
|
|
14156
14145
|
* state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
|
|
14157
14146
|
* omits fields so they still survive via localStorage. Default `false`.
|
|
14158
14147
|
* @param options.urlLocation - Which URL slot backs the value (`"search"` or `"hash"`). Default `"search"`.
|
|
14159
|
-
* @param options.
|
|
14160
|
-
*
|
|
14148
|
+
* @param options.stampUrlWithIdentity - Stamp URL writes with the current identity so the login
|
|
14149
|
+
* redirect can drop entries left by another account on this browser. Default `false`.
|
|
14161
14150
|
*/
|
|
14162
|
-
const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search",
|
|
14151
|
+
const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", stampUrlWithIdentity = false, }) => {
|
|
14163
14152
|
const { encode, decode } = useCustomEncoding();
|
|
14164
14153
|
// Fingerprint of the identity this hook writes URL values for. `undefined`
|
|
14165
|
-
// while the identity is unresolved (or when
|
|
14166
|
-
// unstamped
|
|
14167
|
-
|
|
14168
|
-
const currentUrlOwner = useMemo(() => (scopeUrlToIdentity && clientSideUserId !== undefined ? identityFingerprint(clientSideUserId) : undefined), [scopeUrlToIdentity, clientSideUserId]);
|
|
14154
|
+
// while the identity is unresolved (or when stamping is off): writes are
|
|
14155
|
+
// then unstamped.
|
|
14156
|
+
const currentUrlOwner = useMemo(() => (stampUrlWithIdentity && clientSideUserId !== undefined ? identityFingerprint(clientSideUserId) : undefined), [stampUrlWithIdentity, clientSideUserId]);
|
|
14169
14157
|
const searchActive = enabled && urlLocation === "search";
|
|
14170
14158
|
const hashActive = enabled && urlLocation === "hash";
|
|
14171
14159
|
// Both URL-sync hooks are mounted unconditionally to satisfy the Rules of
|
|
@@ -14221,14 +14209,16 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14221
14209
|
legacySearchValueRef.current = legacySearchValue;
|
|
14222
14210
|
}, [legacySearchValue]);
|
|
14223
14211
|
const readPersistedState = useCallback(({ valueFromUrl, legacyValueFromUrl, validateState, transformFromUrlValue, }) => {
|
|
14212
|
+
// The ownership stamp is only read at the login redirect (see
|
|
14213
|
+
// `useStripForeignIdentityHash`); here it is unwrapped and discarded.
|
|
14224
14214
|
const tryDecode = (raw) => {
|
|
14225
14215
|
try {
|
|
14226
|
-
const {
|
|
14216
|
+
const { value } = unwrapUrlOwner(decode(raw));
|
|
14227
14217
|
const transformed = transformFromUrlValue ? transformFromUrlValue(value) : value;
|
|
14228
|
-
return
|
|
14218
|
+
return validateState(transformed);
|
|
14229
14219
|
}
|
|
14230
14220
|
catch {
|
|
14231
|
-
return
|
|
14221
|
+
return undefined;
|
|
14232
14222
|
}
|
|
14233
14223
|
};
|
|
14234
14224
|
const readStoredState = () => {
|
|
@@ -14249,30 +14239,14 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14249
14239
|
let urlState;
|
|
14250
14240
|
let canonicalUrlValid = false;
|
|
14251
14241
|
let usedLegacyUrl = false;
|
|
14252
|
-
let rejectedAsForeign = false;
|
|
14253
14242
|
if (enabled && valueFromUrl) {
|
|
14254
|
-
|
|
14255
|
-
// Another account on this browser left its state in the URL, or an
|
|
14256
|
-
// unstamped bookmark/history entry would override a layout this user
|
|
14257
|
-
// already saved. Treat the slot as empty so this user's storage wins
|
|
14258
|
-
// and the restore effect below overwrites the stale entry.
|
|
14259
|
-
const unstampedOverSavedState = scopeUrlToIdentity &&
|
|
14260
|
-
decoded.owner === undefined &&
|
|
14261
|
-
decoded.state !== undefined &&
|
|
14262
|
-
currentUrlOwner !== undefined &&
|
|
14263
|
-
readStoredState() !== undefined;
|
|
14264
|
-
rejectedAsForeign =
|
|
14265
|
-
unstampedOverSavedState ||
|
|
14266
|
-
(scopeUrlToIdentity && isUrlOwnedByOtherLocalIdentity(key, decoded.owner, currentUrlOwner));
|
|
14267
|
-
urlState = rejectedAsForeign ? undefined : decoded.state;
|
|
14243
|
+
urlState = tryDecode(valueFromUrl);
|
|
14268
14244
|
if (urlState !== undefined) {
|
|
14269
14245
|
canonicalUrlValid = true;
|
|
14270
14246
|
}
|
|
14271
14247
|
}
|
|
14272
|
-
|
|
14273
|
-
|
|
14274
|
-
if (urlState === undefined && !rejectedAsForeign && enabled && legacyValueFromUrl) {
|
|
14275
|
-
urlState = tryDecode(legacyValueFromUrl).state;
|
|
14248
|
+
if (urlState === undefined && enabled && legacyValueFromUrl) {
|
|
14249
|
+
urlState = tryDecode(legacyValueFromUrl);
|
|
14276
14250
|
if (urlState !== undefined) {
|
|
14277
14251
|
usedLegacyUrl = true;
|
|
14278
14252
|
}
|
|
@@ -14284,7 +14258,6 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14284
14258
|
initialState: urlState,
|
|
14285
14259
|
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14286
14260
|
needsUrlRestore: usedLegacyUrl,
|
|
14287
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14288
14261
|
};
|
|
14289
14262
|
}
|
|
14290
14263
|
const storageState = readStoredState();
|
|
@@ -14295,7 +14268,6 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14295
14268
|
initialState: { ...storageState, ...urlState },
|
|
14296
14269
|
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14297
14270
|
needsUrlRestore: usedLegacyUrl,
|
|
14298
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14299
14271
|
};
|
|
14300
14272
|
}
|
|
14301
14273
|
if (urlState !== undefined) {
|
|
@@ -14303,7 +14275,6 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14303
14275
|
initialState: urlState,
|
|
14304
14276
|
canonicalUrlHasValidState: canonicalUrlValid,
|
|
14305
14277
|
needsUrlRestore: usedLegacyUrl,
|
|
14306
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14307
14278
|
};
|
|
14308
14279
|
}
|
|
14309
14280
|
if (storageState !== undefined) {
|
|
@@ -14311,16 +14282,14 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14311
14282
|
initialState: storageState,
|
|
14312
14283
|
canonicalUrlHasValidState: false,
|
|
14313
14284
|
needsUrlRestore: true,
|
|
14314
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14315
14285
|
};
|
|
14316
14286
|
}
|
|
14317
14287
|
return {
|
|
14318
14288
|
initialState: undefined,
|
|
14319
14289
|
canonicalUrlHasValidState: false,
|
|
14320
14290
|
needsUrlRestore: false,
|
|
14321
|
-
urlRejectedAsForeign: rejectedAsForeign,
|
|
14322
14291
|
};
|
|
14323
|
-
}, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead
|
|
14292
|
+
}, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
|
|
14324
14293
|
// Every URL write goes through here so the ownership stamp is applied
|
|
14325
14294
|
// uniformly (user edits, storage→URL restore, legacy search-param migration).
|
|
14326
14295
|
const encodeForUrl = useCallback((state) => {
|
|
@@ -14347,7 +14316,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14347
14316
|
transformFromUrlValue: fromUrlValueRef.current,
|
|
14348
14317
|
}));
|
|
14349
14318
|
}, [persistedStateReadKey, readPersistedState]);
|
|
14350
|
-
const { initialState, canonicalUrlHasValidState, needsUrlRestore
|
|
14319
|
+
const { initialState, canonicalUrlHasValidState, needsUrlRestore } = persistedStateSnapshot;
|
|
14351
14320
|
// Late-hydration recovery: TanStack Router's `useSearch` can return an
|
|
14352
14321
|
// empty object on the very first render of a route that's still in a
|
|
14353
14322
|
// loader-driven pending phase. The `useState` initializer above runs
|
|
@@ -14358,13 +14327,11 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14358
14327
|
// `onMigrated`. We re-read here when:
|
|
14359
14328
|
// - the snapshot has nothing usable yet (`initialState === undefined`
|
|
14360
14329
|
// AND the canonical URL slot has no valid value), and
|
|
14361
|
-
// - a legacy value has just appeared
|
|
14362
|
-
// - the canonical slot was not rejected (a legacy value next to a foreign
|
|
14363
|
-
// or unstamped-over-saved hash is ignored by the read anyway).
|
|
14330
|
+
// - a legacy value has just appeared.
|
|
14364
14331
|
// Once `initialState` is populated, this effect's guard short-circuits
|
|
14365
14332
|
// and the rest of the lifecycle (restore + migration) takes over.
|
|
14366
14333
|
useEffect(() => {
|
|
14367
|
-
if (initialState !== undefined || canonicalUrlHasValidState
|
|
14334
|
+
if (initialState !== undefined || canonicalUrlHasValidState) {
|
|
14368
14335
|
return;
|
|
14369
14336
|
}
|
|
14370
14337
|
if (legacySearchValue === undefined) {
|
|
@@ -14376,7 +14343,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14376
14343
|
validateState: validateRef.current,
|
|
14377
14344
|
transformFromUrlValue: fromUrlValueRef.current,
|
|
14378
14345
|
}));
|
|
14379
|
-
}, [legacySearchValue, initialState, canonicalUrlHasValidState,
|
|
14346
|
+
}, [legacySearchValue, initialState, canonicalUrlHasValidState, readPersistedState]);
|
|
14380
14347
|
const lastPersistedRef = useRef(initialState);
|
|
14381
14348
|
useEffect(() => {
|
|
14382
14349
|
lastPersistedRef.current = initialState;
|
|
@@ -14407,16 +14374,13 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14407
14374
|
// suppress `onMigrated`, otherwise a valid legacy value would be lost
|
|
14408
14375
|
// on the next reload (see `canonicalUrlHasValidState` for the same
|
|
14409
14376
|
// reasoning in the restore effect above).
|
|
14410
|
-
// A rejected hash counts as "already has a value" too. The legacy param
|
|
14411
|
-
// beside a foreign stamp or an unstamped hash that lost to saved state is
|
|
14412
|
-
// stripped without being written into this user's storage or hash.
|
|
14413
14377
|
useMigrateLegacySearchParam({
|
|
14414
14378
|
key,
|
|
14415
14379
|
enabled: hashActive,
|
|
14416
14380
|
decode,
|
|
14417
14381
|
fromUrlValue,
|
|
14418
14382
|
validate,
|
|
14419
|
-
hashAlreadyHasValue: canonicalUrlHasValidState
|
|
14383
|
+
hashAlreadyHasValue: canonicalUrlHasValidState,
|
|
14420
14384
|
onMigrated: useCallback((migrated) => {
|
|
14421
14385
|
if (dequal(lastPersistedRef.current, migrated)) {
|
|
14422
14386
|
return;
|
|
@@ -14452,6 +14416,101 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
|
|
|
14452
14416
|
return useMemo(() => ({ initialState, persistState, clearState }), [initialState, persistState, clearState]);
|
|
14453
14417
|
};
|
|
14454
14418
|
|
|
14419
|
+
/**
|
|
14420
|
+
* Enumerates localStorage keys. Kept local so this file's exemption from the
|
|
14421
|
+
* no-direct-storage-access rule is centralised, as in `usePersistedState`.
|
|
14422
|
+
*/
|
|
14423
|
+
/* eslint-disable no-restricted-globals */
|
|
14424
|
+
const localStorageKeys = () => {
|
|
14425
|
+
const keys = [];
|
|
14426
|
+
for (let index = 0; index < localStorage.length; index++) {
|
|
14427
|
+
const key = localStorage.key(index);
|
|
14428
|
+
if (key !== null) {
|
|
14429
|
+
keys.push(key);
|
|
14430
|
+
}
|
|
14431
|
+
}
|
|
14432
|
+
return keys;
|
|
14433
|
+
};
|
|
14434
|
+
/* eslint-enable no-restricted-globals */
|
|
14435
|
+
/**
|
|
14436
|
+
* Whether some *other* identity that has used this browser wrote the URL
|
|
14437
|
+
* value: `owner` is the fingerprint of a user id that has state stored under
|
|
14438
|
+
* `key` in this localStorage. Storage keys are `<key>-<clientSideUserId>`
|
|
14439
|
+
* (see `useStorageKey`), so the ids can be recovered from the keys.
|
|
14440
|
+
*
|
|
14441
|
+
* A value with no owner, or one written on another device (no matching
|
|
14442
|
+
* storage here), is not foreign: that is a link someone shared.
|
|
14443
|
+
*/
|
|
14444
|
+
const isOwnedByOtherLocalIdentity = (key, owner, currentOwner) => {
|
|
14445
|
+
if (owner === undefined || owner === currentOwner) {
|
|
14446
|
+
return false;
|
|
14447
|
+
}
|
|
14448
|
+
const prefix = `${key}-`;
|
|
14449
|
+
try {
|
|
14450
|
+
return localStorageKeys()
|
|
14451
|
+
.filter(storageKey => storageKey.startsWith(prefix))
|
|
14452
|
+
.some(storageKey => identityFingerprint(storageKey.slice(prefix.length)) === owner);
|
|
14453
|
+
}
|
|
14454
|
+
catch {
|
|
14455
|
+
return false;
|
|
14456
|
+
}
|
|
14457
|
+
};
|
|
14458
|
+
/**
|
|
14459
|
+
* Removes a URL's hash entries that were written by another account on this
|
|
14460
|
+
* browser, so a login redirect does not hand one account's persisted state
|
|
14461
|
+
* (e.g. an asset list layout) to the next account that signs in.
|
|
14462
|
+
*
|
|
14463
|
+
* `usePersistedState` stamps every hash it writes with a fingerprint of the
|
|
14464
|
+
* writing `clientSideUserId` (see `stampUrlWithIdentity`). On load the hash
|
|
14465
|
+
* always wins, so links and bookmarks work for anyone. The URL also survives
|
|
14466
|
+
* the login redirect (`redirectToLogin(location.href)`), which is how the
|
|
14467
|
+
* previous account's layout reached the next account on a shared PC
|
|
14468
|
+
* (GLU-2396). This hook is applied at that boundary only.
|
|
14469
|
+
*
|
|
14470
|
+
* An entry is removed when it is stamped by a *different* identity that has
|
|
14471
|
+
* state stored in this browser under the same key. Unstamped entries, entries
|
|
14472
|
+
* stamped by the reader, and entries whose author has no storage here (a
|
|
14473
|
+
* link shared from another device) are kept.
|
|
14474
|
+
*
|
|
14475
|
+
* @returns {(url: string | undefined, clientSideUserId: string | undefined) => string}
|
|
14476
|
+
* Returns `url` with the foreign entries removed. A missing url comes back
|
|
14477
|
+
* as `""`. The URL is returned as-is when it has no hash or when
|
|
14478
|
+
* `clientSideUserId` is unknown.
|
|
14479
|
+
*/
|
|
14480
|
+
const useStripForeignIdentityHash = () => {
|
|
14481
|
+
const { decode } = useCustomEncoding();
|
|
14482
|
+
return useCallback((url, clientSideUserId) => {
|
|
14483
|
+
if (typeof url !== "string") {
|
|
14484
|
+
return "";
|
|
14485
|
+
}
|
|
14486
|
+
const hashIndex = url.indexOf("#");
|
|
14487
|
+
if (clientSideUserId === undefined || hashIndex === -1) {
|
|
14488
|
+
return url;
|
|
14489
|
+
}
|
|
14490
|
+
const base = url.slice(0, hashIndex);
|
|
14491
|
+
const hash = url.slice(hashIndex);
|
|
14492
|
+
const currentOwner = identityFingerprint(clientSideUserId);
|
|
14493
|
+
let next = hash;
|
|
14494
|
+
for (const key of listHashKeys(hash)) {
|
|
14495
|
+
const raw = readHashKey(hash, key);
|
|
14496
|
+
if (raw === undefined) {
|
|
14497
|
+
continue;
|
|
14498
|
+
}
|
|
14499
|
+
let owner;
|
|
14500
|
+
try {
|
|
14501
|
+
owner = unwrapUrlOwner(decode(raw)).owner;
|
|
14502
|
+
}
|
|
14503
|
+
catch {
|
|
14504
|
+
continue;
|
|
14505
|
+
}
|
|
14506
|
+
if (isOwnedByOtherLocalIdentity(key, owner, currentOwner)) {
|
|
14507
|
+
next = writeHashKey(next, key, undefined);
|
|
14508
|
+
}
|
|
14509
|
+
}
|
|
14510
|
+
return `${base}${next}`;
|
|
14511
|
+
}, [decode]);
|
|
14512
|
+
};
|
|
14513
|
+
|
|
14455
14514
|
const OVERSCAN = 10;
|
|
14456
14515
|
const DEFAULT_ROW_HEIGHT = 50;
|
|
14457
14516
|
/**
|
|
@@ -15682,4 +15741,4 @@ const useWindowActivity = ({ onFocus, onBlur, skip = false } = { onBlur: undefin
|
|
|
15682
15741
|
*/
|
|
15683
15742
|
setupLibraryTranslations();
|
|
15684
15743
|
|
|
15685
|
-
export { Alert, Badge, Breadcrumb, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyButton, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, IdentityCard, IdentityCardSkeleton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, LabeledValue, LabeledValueList, List, ListItem, MAX_HASH_LENGTH, MAX_URL_LENGTH, MenuContent, MenuDivider, MenuItem, MenuTree, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SidebarContentLayout, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHashParamSync, useHold, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMenuSearchField, useMenuTree, useMergeRefs, useModifierKey, useOptionalPopoverContext, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
|
|
15744
|
+
export { Alert, Badge, Breadcrumb, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyButton, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, IdentityCard, IdentityCardSkeleton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, LabeledValue, LabeledValueList, List, ListItem, MAX_HASH_LENGTH, MAX_URL_LENGTH, MenuContent, MenuDivider, MenuItem, MenuTree, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SHEET_TRANSITION_DURATION, SHEET_TRANSITION_DURATION_MS, SHEET_TRANSITION_EASING, SectionHeader, SegmentedValueBar, Sheet, Sidebar, SidebarContentLayout, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGridAreas, useHashParamSync, useHold, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMenuSearchField, useMenuTree, useMergeRefs, useModifierKey, useOptionalPopoverContext, useOverflowBorder, useOverflowItems, usePersistedState, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSearchParamSync, useSelfUpdatingRef, useSessionStorage, useSessionStorageReducer, useSheet, useSheetSnap, useStorageKey, useStripForeignIdentityHash, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-components",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.3-alpha-b0d834f66ac.0",
|
|
4
4
|
"repository": "https://github.com/Trackunit/manager",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"migrations": "./migrations.json",
|
|
@@ -9,21 +9,21 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@radix-ui/react-slot": "^1.2.3",
|
|
12
|
-
"@radix-ui/react-tabs": "^1.1.
|
|
12
|
+
"@radix-ui/react-tabs": "^1.1.21",
|
|
13
13
|
"react-helmet-async": "^1.3.0",
|
|
14
14
|
"@floating-ui/react": "^0.26.25",
|
|
15
15
|
"string-ts": "^2.0.0",
|
|
16
16
|
"tailwind-merge": "^2.0.0",
|
|
17
|
-
"@trackunit/ui-design-tokens": "1.17.
|
|
18
|
-
"@trackunit/css-class-variance-utilities": "2.2.
|
|
19
|
-
"@trackunit/shared-utils": "1.18.
|
|
20
|
-
"@trackunit/ui-icons": "1.16.
|
|
21
|
-
"es-toolkit": "^1.
|
|
22
|
-
"@tanstack/react-virtual": "^3.14.
|
|
17
|
+
"@trackunit/ui-design-tokens": "1.17.3-alpha-b0d834f66ac.0",
|
|
18
|
+
"@trackunit/css-class-variance-utilities": "2.2.3-alpha-b0d834f66ac.0",
|
|
19
|
+
"@trackunit/shared-utils": "1.18.3-alpha-b0d834f66ac.0",
|
|
20
|
+
"@trackunit/ui-icons": "1.16.3-alpha-b0d834f66ac.0",
|
|
21
|
+
"es-toolkit": "^1.52.0",
|
|
22
|
+
"@tanstack/react-virtual": "^3.14.13",
|
|
23
23
|
"dequal": "^2.0.3",
|
|
24
|
-
"fflate": "^0.8.
|
|
24
|
+
"fflate": "^0.8.3",
|
|
25
25
|
"zod": "^3.25.76",
|
|
26
|
-
"@trackunit/i18n-library-translation": "2.7.
|
|
26
|
+
"@trackunit/i18n-library-translation": "2.7.3-alpha-b0d834f66ac.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"react": "^19.0.0",
|
|
@@ -46,6 +46,14 @@
|
|
|
46
46
|
* entry whose token has no `=` is treated as foreign content and ignored.
|
|
47
47
|
*/
|
|
48
48
|
export declare const readHashKey: (hash: string, key: string) => string | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Lists the key of every named entry in a URL fragment, in order of first
|
|
51
|
+
* appearance. Foreign tokens (no `=`) are skipped and duplicates collapse.
|
|
52
|
+
*
|
|
53
|
+
* @param hash - The fragment string, with or without the leading `#`.
|
|
54
|
+
* @returns {Array<string>} The distinct entry keys.
|
|
55
|
+
*/
|
|
56
|
+
export declare const listHashKeys: (hash: string) => Array<string>;
|
|
49
57
|
/**
|
|
50
58
|
* Returns a new fragment string with the named entry written, replaced,
|
|
51
59
|
* or removed. Foreign tokens are preserved in their original order; the
|
|
@@ -41,23 +41,18 @@ type UsePersistedStateOptions<TState> = {
|
|
|
41
41
|
readonly urlLocation?: UrlPersistenceLocation;
|
|
42
42
|
/**
|
|
43
43
|
* When `true`, every URL write is stamped with a fingerprint of
|
|
44
|
-
* `clientSideUserId
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* back over the stale URL entry.
|
|
44
|
+
* `clientSideUserId` (`{ o: <fingerprint>, v: <payload> }`). Reads are
|
|
45
|
+
* unaffected: the URL still wins on load for everyone, so links and
|
|
46
|
+
* bookmarks render for whoever opens them.
|
|
48
47
|
*
|
|
49
|
-
* The
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* state
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* another device) still render. An unstamped value written before this
|
|
56
|
-
* option existed renders only when the current user has nothing saved;
|
|
57
|
-
* otherwise their localStorage wins and the hash is restamped, because a
|
|
58
|
-
* bookmark or history entry is never rewritten on its own. Default `false`.
|
|
48
|
+
* The stamp exists for the login redirect. The URL is shared by every
|
|
49
|
+
* account that signs in from the same browser and `redirectToLogin`
|
|
50
|
+
* carries it through to the next account, which is how one account's
|
|
51
|
+
* state replaced another's on a shared PC (GLU-2396). At that boundary
|
|
52
|
+
* `useStripForeignIdentityHash` drops entries stamped by a different
|
|
53
|
+
* identity that has state stored in this browser. Default `false`.
|
|
59
54
|
*/
|
|
60
|
-
readonly
|
|
55
|
+
readonly stampUrlWithIdentity?: boolean;
|
|
61
56
|
};
|
|
62
57
|
type UsePersistedStateReturn<TState> = {
|
|
63
58
|
readonly initialState: TState | undefined;
|
|
@@ -107,8 +102,8 @@ type UsePersistedStateReturn<TState> = {
|
|
|
107
102
|
* state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
|
|
108
103
|
* omits fields so they still survive via localStorage. Default `false`.
|
|
109
104
|
* @param options.urlLocation - Which URL slot backs the value (`"search"` or `"hash"`). Default `"search"`.
|
|
110
|
-
* @param options.
|
|
111
|
-
*
|
|
105
|
+
* @param options.stampUrlWithIdentity - Stamp URL writes with the current identity so the login
|
|
106
|
+
* redirect can drop entries left by another account on this browser. Default `false`.
|
|
112
107
|
*/
|
|
113
|
-
export declare const usePersistedState: <TState extends object>({ key, validate, serialize, toUrlValue, fromUrlValue, enabled, localStorageEnabled, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead, urlLocation,
|
|
108
|
+
export declare const usePersistedState: <TState extends object>({ key, validate, serialize, toUrlValue, fromUrlValue, enabled, localStorageEnabled, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead, urlLocation, stampUrlWithIdentity, }: UsePersistedStateOptions<TState>) => UsePersistedStateReturn<TState>;
|
|
114
109
|
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes a URL's hash entries that were written by another account on this
|
|
3
|
+
* browser, so a login redirect does not hand one account's persisted state
|
|
4
|
+
* (e.g. an asset list layout) to the next account that signs in.
|
|
5
|
+
*
|
|
6
|
+
* `usePersistedState` stamps every hash it writes with a fingerprint of the
|
|
7
|
+
* writing `clientSideUserId` (see `stampUrlWithIdentity`). On load the hash
|
|
8
|
+
* always wins, so links and bookmarks work for anyone. The URL also survives
|
|
9
|
+
* the login redirect (`redirectToLogin(location.href)`), which is how the
|
|
10
|
+
* previous account's layout reached the next account on a shared PC
|
|
11
|
+
* (GLU-2396). This hook is applied at that boundary only.
|
|
12
|
+
*
|
|
13
|
+
* An entry is removed when it is stamped by a *different* identity that has
|
|
14
|
+
* state stored in this browser under the same key. Unstamped entries, entries
|
|
15
|
+
* stamped by the reader, and entries whose author has no storage here (a
|
|
16
|
+
* link shared from another device) are kept.
|
|
17
|
+
*
|
|
18
|
+
* @returns {(url: string | undefined, clientSideUserId: string | undefined) => string}
|
|
19
|
+
* Returns `url` with the foreign entries removed. A missing url comes back
|
|
20
|
+
* as `""`. The URL is returned as-is when it has no hash or when
|
|
21
|
+
* `clientSideUserId` is unknown.
|
|
22
|
+
*/
|
|
23
|
+
export declare const useStripForeignIdentityHash: () => ((url: string | undefined, clientSideUserId: string | undefined) => string);
|
package/src/index.d.ts
CHANGED
|
@@ -129,6 +129,7 @@ export * from "./hooks/persistence/useHashParamSync";
|
|
|
129
129
|
export * from "./hooks/persistence/usePersistedState";
|
|
130
130
|
export * from "./hooks/persistence/useSearchParamSync";
|
|
131
131
|
export * from "./hooks/persistence/useStorageKey";
|
|
132
|
+
export * from "./hooks/persistence/useStripForeignIdentityHash";
|
|
132
133
|
export * from "./hooks/useBidirectionalScroll";
|
|
133
134
|
export * from "./hooks/useClickOutside";
|
|
134
135
|
export * from "./hooks/useContainerBreakpoints";
|