@trackunit/react-components 3.4.2 → 3.4.5

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/README.md CHANGED
@@ -5,6 +5,8 @@ This library is exposed publicly for use in the the Trackunit [Iris App SDK](ht
5
5
 
6
6
  To browse all available components visit the [Design System](https://design.iris.trackunit.com/).
7
7
 
8
+ AI agents should use the machine-readable index at [`/llms.txt`](https://design.iris.trackunit.com/llms.txt) instead of the Storybook HTML; it links to per-component docs at `/ai?storyId=<id>`.
9
+
8
10
  For more info and a full guide on Iris App SDK Development, please visit our [Developer Hub](https://developers.trackunit.com/).
9
11
 
10
12
  ## Development
package/index.cjs.js CHANGED
@@ -13696,6 +13696,76 @@ const readPrimitiveSearchParam = (search, key) => {
13696
13696
  return String(parsedValue.data);
13697
13697
  };
13698
13698
 
13699
+ /**
13700
+ * Pure utilities for stamping a URL-persisted value with the identity that
13701
+ * wrote it.
13702
+ *
13703
+ * A URL (hash or search param) is shared by every account that uses the same
13704
+ * browser: it survives login redirects, history and bookmarks, and it takes
13705
+ * precedence over per-user localStorage when a page loads. Without a stamp
13706
+ * there is no way to tell "a colleague shared this link" from "the previous
13707
+ * account on this PC left its layout in the address bar" — and the second
13708
+ * case silently replaces the current user's saved state (GLU-2396).
13709
+ *
13710
+ * The stamp is a short, non-reversible fingerprint of `clientSideUserId`.
13711
+ * It is not a secret and not a security boundary; it only lets a reader
13712
+ * recognise a value written by a *different* identity on the same device.
13713
+ */
13714
+ /**
13715
+ * Wire shape of a stamped URL value: `{ o: <fingerprint>, v: <payload> }`.
13716
+ * Values written before stamping existed are bare payloads and decode as
13717
+ * unowned (see {@link unwrapUrlOwner}).
13718
+ */
13719
+ const stampedUrlValueSchema = zod.z.object({
13720
+ o: zod.z.string(),
13721
+ v: zod.z.unknown(),
13722
+ });
13723
+ const FNV_OFFSET_BASIS = 0x811c9dc5;
13724
+ const FNV_PRIME = 0x01000193;
13725
+ /**
13726
+ * 32-bit FNV-1a of `identity`, rendered in base36 (≤ 7 chars).
13727
+ *
13728
+ * Synchronous and dependency-free so it can run inside a `useState`
13729
+ * initializer. Collisions are possible in theory but irrelevant here: a
13730
+ * collision can only make a foreign value look like the reader's own, which
13731
+ * is today's behaviour for every value.
13732
+ *
13733
+ * @param identity - The client-side user id to fingerprint.
13734
+ * @returns {string} A short stable fingerprint of `identity`.
13735
+ */
13736
+ const identityFingerprint = (identity) => {
13737
+ let hash = FNV_OFFSET_BASIS;
13738
+ for (let index = 0; index < identity.length; index++) {
13739
+ hash ^= identity.charCodeAt(index);
13740
+ hash = Math.imul(hash, FNV_PRIME) >>> 0;
13741
+ }
13742
+ return hash.toString(36);
13743
+ };
13744
+ /**
13745
+ * Wraps a URL payload with the fingerprint of the identity writing it.
13746
+ *
13747
+ * @param value - The payload that would otherwise be encoded into the URL.
13748
+ * @param owner - Fingerprint of the writing identity (see {@link identityFingerprint}).
13749
+ * @returns {StampedUrlValue} The payload wrapped in its ownership envelope.
13750
+ */
13751
+ const wrapUrlOwner = (value, owner) => ({ o: owner, v: value });
13752
+ /**
13753
+ * Splits a decoded URL value into its owner fingerprint and payload.
13754
+ * Bare (pre-stamping) payloads come back with `owner: undefined`.
13755
+ *
13756
+ * @param decoded - The decoded URL value, stamped or bare.
13757
+ * @returns {{ owner: string | undefined; value: unknown }} The owner fingerprint (if any) and the payload.
13758
+ */
13759
+ const unwrapUrlOwner = (decoded) => {
13760
+ const stamped = stampedUrlValueSchema.safeParse(decoded);
13761
+ // `z.unknown()` accepts a missing key, so `{ o: "x" }` parses without `v`.
13762
+ // Such an object is a payload of its own, not an envelope; the `in` check
13763
+ // is what keeps it unowned. Do not remove it as redundant.
13764
+ return stamped.success && "v" in stamped.data
13765
+ ? { owner: stamped.data.o, value: stamped.data.v }
13766
+ : { owner: undefined, value: decoded };
13767
+ };
13768
+
13699
13769
  /**
13700
13770
  * One-shot migration: if the URL still carries a legacy `?<key>=...`
13701
13771
  * search param from before this hook moved table-style state to the
@@ -14008,6 +14078,41 @@ const rawLocalStorage = {
14008
14078
  read: (key) => localStorage.getItem(key),
14009
14079
  write: (key, value) => localStorage.setItem(key, value),
14010
14080
  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
+ }
14011
14116
  };
14012
14117
  /**
14013
14118
  * Generic persistence hook that loads state from a URL slot (search param
@@ -14052,9 +14157,16 @@ const rawLocalStorage = {
14052
14157
  * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
14053
14158
  * omits fields so they still survive via localStorage. Default `false`.
14054
14159
  * @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`.
14055
14162
  */
14056
- const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", }) => {
14163
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", scopeUrlToIdentity = false, }) => {
14057
14164
  const { encode, decode } = useCustomEncoding();
14165
+ // 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]);
14058
14170
  const searchActive = enabled && urlLocation === "search";
14059
14171
  const hashActive = enabled && urlLocation === "hash";
14060
14172
  // Both URL-sync hooks are mounted unconditionally to satisfy the Rules of
@@ -14112,9 +14224,24 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14112
14224
  const readPersistedState = react.useCallback(({ valueFromUrl, legacyValueFromUrl, validateState, transformFromUrlValue, }) => {
14113
14225
  const tryDecode = (raw) => {
14114
14226
  try {
14115
- const decoded = decode(raw);
14116
- const transformed = transformFromUrlValue ? transformFromUrlValue(decoded) : decoded;
14117
- return validateState(transformed);
14227
+ const { owner, value } = unwrapUrlOwner(decode(raw));
14228
+ const transformed = transformFromUrlValue ? transformFromUrlValue(value) : value;
14229
+ return { state: validateState(transformed), owner };
14230
+ }
14231
+ catch {
14232
+ return { state: undefined, owner: undefined };
14233
+ }
14234
+ };
14235
+ const readStoredState = () => {
14236
+ if (!localStorageEnabled) {
14237
+ return undefined;
14238
+ }
14239
+ try {
14240
+ const raw = rawLocalStorage.read(storageKey);
14241
+ if (!raw) {
14242
+ return undefined;
14243
+ }
14244
+ return validateState(sharedUtils.storageSerializer.deserialize(raw));
14118
14245
  }
14119
14246
  catch {
14120
14247
  return undefined;
@@ -14123,14 +14250,30 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14123
14250
  let urlState;
14124
14251
  let canonicalUrlValid = false;
14125
14252
  let usedLegacyUrl = false;
14253
+ let rejectedAsForeign = false;
14126
14254
  if (enabled && valueFromUrl) {
14127
- urlState = tryDecode(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;
14128
14269
  if (urlState !== undefined) {
14129
14270
  canonicalUrlValid = true;
14130
14271
  }
14131
14272
  }
14132
- if (urlState === undefined && enabled && legacyValueFromUrl) {
14133
- urlState = tryDecode(legacyValueFromUrl);
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;
14134
14277
  if (urlState !== undefined) {
14135
14278
  usedLegacyUrl = true;
14136
14279
  }
@@ -14142,21 +14285,10 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14142
14285
  initialState: urlState,
14143
14286
  canonicalUrlHasValidState: canonicalUrlValid,
14144
14287
  needsUrlRestore: usedLegacyUrl,
14288
+ urlRejectedAsForeign: rejectedAsForeign,
14145
14289
  };
14146
14290
  }
14147
- let storageState;
14148
- if (localStorageEnabled) {
14149
- try {
14150
- const raw = rawLocalStorage.read(storageKey);
14151
- if (raw) {
14152
- const parsed = sharedUtils.storageSerializer.deserialize(raw);
14153
- storageState = validateState(parsed);
14154
- }
14155
- }
14156
- catch {
14157
- // no valid stored state
14158
- }
14159
- }
14291
+ const storageState = readStoredState();
14160
14292
  if (urlState !== undefined && storageState !== undefined) {
14161
14293
  // Shallow merge — URL fields take precedence; storage fills in
14162
14294
  // whatever the URL omits (e.g. fields stripped by `toUrlValue`).
@@ -14164,6 +14296,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14164
14296
  initialState: { ...storageState, ...urlState },
14165
14297
  canonicalUrlHasValidState: canonicalUrlValid,
14166
14298
  needsUrlRestore: usedLegacyUrl,
14299
+ urlRejectedAsForeign: rejectedAsForeign,
14167
14300
  };
14168
14301
  }
14169
14302
  if (urlState !== undefined) {
@@ -14171,6 +14304,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14171
14304
  initialState: urlState,
14172
14305
  canonicalUrlHasValidState: canonicalUrlValid,
14173
14306
  needsUrlRestore: usedLegacyUrl,
14307
+ urlRejectedAsForeign: rejectedAsForeign,
14174
14308
  };
14175
14309
  }
14176
14310
  if (storageState !== undefined) {
@@ -14178,14 +14312,22 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14178
14312
  initialState: storageState,
14179
14313
  canonicalUrlHasValidState: false,
14180
14314
  needsUrlRestore: true,
14315
+ urlRejectedAsForeign: rejectedAsForeign,
14181
14316
  };
14182
14317
  }
14183
14318
  return {
14184
14319
  initialState: undefined,
14185
14320
  canonicalUrlHasValidState: false,
14186
14321
  needsUrlRestore: false,
14322
+ urlRejectedAsForeign: rejectedAsForeign,
14187
14323
  };
14188
- }, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
14324
+ }, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead, scopeUrlToIdentity, key, currentUrlOwner]);
14325
+ // Every URL write goes through here so the ownership stamp is applied
14326
+ // uniformly (user edits, storage→URL restore, legacy search-param migration).
14327
+ const encodeForUrl = react.useCallback((state) => {
14328
+ const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
14329
+ return encode(currentUrlOwner === undefined ? urlValue : wrapUrlOwner(urlValue, currentUrlOwner));
14330
+ }, [encode, currentUrlOwner]);
14189
14331
  const [persistedStateSnapshot, setPersistedStateSnapshot] = react.useState(() => readPersistedState({
14190
14332
  valueFromUrl: searchValue,
14191
14333
  legacyValueFromUrl: legacySearchValue,
@@ -14206,7 +14348,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14206
14348
  transformFromUrlValue: fromUrlValueRef.current,
14207
14349
  }));
14208
14350
  }, [persistedStateReadKey, readPersistedState]);
14209
- const { initialState, canonicalUrlHasValidState, needsUrlRestore } = persistedStateSnapshot;
14351
+ const { initialState, canonicalUrlHasValidState, needsUrlRestore, urlRejectedAsForeign } = persistedStateSnapshot;
14210
14352
  // Late-hydration recovery: TanStack Router's `useSearch` can return an
14211
14353
  // empty object on the very first render of a route that's still in a
14212
14354
  // loader-driven pending phase. The `useState` initializer above runs
@@ -14217,11 +14359,13 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14217
14359
  // `onMigrated`. We re-read here when:
14218
14360
  // - the snapshot has nothing usable yet (`initialState === undefined`
14219
14361
  // AND the canonical URL slot has no valid value), and
14220
- // - a legacy value has just appeared.
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).
14221
14365
  // Once `initialState` is populated, this effect's guard short-circuits
14222
14366
  // and the rest of the lifecycle (restore + migration) takes over.
14223
14367
  react.useEffect(() => {
14224
- if (initialState !== undefined || canonicalUrlHasValidState) {
14368
+ if (initialState !== undefined || canonicalUrlHasValidState || urlRejectedAsForeign) {
14225
14369
  return;
14226
14370
  }
14227
14371
  if (legacySearchValue === undefined) {
@@ -14233,7 +14377,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14233
14377
  validateState: validateRef.current,
14234
14378
  transformFromUrlValue: fromUrlValueRef.current,
14235
14379
  }));
14236
- }, [legacySearchValue, initialState, canonicalUrlHasValidState, readPersistedState]);
14380
+ }, [legacySearchValue, initialState, canonicalUrlHasValidState, urlRejectedAsForeign, readPersistedState]);
14237
14381
  const lastPersistedRef = react.useRef(initialState);
14238
14382
  react.useEffect(() => {
14239
14383
  lastPersistedRef.current = initialState;
@@ -14255,10 +14399,8 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14255
14399
  return;
14256
14400
  }
14257
14401
  hasRestoredUrlRef.current = true;
14258
- const urlValue = toUrlValueRef.current ? toUrlValueRef.current(initialState) : initialState;
14259
- const encoded = encode(urlValue);
14260
- updateSearchParam(encoded, { replace: true });
14261
- }, [enabled, needsUrlRestore, initialState, canonicalUrlHasValidState, updateSearchParam, encode]);
14402
+ updateSearchParam(encodeForUrl(initialState), { replace: true });
14403
+ }, [enabled, needsUrlRestore, initialState, canonicalUrlHasValidState, updateSearchParam, encodeForUrl]);
14262
14404
  // Strip the legacy search param so reloads come from the canonical hash
14263
14405
  // slot and the URL stays tidy. The hook is a no-op outside hash mode.
14264
14406
  // `hashAlreadyHasValue` is `true` only when the canonical hash slot
@@ -14266,13 +14408,16 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14266
14408
  // suppress `onMigrated`, otherwise a valid legacy value would be lost
14267
14409
  // on the next reload (see `canonicalUrlHasValidState` for the same
14268
14410
  // 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.
14269
14414
  useMigrateLegacySearchParam({
14270
14415
  key,
14271
14416
  enabled: hashActive,
14272
14417
  decode,
14273
14418
  fromUrlValue,
14274
14419
  validate,
14275
- hashAlreadyHasValue: canonicalUrlHasValidState,
14420
+ hashAlreadyHasValue: canonicalUrlHasValidState || urlRejectedAsForeign,
14276
14421
  onMigrated: react.useCallback((migrated) => {
14277
14422
  if (dequal.dequal(lastPersistedRef.current, migrated)) {
14278
14423
  return;
@@ -14284,10 +14429,8 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14284
14429
  : sharedUtils.storageSerializer.serialize(migrated);
14285
14430
  rawLocalStorage.write(storageKey, serialized);
14286
14431
  }
14287
- const urlValue = toUrlValueRef.current ? toUrlValueRef.current(migrated) : migrated;
14288
- const encoded = encode(urlValue);
14289
- updateSearchParam(encoded, { replace: true });
14290
- }, [encode, localStorageEnabled, storageKey, updateSearchParam]),
14432
+ updateSearchParam(encodeForUrl(migrated), { replace: true });
14433
+ }, [encodeForUrl, localStorageEnabled, storageKey, updateSearchParam]),
14291
14434
  });
14292
14435
  const persistState = react.useCallback((state) => {
14293
14436
  if (dequal.dequal(lastPersistedRef.current, state)) {
@@ -14298,10 +14441,8 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14298
14441
  const serialized = serializeRef.current ? serializeRef.current(state) : sharedUtils.storageSerializer.serialize(state);
14299
14442
  rawLocalStorage.write(storageKey, serialized);
14300
14443
  }
14301
- const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
14302
- const encoded = encode(urlValue);
14303
- updateSearchParam(encoded);
14304
- }, [storageKey, encode, localStorageEnabled, updateSearchParam]);
14444
+ updateSearchParam(encodeForUrl(state));
14445
+ }, [storageKey, encodeForUrl, localStorageEnabled, updateSearchParam]);
14305
14446
  const clearState = react.useCallback(() => {
14306
14447
  lastPersistedRef.current = undefined;
14307
14448
  if (localStorageEnabled) {
package/index.esm.js CHANGED
@@ -13695,6 +13695,76 @@ const readPrimitiveSearchParam = (search, key) => {
13695
13695
  return String(parsedValue.data);
13696
13696
  };
13697
13697
 
13698
+ /**
13699
+ * Pure utilities for stamping a URL-persisted value with the identity that
13700
+ * wrote it.
13701
+ *
13702
+ * A URL (hash or search param) is shared by every account that uses the same
13703
+ * browser: it survives login redirects, history and bookmarks, and it takes
13704
+ * precedence over per-user localStorage when a page loads. Without a stamp
13705
+ * there is no way to tell "a colleague shared this link" from "the previous
13706
+ * account on this PC left its layout in the address bar" — and the second
13707
+ * case silently replaces the current user's saved state (GLU-2396).
13708
+ *
13709
+ * The stamp is a short, non-reversible fingerprint of `clientSideUserId`.
13710
+ * It is not a secret and not a security boundary; it only lets a reader
13711
+ * recognise a value written by a *different* identity on the same device.
13712
+ */
13713
+ /**
13714
+ * Wire shape of a stamped URL value: `{ o: <fingerprint>, v: <payload> }`.
13715
+ * Values written before stamping existed are bare payloads and decode as
13716
+ * unowned (see {@link unwrapUrlOwner}).
13717
+ */
13718
+ const stampedUrlValueSchema = z.object({
13719
+ o: z.string(),
13720
+ v: z.unknown(),
13721
+ });
13722
+ const FNV_OFFSET_BASIS = 0x811c9dc5;
13723
+ const FNV_PRIME = 0x01000193;
13724
+ /**
13725
+ * 32-bit FNV-1a of `identity`, rendered in base36 (≤ 7 chars).
13726
+ *
13727
+ * Synchronous and dependency-free so it can run inside a `useState`
13728
+ * initializer. Collisions are possible in theory but irrelevant here: a
13729
+ * collision can only make a foreign value look like the reader's own, which
13730
+ * is today's behaviour for every value.
13731
+ *
13732
+ * @param identity - The client-side user id to fingerprint.
13733
+ * @returns {string} A short stable fingerprint of `identity`.
13734
+ */
13735
+ const identityFingerprint = (identity) => {
13736
+ let hash = FNV_OFFSET_BASIS;
13737
+ for (let index = 0; index < identity.length; index++) {
13738
+ hash ^= identity.charCodeAt(index);
13739
+ hash = Math.imul(hash, FNV_PRIME) >>> 0;
13740
+ }
13741
+ return hash.toString(36);
13742
+ };
13743
+ /**
13744
+ * Wraps a URL payload with the fingerprint of the identity writing it.
13745
+ *
13746
+ * @param value - The payload that would otherwise be encoded into the URL.
13747
+ * @param owner - Fingerprint of the writing identity (see {@link identityFingerprint}).
13748
+ * @returns {StampedUrlValue} The payload wrapped in its ownership envelope.
13749
+ */
13750
+ const wrapUrlOwner = (value, owner) => ({ o: owner, v: value });
13751
+ /**
13752
+ * Splits a decoded URL value into its owner fingerprint and payload.
13753
+ * Bare (pre-stamping) payloads come back with `owner: undefined`.
13754
+ *
13755
+ * @param decoded - The decoded URL value, stamped or bare.
13756
+ * @returns {{ owner: string | undefined; value: unknown }} The owner fingerprint (if any) and the payload.
13757
+ */
13758
+ const unwrapUrlOwner = (decoded) => {
13759
+ const stamped = stampedUrlValueSchema.safeParse(decoded);
13760
+ // `z.unknown()` accepts a missing key, so `{ o: "x" }` parses without `v`.
13761
+ // Such an object is a payload of its own, not an envelope; the `in` check
13762
+ // is what keeps it unowned. Do not remove it as redundant.
13763
+ return stamped.success && "v" in stamped.data
13764
+ ? { owner: stamped.data.o, value: stamped.data.v }
13765
+ : { owner: undefined, value: decoded };
13766
+ };
13767
+
13698
13768
  /**
13699
13769
  * One-shot migration: if the URL still carries a legacy `?<key>=...`
13700
13770
  * search param from before this hook moved table-style state to the
@@ -14007,6 +14077,41 @@ const rawLocalStorage = {
14007
14077
  read: (key) => localStorage.getItem(key),
14008
14078
  write: (key, value) => localStorage.setItem(key, value),
14009
14079
  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
+ }
14010
14115
  };
14011
14116
  /**
14012
14117
  * Generic persistence hook that loads state from a URL slot (search param
@@ -14051,9 +14156,16 @@ const rawLocalStorage = {
14051
14156
  * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
14052
14157
  * omits fields so they still survive via localStorage. Default `false`.
14053
14158
  * @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`.
14054
14161
  */
14055
- const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", }) => {
14162
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, urlLocation = "search", scopeUrlToIdentity = false, }) => {
14056
14163
  const { encode, decode } = useCustomEncoding();
14164
+ // 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]);
14057
14169
  const searchActive = enabled && urlLocation === "search";
14058
14170
  const hashActive = enabled && urlLocation === "hash";
14059
14171
  // Both URL-sync hooks are mounted unconditionally to satisfy the Rules of
@@ -14111,9 +14223,24 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14111
14223
  const readPersistedState = useCallback(({ valueFromUrl, legacyValueFromUrl, validateState, transformFromUrlValue, }) => {
14112
14224
  const tryDecode = (raw) => {
14113
14225
  try {
14114
- const decoded = decode(raw);
14115
- const transformed = transformFromUrlValue ? transformFromUrlValue(decoded) : decoded;
14116
- return validateState(transformed);
14226
+ const { owner, value } = unwrapUrlOwner(decode(raw));
14227
+ const transformed = transformFromUrlValue ? transformFromUrlValue(value) : value;
14228
+ return { state: validateState(transformed), owner };
14229
+ }
14230
+ catch {
14231
+ return { state: undefined, owner: undefined };
14232
+ }
14233
+ };
14234
+ const readStoredState = () => {
14235
+ if (!localStorageEnabled) {
14236
+ return undefined;
14237
+ }
14238
+ try {
14239
+ const raw = rawLocalStorage.read(storageKey);
14240
+ if (!raw) {
14241
+ return undefined;
14242
+ }
14243
+ return validateState(storageSerializer.deserialize(raw));
14117
14244
  }
14118
14245
  catch {
14119
14246
  return undefined;
@@ -14122,14 +14249,30 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14122
14249
  let urlState;
14123
14250
  let canonicalUrlValid = false;
14124
14251
  let usedLegacyUrl = false;
14252
+ let rejectedAsForeign = false;
14125
14253
  if (enabled && valueFromUrl) {
14126
- urlState = tryDecode(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;
14127
14268
  if (urlState !== undefined) {
14128
14269
  canonicalUrlValid = true;
14129
14270
  }
14130
14271
  }
14131
- if (urlState === undefined && enabled && legacyValueFromUrl) {
14132
- urlState = tryDecode(legacyValueFromUrl);
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;
14133
14276
  if (urlState !== undefined) {
14134
14277
  usedLegacyUrl = true;
14135
14278
  }
@@ -14141,21 +14284,10 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14141
14284
  initialState: urlState,
14142
14285
  canonicalUrlHasValidState: canonicalUrlValid,
14143
14286
  needsUrlRestore: usedLegacyUrl,
14287
+ urlRejectedAsForeign: rejectedAsForeign,
14144
14288
  };
14145
14289
  }
14146
- let storageState;
14147
- if (localStorageEnabled) {
14148
- try {
14149
- const raw = rawLocalStorage.read(storageKey);
14150
- if (raw) {
14151
- const parsed = storageSerializer.deserialize(raw);
14152
- storageState = validateState(parsed);
14153
- }
14154
- }
14155
- catch {
14156
- // no valid stored state
14157
- }
14158
- }
14290
+ const storageState = readStoredState();
14159
14291
  if (urlState !== undefined && storageState !== undefined) {
14160
14292
  // Shallow merge — URL fields take precedence; storage fills in
14161
14293
  // whatever the URL omits (e.g. fields stripped by `toUrlValue`).
@@ -14163,6 +14295,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14163
14295
  initialState: { ...storageState, ...urlState },
14164
14296
  canonicalUrlHasValidState: canonicalUrlValid,
14165
14297
  needsUrlRestore: usedLegacyUrl,
14298
+ urlRejectedAsForeign: rejectedAsForeign,
14166
14299
  };
14167
14300
  }
14168
14301
  if (urlState !== undefined) {
@@ -14170,6 +14303,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14170
14303
  initialState: urlState,
14171
14304
  canonicalUrlHasValidState: canonicalUrlValid,
14172
14305
  needsUrlRestore: usedLegacyUrl,
14306
+ urlRejectedAsForeign: rejectedAsForeign,
14173
14307
  };
14174
14308
  }
14175
14309
  if (storageState !== undefined) {
@@ -14177,14 +14311,22 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14177
14311
  initialState: storageState,
14178
14312
  canonicalUrlHasValidState: false,
14179
14313
  needsUrlRestore: true,
14314
+ urlRejectedAsForeign: rejectedAsForeign,
14180
14315
  };
14181
14316
  }
14182
14317
  return {
14183
14318
  initialState: undefined,
14184
14319
  canonicalUrlHasValidState: false,
14185
14320
  needsUrlRestore: false,
14321
+ urlRejectedAsForeign: rejectedAsForeign,
14186
14322
  };
14187
- }, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
14323
+ }, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead, scopeUrlToIdentity, key, currentUrlOwner]);
14324
+ // Every URL write goes through here so the ownership stamp is applied
14325
+ // uniformly (user edits, storage→URL restore, legacy search-param migration).
14326
+ const encodeForUrl = useCallback((state) => {
14327
+ const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
14328
+ return encode(currentUrlOwner === undefined ? urlValue : wrapUrlOwner(urlValue, currentUrlOwner));
14329
+ }, [encode, currentUrlOwner]);
14188
14330
  const [persistedStateSnapshot, setPersistedStateSnapshot] = useState(() => readPersistedState({
14189
14331
  valueFromUrl: searchValue,
14190
14332
  legacyValueFromUrl: legacySearchValue,
@@ -14205,7 +14347,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14205
14347
  transformFromUrlValue: fromUrlValueRef.current,
14206
14348
  }));
14207
14349
  }, [persistedStateReadKey, readPersistedState]);
14208
- const { initialState, canonicalUrlHasValidState, needsUrlRestore } = persistedStateSnapshot;
14350
+ const { initialState, canonicalUrlHasValidState, needsUrlRestore, urlRejectedAsForeign } = persistedStateSnapshot;
14209
14351
  // Late-hydration recovery: TanStack Router's `useSearch` can return an
14210
14352
  // empty object on the very first render of a route that's still in a
14211
14353
  // loader-driven pending phase. The `useState` initializer above runs
@@ -14216,11 +14358,13 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14216
14358
  // `onMigrated`. We re-read here when:
14217
14359
  // - the snapshot has nothing usable yet (`initialState === undefined`
14218
14360
  // AND the canonical URL slot has no valid value), and
14219
- // - a legacy value has just appeared.
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).
14220
14364
  // Once `initialState` is populated, this effect's guard short-circuits
14221
14365
  // and the rest of the lifecycle (restore + migration) takes over.
14222
14366
  useEffect(() => {
14223
- if (initialState !== undefined || canonicalUrlHasValidState) {
14367
+ if (initialState !== undefined || canonicalUrlHasValidState || urlRejectedAsForeign) {
14224
14368
  return;
14225
14369
  }
14226
14370
  if (legacySearchValue === undefined) {
@@ -14232,7 +14376,7 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14232
14376
  validateState: validateRef.current,
14233
14377
  transformFromUrlValue: fromUrlValueRef.current,
14234
14378
  }));
14235
- }, [legacySearchValue, initialState, canonicalUrlHasValidState, readPersistedState]);
14379
+ }, [legacySearchValue, initialState, canonicalUrlHasValidState, urlRejectedAsForeign, readPersistedState]);
14236
14380
  const lastPersistedRef = useRef(initialState);
14237
14381
  useEffect(() => {
14238
14382
  lastPersistedRef.current = initialState;
@@ -14254,10 +14398,8 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14254
14398
  return;
14255
14399
  }
14256
14400
  hasRestoredUrlRef.current = true;
14257
- const urlValue = toUrlValueRef.current ? toUrlValueRef.current(initialState) : initialState;
14258
- const encoded = encode(urlValue);
14259
- updateSearchParam(encoded, { replace: true });
14260
- }, [enabled, needsUrlRestore, initialState, canonicalUrlHasValidState, updateSearchParam, encode]);
14401
+ updateSearchParam(encodeForUrl(initialState), { replace: true });
14402
+ }, [enabled, needsUrlRestore, initialState, canonicalUrlHasValidState, updateSearchParam, encodeForUrl]);
14261
14403
  // Strip the legacy search param so reloads come from the canonical hash
14262
14404
  // slot and the URL stays tidy. The hook is a no-op outside hash mode.
14263
14405
  // `hashAlreadyHasValue` is `true` only when the canonical hash slot
@@ -14265,13 +14407,16 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14265
14407
  // suppress `onMigrated`, otherwise a valid legacy value would be lost
14266
14408
  // on the next reload (see `canonicalUrlHasValidState` for the same
14267
14409
  // 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.
14268
14413
  useMigrateLegacySearchParam({
14269
14414
  key,
14270
14415
  enabled: hashActive,
14271
14416
  decode,
14272
14417
  fromUrlValue,
14273
14418
  validate,
14274
- hashAlreadyHasValue: canonicalUrlHasValidState,
14419
+ hashAlreadyHasValue: canonicalUrlHasValidState || urlRejectedAsForeign,
14275
14420
  onMigrated: useCallback((migrated) => {
14276
14421
  if (dequal(lastPersistedRef.current, migrated)) {
14277
14422
  return;
@@ -14283,10 +14428,8 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14283
14428
  : storageSerializer.serialize(migrated);
14284
14429
  rawLocalStorage.write(storageKey, serialized);
14285
14430
  }
14286
- const urlValue = toUrlValueRef.current ? toUrlValueRef.current(migrated) : migrated;
14287
- const encoded = encode(urlValue);
14288
- updateSearchParam(encoded, { replace: true });
14289
- }, [encode, localStorageEnabled, storageKey, updateSearchParam]),
14431
+ updateSearchParam(encodeForUrl(migrated), { replace: true });
14432
+ }, [encodeForUrl, localStorageEnabled, storageKey, updateSearchParam]),
14290
14433
  });
14291
14434
  const persistState = useCallback((state) => {
14292
14435
  if (dequal(lastPersistedRef.current, state)) {
@@ -14297,10 +14440,8 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
14297
14440
  const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
14298
14441
  rawLocalStorage.write(storageKey, serialized);
14299
14442
  }
14300
- const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
14301
- const encoded = encode(urlValue);
14302
- updateSearchParam(encoded);
14303
- }, [storageKey, encode, localStorageEnabled, updateSearchParam]);
14443
+ updateSearchParam(encodeForUrl(state));
14444
+ }, [storageKey, encodeForUrl, localStorageEnabled, updateSearchParam]);
14304
14445
  const clearState = useCallback(() => {
14305
14446
  lastPersistedRef.current = undefined;
14306
14447
  if (localStorageEnabled) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "3.4.2",
3
+ "version": "3.4.5",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "migrations": "./migrations.json",
@@ -14,16 +14,16 @@
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.16.2",
18
- "@trackunit/css-class-variance-utilities": "2.1.2",
19
- "@trackunit/shared-utils": "1.17.2",
20
- "@trackunit/ui-icons": "1.15.2",
17
+ "@trackunit/ui-design-tokens": "1.16.4",
18
+ "@trackunit/css-class-variance-utilities": "2.1.4",
19
+ "@trackunit/shared-utils": "1.17.4",
20
+ "@trackunit/ui-icons": "1.15.4",
21
21
  "es-toolkit": "^1.39.10",
22
22
  "@tanstack/react-virtual": "^3.14.3",
23
23
  "dequal": "^2.0.3",
24
24
  "fflate": "^0.8.2",
25
25
  "zod": "^3.25.76",
26
- "@trackunit/i18n-library-translation": "2.6.2"
26
+ "@trackunit/i18n-library-translation": "2.6.4"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "react": "^19.0.0",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pure utilities for stamping a URL-persisted value with the identity that
3
+ * wrote it.
4
+ *
5
+ * A URL (hash or search param) is shared by every account that uses the same
6
+ * browser: it survives login redirects, history and bookmarks, and it takes
7
+ * precedence over per-user localStorage when a page loads. Without a stamp
8
+ * there is no way to tell "a colleague shared this link" from "the previous
9
+ * account on this PC left its layout in the address bar" — and the second
10
+ * case silently replaces the current user's saved state (GLU-2396).
11
+ *
12
+ * The stamp is a short, non-reversible fingerprint of `clientSideUserId`.
13
+ * It is not a secret and not a security boundary; it only lets a reader
14
+ * recognise a value written by a *different* identity on the same device.
15
+ */
16
+ import { z } from "zod";
17
+ /**
18
+ * Wire shape of a stamped URL value: `{ o: <fingerprint>, v: <payload> }`.
19
+ * Values written before stamping existed are bare payloads and decode as
20
+ * unowned (see {@link unwrapUrlOwner}).
21
+ */
22
+ declare const stampedUrlValueSchema: z.ZodObject<{
23
+ o: z.ZodString;
24
+ v: z.ZodUnknown;
25
+ }, "strip", z.ZodTypeAny, {
26
+ o: string;
27
+ v?: unknown;
28
+ }, {
29
+ o: string;
30
+ v?: unknown;
31
+ }>;
32
+ type StampedUrlValue = z.infer<typeof stampedUrlValueSchema>;
33
+ /**
34
+ * 32-bit FNV-1a of `identity`, rendered in base36 (≤ 7 chars).
35
+ *
36
+ * Synchronous and dependency-free so it can run inside a `useState`
37
+ * initializer. Collisions are possible in theory but irrelevant here: a
38
+ * collision can only make a foreign value look like the reader's own, which
39
+ * is today's behaviour for every value.
40
+ *
41
+ * @param identity - The client-side user id to fingerprint.
42
+ * @returns {string} A short stable fingerprint of `identity`.
43
+ */
44
+ export declare const identityFingerprint: (identity: string) => string;
45
+ /**
46
+ * Wraps a URL payload with the fingerprint of the identity writing it.
47
+ *
48
+ * @param value - The payload that would otherwise be encoded into the URL.
49
+ * @param owner - Fingerprint of the writing identity (see {@link identityFingerprint}).
50
+ * @returns {StampedUrlValue} The payload wrapped in its ownership envelope.
51
+ */
52
+ export declare const wrapUrlOwner: (value: unknown, owner: string) => StampedUrlValue;
53
+ /**
54
+ * Splits a decoded URL value into its owner fingerprint and payload.
55
+ * Bare (pre-stamping) payloads come back with `owner: undefined`.
56
+ *
57
+ * @param decoded - The decoded URL value, stamped or bare.
58
+ * @returns {{ owner: string | undefined; value: unknown }} The owner fingerprint (if any) and the payload.
59
+ */
60
+ export declare const unwrapUrlOwner: (decoded: unknown) => {
61
+ readonly owner: string | undefined;
62
+ readonly value: unknown;
63
+ };
64
+ export {};
@@ -39,6 +39,25 @@ type UsePersistedStateOptions<TState> = {
39
39
  * See {@link UrlPersistenceLocation} for the trade-offs.
40
40
  */
41
41
  readonly urlLocation?: UrlPersistenceLocation;
42
+ /**
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.
48
+ *
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`.
59
+ */
60
+ readonly scopeUrlToIdentity?: boolean;
42
61
  };
43
62
  type UsePersistedStateReturn<TState> = {
44
63
  readonly initialState: TState | undefined;
@@ -88,6 +107,8 @@ type UsePersistedStateReturn<TState> = {
88
107
  * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
89
108
  * omits fields so they still survive via localStorage. Default `false`.
90
109
  * @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`.
91
112
  */
92
- export declare const usePersistedState: <TState extends object>({ key, validate, serialize, toUrlValue, fromUrlValue, enabled, localStorageEnabled, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead, urlLocation, }: UsePersistedStateOptions<TState>) => UsePersistedStateReturn<TState>;
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>;
93
114
  export {};