@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 +2 -0
- package/index.cjs.js +179 -38
- package/index.esm.js +179 -38
- package/package.json +6 -6
- package/src/hooks/persistence/urlIdentity.d.ts +64 -0
- package/src/hooks/persistence/usePersistedState.d.ts +22 -1
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
|
|
14116
|
-
const transformed = transformFromUrlValue ? transformFromUrlValue(
|
|
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
|
-
|
|
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
|
-
|
|
14133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14259
|
-
|
|
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
|
-
|
|
14288
|
-
|
|
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
|
-
|
|
14302
|
-
|
|
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
|
|
14115
|
-
const transformed = transformFromUrlValue ? transformFromUrlValue(
|
|
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
|
-
|
|
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
|
-
|
|
14132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14258
|
-
|
|
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
|
-
|
|
14287
|
-
|
|
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
|
-
|
|
14301
|
-
|
|
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.
|
|
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.
|
|
18
|
-
"@trackunit/css-class-variance-utilities": "2.1.
|
|
19
|
-
"@trackunit/shared-utils": "1.17.
|
|
20
|
-
"@trackunit/ui-icons": "1.15.
|
|
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.
|
|
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 {};
|