@trackunit/react-components 1.24.3 → 1.24.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/index.cjs.js CHANGED
@@ -11133,6 +11133,13 @@ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage,
11133
11133
  */
11134
11134
  const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
11135
11135
 
11136
+ /**
11137
+ * Default maximum full URL length after adding one persisted search param
11138
+ * before the URL write is skipped (localStorage still persists the full state).
11139
+ *
11140
+ * Sized to stay safely below the AWS WAF managed-rule default
11141
+ * `SizeRestrictions_QUERYSTRING` cap of 5000 bytes for the entire url host + path + query + hash string
11142
+ */
11136
11143
  const MAX_URL_LENGTH = 5000;
11137
11144
  /**
11138
11145
  * Syncs an encoded string value with a URL search parameter via Tanstack Router.
@@ -11227,7 +11234,16 @@ const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: re
11227
11234
  replace: shouldReplace,
11228
11235
  });
11229
11236
  });
11230
- }, [enabled, navigate, key, replaceOption, location.hash, getUrlLengthWithSearchParam, currentSearchValue, router.state.status]);
11237
+ }, [
11238
+ enabled,
11239
+ navigate,
11240
+ key,
11241
+ replaceOption,
11242
+ location.hash,
11243
+ getUrlLengthWithSearchParam,
11244
+ currentSearchValue,
11245
+ router.state.status,
11246
+ ]);
11231
11247
  return react.useMemo(() => ({ searchValue: currentSearchValue, updateSearchParam }), [currentSearchValue, updateSearchParam]);
11232
11248
  };
11233
11249
 
@@ -11249,9 +11265,13 @@ const useStorageKey = (key, userId) => {
11249
11265
  * Generic persistence hook that loads state from URL search params (with
11250
11266
  * localStorage fallback) and writes changes to both.
11251
11267
  *
11252
- * On mount the hook tries, in order:
11253
- * 1. Decode the URL search param and pass it through `validate`.
11254
- * 2. If that yields nothing, read localStorage and pass the parsed JSON through `validate`.
11268
+ * On mount the hook resolves the initial state as follows:
11269
+ * - If a URL search param exists, decode it and pass through `validate`.
11270
+ * - If localStorage is enabled, read and validate its value.
11271
+ * - When `mergeWithStorageOnRead` is `false` (default): URL state wins
11272
+ * entirely when present, otherwise fall back to localStorage.
11273
+ * - When `mergeWithStorageOnRead` is `true`: shallow-merge URL fields over
11274
+ * localStorage so fields stripped by `toUrlValue` survive via storage.
11255
11275
  *
11256
11276
  * `persistState` writes the state to localStorage (via `serialize`, default
11257
11277
  * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
@@ -11264,11 +11284,15 @@ const useStorageKey = (key, userId) => {
11264
11284
  * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
11265
11285
  * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
11266
11286
  * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
11287
+ * @param options.localStorageEnabled - When `false` localStorage is neither read nor written (default `true`).
11267
11288
  * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
11268
11289
  * @param options.replace - Forwarded to `useSearchParamSync`.
11269
11290
  * @param options.clientSideUserId - The user ID to use for the localStorage key.
11291
+ * @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
11292
+ * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
11293
+ * omits fields so they still survive via localStorage. Default `false`.
11270
11294
  */
11271
- const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, onExternalChange, replace, clientSideUserId, }) => {
11295
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, }) => {
11272
11296
  const { encode, decode } = useCustomEncoding();
11273
11297
  const { searchValue, updateSearchParam } = useSearchParamSync({
11274
11298
  key,
@@ -11277,51 +11301,96 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
11277
11301
  replace,
11278
11302
  });
11279
11303
  const storageKey = useStorageKey(key, clientSideUserId);
11280
- const validateRef = react.useRef(validate);
11281
- react.useEffect(() => {
11282
- validateRef.current = validate;
11283
- }, [validate]);
11284
11304
  const toUrlValueRef = react.useRef(toUrlValue);
11285
11305
  react.useEffect(() => {
11286
11306
  toUrlValueRef.current = toUrlValue;
11287
11307
  }, [toUrlValue]);
11308
+ const serializeRef = react.useRef(serialize);
11309
+ react.useEffect(() => {
11310
+ serializeRef.current = serialize;
11311
+ }, [serialize]);
11312
+ const validateRef = react.useRef(validate);
11313
+ react.useEffect(() => {
11314
+ validateRef.current = validate;
11315
+ }, [validate]);
11288
11316
  const fromUrlValueRef = react.useRef(fromUrlValue);
11289
11317
  react.useEffect(() => {
11290
11318
  fromUrlValueRef.current = fromUrlValue;
11291
11319
  }, [fromUrlValue]);
11292
- const serializeRef = react.useRef(serialize);
11320
+ const searchValueRef = react.useRef(searchValue);
11293
11321
  react.useEffect(() => {
11294
- serializeRef.current = serialize;
11295
- }, [serialize]);
11296
- const [{ initialState, loadedFromStorage }] = react.useState(() => {
11297
- if (enabled && searchValue) {
11322
+ searchValueRef.current = searchValue;
11323
+ }, [searchValue]);
11324
+ const readPersistedState = react.useCallback(({ valueFromUrl, validateState, transformFromUrlValue, }) => {
11325
+ let urlState;
11326
+ if (enabled && valueFromUrl) {
11298
11327
  try {
11299
- const decoded = decode(searchValue);
11300
- const transformed = fromUrlValue ? fromUrlValue(decoded) : decoded;
11301
- const validated = validate(transformed);
11302
- if (validated !== undefined) {
11303
- return { initialState: validated, loadedFromStorage: false };
11304
- }
11328
+ const decoded = decode(valueFromUrl);
11329
+ const transformed = transformFromUrlValue ? transformFromUrlValue(decoded) : decoded;
11330
+ urlState = validateState(transformed);
11305
11331
  }
11306
11332
  catch {
11307
11333
  // fall through to localStorage
11308
11334
  }
11309
11335
  }
11310
- try {
11311
- const raw = localStorage.getItem(storageKey);
11312
- if (raw) {
11313
- const parsed = storageSerializer.deserialize(raw);
11314
- const validated = validate(parsed);
11315
- return { initialState: validated, loadedFromStorage: validated !== undefined };
11336
+ // Without the merge option, URL state wins entirely when present —
11337
+ // this preserves the original "URL > localStorage" priority.
11338
+ if (urlState !== undefined && !mergeWithStorageOnRead) {
11339
+ return { initialState: urlState, loadedFromStorage: false };
11340
+ }
11341
+ let storageState;
11342
+ if (localStorageEnabled) {
11343
+ try {
11344
+ const raw = localStorage.getItem(storageKey);
11345
+ if (raw) {
11346
+ const parsed = storageSerializer.deserialize(raw);
11347
+ storageState = validateState(parsed);
11348
+ }
11316
11349
  }
11350
+ catch {
11351
+ // no valid stored state
11352
+ }
11353
+ }
11354
+ if (urlState !== undefined && storageState !== undefined) {
11355
+ // Shallow merge — URL fields take precedence; storage fills in
11356
+ // whatever the URL omits (e.g. fields stripped by `toUrlValue`).
11357
+ return { initialState: { ...storageState, ...urlState }, loadedFromStorage: false };
11317
11358
  }
11318
- catch {
11319
- // no valid stored state
11359
+ if (urlState !== undefined) {
11360
+ return { initialState: urlState, loadedFromStorage: false };
11361
+ }
11362
+ if (storageState !== undefined) {
11363
+ return { initialState: storageState, loadedFromStorage: true };
11320
11364
  }
11321
11365
  return { initialState: undefined, loadedFromStorage: false };
11322
- });
11366
+ }, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
11367
+ const [persistedStateSnapshot, setPersistedStateSnapshot] = react.useState(() => readPersistedState({
11368
+ valueFromUrl: searchValue,
11369
+ validateState: validate,
11370
+ transformFromUrlValue: fromUrlValue,
11371
+ }));
11372
+ const persistedStateReadKey = `${enabled}:${localStorageEnabled}:${mergeWithStorageOnRead}:${storageKey}`;
11373
+ const persistedStateReadKeyRef = react.useRef(persistedStateReadKey);
11374
+ react.useEffect(() => {
11375
+ if (persistedStateReadKeyRef.current === persistedStateReadKey) {
11376
+ return;
11377
+ }
11378
+ persistedStateReadKeyRef.current = persistedStateReadKey;
11379
+ setPersistedStateSnapshot(readPersistedState({
11380
+ valueFromUrl: searchValueRef.current,
11381
+ validateState: validateRef.current,
11382
+ transformFromUrlValue: fromUrlValueRef.current,
11383
+ }));
11384
+ }, [persistedStateReadKey, readPersistedState]);
11385
+ const { initialState, loadedFromStorage } = persistedStateSnapshot;
11323
11386
  const lastPersistedRef = react.useRef(initialState);
11387
+ react.useEffect(() => {
11388
+ lastPersistedRef.current = initialState;
11389
+ }, [initialState, localStorageEnabled, storageKey]);
11324
11390
  const hasRestoredUrlRef = react.useRef(false);
11391
+ react.useEffect(() => {
11392
+ hasRestoredUrlRef.current = false;
11393
+ }, [enabled, localStorageEnabled, storageKey]);
11325
11394
  react.useEffect(() => {
11326
11395
  if (hasRestoredUrlRef.current) {
11327
11396
  return;
@@ -11331,18 +11400,22 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
11331
11400
  }
11332
11401
  hasRestoredUrlRef.current = true;
11333
11402
  const urlValue = toUrlValueRef.current ? toUrlValueRef.current(initialState) : initialState;
11334
- updateSearchParam(encode(urlValue), { replace: true });
11403
+ const encoded = encode(urlValue);
11404
+ updateSearchParam(encoded, { replace: true });
11335
11405
  }, [enabled, loadedFromStorage, initialState, searchValue, updateSearchParam, encode]);
11336
11406
  const persistState = react.useCallback((state) => {
11337
11407
  if (dequal.dequal(lastPersistedRef.current, state)) {
11338
11408
  return;
11339
11409
  }
11340
11410
  lastPersistedRef.current = state;
11341
- const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
11342
- localStorage.setItem(storageKey, serialized);
11411
+ if (localStorageEnabled) {
11412
+ const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
11413
+ localStorage.setItem(storageKey, serialized);
11414
+ }
11343
11415
  const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
11344
- updateSearchParam(encode(urlValue));
11345
- }, [storageKey, encode, updateSearchParam]);
11416
+ const encoded = encode(urlValue);
11417
+ updateSearchParam(encoded);
11418
+ }, [storageKey, encode, localStorageEnabled, updateSearchParam]);
11346
11419
  return react.useMemo(() => ({ initialState, persistState }), [initialState, persistState]);
11347
11420
  };
11348
11421
 
package/index.esm.js CHANGED
@@ -11131,6 +11131,13 @@ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage,
11131
11131
  */
11132
11132
  const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
11133
11133
 
11134
+ /**
11135
+ * Default maximum full URL length after adding one persisted search param
11136
+ * before the URL write is skipped (localStorage still persists the full state).
11137
+ *
11138
+ * Sized to stay safely below the AWS WAF managed-rule default
11139
+ * `SizeRestrictions_QUERYSTRING` cap of 5000 bytes for the entire url host + path + query + hash string
11140
+ */
11134
11141
  const MAX_URL_LENGTH = 5000;
11135
11142
  /**
11136
11143
  * Syncs an encoded string value with a URL search parameter via Tanstack Router.
@@ -11225,7 +11232,16 @@ const useSearchParamSync = ({ key, enabled = true, onExternalChange, replace: re
11225
11232
  replace: shouldReplace,
11226
11233
  });
11227
11234
  });
11228
- }, [enabled, navigate, key, replaceOption, location.hash, getUrlLengthWithSearchParam, currentSearchValue, router.state.status]);
11235
+ }, [
11236
+ enabled,
11237
+ navigate,
11238
+ key,
11239
+ replaceOption,
11240
+ location.hash,
11241
+ getUrlLengthWithSearchParam,
11242
+ currentSearchValue,
11243
+ router.state.status,
11244
+ ]);
11229
11245
  return useMemo(() => ({ searchValue: currentSearchValue, updateSearchParam }), [currentSearchValue, updateSearchParam]);
11230
11246
  };
11231
11247
 
@@ -11247,9 +11263,13 @@ const useStorageKey = (key, userId) => {
11247
11263
  * Generic persistence hook that loads state from URL search params (with
11248
11264
  * localStorage fallback) and writes changes to both.
11249
11265
  *
11250
- * On mount the hook tries, in order:
11251
- * 1. Decode the URL search param and pass it through `validate`.
11252
- * 2. If that yields nothing, read localStorage and pass the parsed JSON through `validate`.
11266
+ * On mount the hook resolves the initial state as follows:
11267
+ * - If a URL search param exists, decode it and pass through `validate`.
11268
+ * - If localStorage is enabled, read and validate its value.
11269
+ * - When `mergeWithStorageOnRead` is `false` (default): URL state wins
11270
+ * entirely when present, otherwise fall back to localStorage.
11271
+ * - When `mergeWithStorageOnRead` is `true`: shallow-merge URL fields over
11272
+ * localStorage so fields stripped by `toUrlValue` survive via storage.
11253
11273
  *
11254
11274
  * `persistState` writes the state to localStorage (via `serialize`, default
11255
11275
  * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
@@ -11262,11 +11282,15 @@ const useStorageKey = (key, userId) => {
11262
11282
  * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
11263
11283
  * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
11264
11284
  * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
11285
+ * @param options.localStorageEnabled - When `false` localStorage is neither read nor written (default `true`).
11265
11286
  * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
11266
11287
  * @param options.replace - Forwarded to `useSearchParamSync`.
11267
11288
  * @param options.clientSideUserId - The user ID to use for the localStorage key.
11289
+ * @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
11290
+ * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
11291
+ * omits fields so they still survive via localStorage. Default `false`.
11268
11292
  */
11269
- const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, onExternalChange, replace, clientSideUserId, }) => {
11293
+ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue, enabled = true, localStorageEnabled = true, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead = false, }) => {
11270
11294
  const { encode, decode } = useCustomEncoding();
11271
11295
  const { searchValue, updateSearchParam } = useSearchParamSync({
11272
11296
  key,
@@ -11275,51 +11299,96 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
11275
11299
  replace,
11276
11300
  });
11277
11301
  const storageKey = useStorageKey(key, clientSideUserId);
11278
- const validateRef = useRef(validate);
11279
- useEffect(() => {
11280
- validateRef.current = validate;
11281
- }, [validate]);
11282
11302
  const toUrlValueRef = useRef(toUrlValue);
11283
11303
  useEffect(() => {
11284
11304
  toUrlValueRef.current = toUrlValue;
11285
11305
  }, [toUrlValue]);
11306
+ const serializeRef = useRef(serialize);
11307
+ useEffect(() => {
11308
+ serializeRef.current = serialize;
11309
+ }, [serialize]);
11310
+ const validateRef = useRef(validate);
11311
+ useEffect(() => {
11312
+ validateRef.current = validate;
11313
+ }, [validate]);
11286
11314
  const fromUrlValueRef = useRef(fromUrlValue);
11287
11315
  useEffect(() => {
11288
11316
  fromUrlValueRef.current = fromUrlValue;
11289
11317
  }, [fromUrlValue]);
11290
- const serializeRef = useRef(serialize);
11318
+ const searchValueRef = useRef(searchValue);
11291
11319
  useEffect(() => {
11292
- serializeRef.current = serialize;
11293
- }, [serialize]);
11294
- const [{ initialState, loadedFromStorage }] = useState(() => {
11295
- if (enabled && searchValue) {
11320
+ searchValueRef.current = searchValue;
11321
+ }, [searchValue]);
11322
+ const readPersistedState = useCallback(({ valueFromUrl, validateState, transformFromUrlValue, }) => {
11323
+ let urlState;
11324
+ if (enabled && valueFromUrl) {
11296
11325
  try {
11297
- const decoded = decode(searchValue);
11298
- const transformed = fromUrlValue ? fromUrlValue(decoded) : decoded;
11299
- const validated = validate(transformed);
11300
- if (validated !== undefined) {
11301
- return { initialState: validated, loadedFromStorage: false };
11302
- }
11326
+ const decoded = decode(valueFromUrl);
11327
+ const transformed = transformFromUrlValue ? transformFromUrlValue(decoded) : decoded;
11328
+ urlState = validateState(transformed);
11303
11329
  }
11304
11330
  catch {
11305
11331
  // fall through to localStorage
11306
11332
  }
11307
11333
  }
11308
- try {
11309
- const raw = localStorage.getItem(storageKey);
11310
- if (raw) {
11311
- const parsed = storageSerializer.deserialize(raw);
11312
- const validated = validate(parsed);
11313
- return { initialState: validated, loadedFromStorage: validated !== undefined };
11334
+ // Without the merge option, URL state wins entirely when present —
11335
+ // this preserves the original "URL > localStorage" priority.
11336
+ if (urlState !== undefined && !mergeWithStorageOnRead) {
11337
+ return { initialState: urlState, loadedFromStorage: false };
11338
+ }
11339
+ let storageState;
11340
+ if (localStorageEnabled) {
11341
+ try {
11342
+ const raw = localStorage.getItem(storageKey);
11343
+ if (raw) {
11344
+ const parsed = storageSerializer.deserialize(raw);
11345
+ storageState = validateState(parsed);
11346
+ }
11314
11347
  }
11348
+ catch {
11349
+ // no valid stored state
11350
+ }
11351
+ }
11352
+ if (urlState !== undefined && storageState !== undefined) {
11353
+ // Shallow merge — URL fields take precedence; storage fills in
11354
+ // whatever the URL omits (e.g. fields stripped by `toUrlValue`).
11355
+ return { initialState: { ...storageState, ...urlState }, loadedFromStorage: false };
11315
11356
  }
11316
- catch {
11317
- // no valid stored state
11357
+ if (urlState !== undefined) {
11358
+ return { initialState: urlState, loadedFromStorage: false };
11359
+ }
11360
+ if (storageState !== undefined) {
11361
+ return { initialState: storageState, loadedFromStorage: true };
11318
11362
  }
11319
11363
  return { initialState: undefined, loadedFromStorage: false };
11320
- });
11364
+ }, [decode, enabled, localStorageEnabled, storageKey, mergeWithStorageOnRead]);
11365
+ const [persistedStateSnapshot, setPersistedStateSnapshot] = useState(() => readPersistedState({
11366
+ valueFromUrl: searchValue,
11367
+ validateState: validate,
11368
+ transformFromUrlValue: fromUrlValue,
11369
+ }));
11370
+ const persistedStateReadKey = `${enabled}:${localStorageEnabled}:${mergeWithStorageOnRead}:${storageKey}`;
11371
+ const persistedStateReadKeyRef = useRef(persistedStateReadKey);
11372
+ useEffect(() => {
11373
+ if (persistedStateReadKeyRef.current === persistedStateReadKey) {
11374
+ return;
11375
+ }
11376
+ persistedStateReadKeyRef.current = persistedStateReadKey;
11377
+ setPersistedStateSnapshot(readPersistedState({
11378
+ valueFromUrl: searchValueRef.current,
11379
+ validateState: validateRef.current,
11380
+ transformFromUrlValue: fromUrlValueRef.current,
11381
+ }));
11382
+ }, [persistedStateReadKey, readPersistedState]);
11383
+ const { initialState, loadedFromStorage } = persistedStateSnapshot;
11321
11384
  const lastPersistedRef = useRef(initialState);
11385
+ useEffect(() => {
11386
+ lastPersistedRef.current = initialState;
11387
+ }, [initialState, localStorageEnabled, storageKey]);
11322
11388
  const hasRestoredUrlRef = useRef(false);
11389
+ useEffect(() => {
11390
+ hasRestoredUrlRef.current = false;
11391
+ }, [enabled, localStorageEnabled, storageKey]);
11323
11392
  useEffect(() => {
11324
11393
  if (hasRestoredUrlRef.current) {
11325
11394
  return;
@@ -11329,18 +11398,22 @@ const usePersistedState = ({ key, validate, serialize, toUrlValue, fromUrlValue,
11329
11398
  }
11330
11399
  hasRestoredUrlRef.current = true;
11331
11400
  const urlValue = toUrlValueRef.current ? toUrlValueRef.current(initialState) : initialState;
11332
- updateSearchParam(encode(urlValue), { replace: true });
11401
+ const encoded = encode(urlValue);
11402
+ updateSearchParam(encoded, { replace: true });
11333
11403
  }, [enabled, loadedFromStorage, initialState, searchValue, updateSearchParam, encode]);
11334
11404
  const persistState = useCallback((state) => {
11335
11405
  if (dequal(lastPersistedRef.current, state)) {
11336
11406
  return;
11337
11407
  }
11338
11408
  lastPersistedRef.current = state;
11339
- const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
11340
- localStorage.setItem(storageKey, serialized);
11409
+ if (localStorageEnabled) {
11410
+ const serialized = serializeRef.current ? serializeRef.current(state) : storageSerializer.serialize(state);
11411
+ localStorage.setItem(storageKey, serialized);
11412
+ }
11341
11413
  const urlValue = toUrlValueRef.current ? toUrlValueRef.current(state) : state;
11342
- updateSearchParam(encode(urlValue));
11343
- }, [storageKey, encode, updateSearchParam]);
11414
+ const encoded = encode(urlValue);
11415
+ updateSearchParam(encoded);
11416
+ }, [storageKey, encode, localStorageEnabled, updateSearchParam]);
11344
11417
  return useMemo(() => ({ initialState, persistState }), [initialState, persistState]);
11345
11418
  };
11346
11419
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "1.24.3",
3
+ "version": "1.24.5",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "migrations": "./migrations.json",
@@ -14,10 +14,10 @@
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.13.1",
18
- "@trackunit/css-class-variance-utilities": "1.13.1",
19
- "@trackunit/shared-utils": "1.15.1",
20
- "@trackunit/ui-icons": "1.13.1",
17
+ "@trackunit/ui-design-tokens": "1.13.2",
18
+ "@trackunit/css-class-variance-utilities": "1.13.2",
19
+ "@trackunit/shared-utils": "1.15.2",
20
+ "@trackunit/ui-icons": "1.13.2",
21
21
  "es-toolkit": "^1.39.10",
22
22
  "@tanstack/react-virtual": "3.13.12",
23
23
  "dequal": "^2.0.3",
@@ -5,9 +5,23 @@ type UsePersistedStateOptions<TState> = {
5
5
  readonly toUrlValue?: (state: TState) => string | object | Array<object>;
6
6
  readonly fromUrlValue?: (decoded: unknown) => unknown;
7
7
  readonly enabled?: boolean;
8
+ readonly localStorageEnabled?: boolean;
8
9
  readonly onExternalChange?: () => void;
9
10
  readonly replace?: boolean;
10
11
  readonly clientSideUserId?: string;
12
+ /**
13
+ * When `true` and both URL and localStorage have valid state, the read
14
+ * returns a shallow merge `{ ...storage, ...url }` where URL fields take
15
+ * precedence and storage fills in fields the URL omits.
16
+ *
17
+ * Use this when `toUrlValue` deliberately omits some fields from the URL
18
+ * (e.g. per-user layout details that shouldn't be shareable) so the
19
+ * omitted fields still survive reloads via localStorage.
20
+ *
21
+ * Default is `false`, preserving the original behavior where URL state
22
+ * fully replaces localStorage state on read.
23
+ */
24
+ readonly mergeWithStorageOnRead?: boolean;
11
25
  };
12
26
  type UsePersistedStateReturn<TState> = {
13
27
  readonly initialState: TState | undefined;
@@ -17,9 +31,13 @@ type UsePersistedStateReturn<TState> = {
17
31
  * Generic persistence hook that loads state from URL search params (with
18
32
  * localStorage fallback) and writes changes to both.
19
33
  *
20
- * On mount the hook tries, in order:
21
- * 1. Decode the URL search param and pass it through `validate`.
22
- * 2. If that yields nothing, read localStorage and pass the parsed JSON through `validate`.
34
+ * On mount the hook resolves the initial state as follows:
35
+ * - If a URL search param exists, decode it and pass through `validate`.
36
+ * - If localStorage is enabled, read and validate its value.
37
+ * - When `mergeWithStorageOnRead` is `false` (default): URL state wins
38
+ * entirely when present, otherwise fall back to localStorage.
39
+ * - When `mergeWithStorageOnRead` is `true`: shallow-merge URL fields over
40
+ * localStorage so fields stripped by `toUrlValue` survive via storage.
23
41
  *
24
42
  * `persistState` writes the state to localStorage (via `serialize`, default
25
43
  * `JSON.stringify`) and syncs to the URL (via `toUrlValue` + encoding).
@@ -32,9 +50,13 @@ type UsePersistedStateReturn<TState> = {
32
50
  * @param options.toUrlValue - Transform applied before URL encoding (default: encode state as-is).
33
51
  * @param options.fromUrlValue - Transform applied after URL decoding, before validation (default: identity).
34
52
  * @param options.enabled - When `false` the URL is neither read nor written (default `true`).
53
+ * @param options.localStorageEnabled - When `false` localStorage is neither read nor written (default `true`).
35
54
  * @param options.onExternalChange - Fired when the URL param changes externally (e.g. browser back).
36
55
  * @param options.replace - Forwarded to `useSearchParamSync`.
37
56
  * @param options.clientSideUserId - The user ID to use for the localStorage key.
57
+ * @param options.mergeWithStorageOnRead - Merge URL state on top of localStorage
58
+ * state on read (URL wins on shared keys). Use when `toUrlValue` deliberately
59
+ * omits fields so they still survive via localStorage. Default `false`.
38
60
  */
39
- export declare const usePersistedState: <TState extends object>({ key, validate, serialize, toUrlValue, fromUrlValue, enabled, onExternalChange, replace, clientSideUserId, }: UsePersistedStateOptions<TState>) => UsePersistedStateReturn<TState>;
61
+ export declare const usePersistedState: <TState extends object>({ key, validate, serialize, toUrlValue, fromUrlValue, enabled, localStorageEnabled, onExternalChange, replace, clientSideUserId, mergeWithStorageOnRead, }: UsePersistedStateOptions<TState>) => UsePersistedStateReturn<TState>;
40
62
  export {};
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Default maximum full URL length after adding one persisted search param
3
+ * before the URL write is skipped (localStorage still persists the full state).
4
+ *
5
+ * Sized to stay safely below the AWS WAF managed-rule default
6
+ * `SizeRestrictions_QUERYSTRING` cap of 5000 bytes for the entire url host + path + query + hash string
7
+ */
1
8
  export declare const MAX_URL_LENGTH = 5000;
2
9
  type UseSearchParamSyncOptions = {
3
10
  readonly key: string;