@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 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.scopeUrlToIdentity - Stamp URL writes with the current identity and ignore
14161
- * URL values written by another identity that has state in this browser. Default `false`.
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", scopeUrlToIdentity = false, }) => {
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 scoping is off): writes are then
14167
- // unstamped and no URL value is treated as foreign. The read below re-runs
14168
- // once `storageKey` picks up the resolved identity.
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 { owner, value } = unwrapUrlOwner(decode(raw));
14217
+ const { value } = unwrapUrlOwner(decode(raw));
14228
14218
  const transformed = transformFromUrlValue ? transformFromUrlValue(value) : value;
14229
- return { state: validateState(transformed), owner };
14219
+ return validateState(transformed);
14230
14220
  }
14231
14221
  catch {
14232
- return { state: undefined, owner: undefined };
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
- const decoded = tryDecode(valueFromUrl);
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
- // A legacy `?<key>=...` param next to a foreign hash came from the same
14274
- // other-account session, so it must not stand in for the rejected hash.
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, scopeUrlToIdentity, key, currentUrlOwner]);
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, urlRejectedAsForeign } = persistedStateSnapshot;
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, and
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 || urlRejectedAsForeign) {
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, urlRejectedAsForeign, readPersistedState]);
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 || urlRejectedAsForeign,
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.scopeUrlToIdentity - Stamp URL writes with the current identity and ignore
14160
- * URL values written by another identity that has state in this browser. Default `false`.
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", scopeUrlToIdentity = false, }) => {
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 scoping is off): writes are then
14166
- // unstamped and no URL value is treated as foreign. The read below re-runs
14167
- // once `storageKey` picks up the resolved identity.
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 { owner, value } = unwrapUrlOwner(decode(raw));
14216
+ const { value } = unwrapUrlOwner(decode(raw));
14227
14217
  const transformed = transformFromUrlValue ? transformFromUrlValue(value) : value;
14228
- return { state: validateState(transformed), owner };
14218
+ return validateState(transformed);
14229
14219
  }
14230
14220
  catch {
14231
- return { state: undefined, owner: undefined };
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
- const decoded = tryDecode(valueFromUrl);
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
- // A legacy `?<key>=...` param next to a foreign hash came from the same
14273
- // other-account session, so it must not stand in for the rejected hash.
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, scopeUrlToIdentity, key, currentUrlOwner]);
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, urlRejectedAsForeign } = persistedStateSnapshot;
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, and
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 || urlRejectedAsForeign) {
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, urlRejectedAsForeign, readPersistedState]);
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 || urlRejectedAsForeign,
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.2-alpha-34e3e456eda.0",
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.13",
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.2-alpha-34e3e456eda.0",
18
- "@trackunit/css-class-variance-utilities": "2.2.2-alpha-34e3e456eda.0",
19
- "@trackunit/shared-utils": "1.18.2-alpha-34e3e456eda.0",
20
- "@trackunit/ui-icons": "1.16.2-alpha-34e3e456eda.0",
21
- "es-toolkit": "^1.39.10",
22
- "@tanstack/react-virtual": "^3.14.3",
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.2",
24
+ "fflate": "^0.8.3",
25
25
  "zod": "^3.25.76",
26
- "@trackunit/i18n-library-translation": "2.7.2-alpha-34e3e456eda.0"
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`, and a URL value stamped by a *different* identity
45
- * that also has state stored in this browser (under the same `key`) is
46
- * ignored on read — the current user's localStorage wins and is written
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 URL is shared by every account that signs in from the same browser
50
- * (history, bookmarks, login redirects carry it along) and it takes
51
- * precedence over per-user storage, so without this the previous account's
52
- * state replaces the next account's saved state (GLU-2396).
53
- *
54
- * Values stamped by an identity with no storage here (a link shared from
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 scopeUrlToIdentity?: boolean;
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.scopeUrlToIdentity - Stamp URL writes with the current identity and ignore
111
- * URL values written by another identity that has state in this browser. Default `false`.
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, scopeUrlToIdentity, }: UsePersistedStateOptions<TState>) => UsePersistedStateReturn<TState>;
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";