@trackunit/react-components 1.18.15 → 1.18.16

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
@@ -20,6 +20,8 @@ var reactVirtual = require('@tanstack/react-virtual');
20
20
  var reactHelmetAsync = require('react-helmet-async');
21
21
  var reactTabs = require('@radix-ui/react-tabs');
22
22
  var fflate = require('fflate');
23
+ var zod = require('zod');
24
+ var superjson = require('superjson');
23
25
 
24
26
  const cvaIcon = cssClassVarianceUtilities.cvaMerge(["aspect-square", "inline-grid", "relative", "shrink-0"], {
25
27
  variants: {
@@ -8240,179 +8242,615 @@ const useCustomEncoding = () => {
8240
8242
  };
8241
8243
 
8242
8244
  /**
8243
- * The usePrevious hook is a useful tool for tracking the previous value of a variable in a functional component. This can be particularly handy in scenarios where it is necessary to compare the current value with the previous one, such as triggering actions or rendering based on changes.
8245
+ * Runs a sequential migration pipeline on the provided data, applying
8246
+ * each migration whose version is in the range (fromVersion, toVersion].
8244
8247
  *
8245
- * @example
8246
- * const [color, setColor] = React.useState(getRandomColor());
8247
- const previousColor = usePrevious(color);
8248
+ * Throws if the filtered migrations have non-contiguous versions (gaps).
8248
8249
  */
8249
- const usePrevious = (value) => {
8250
- const ref = react.useRef(undefined);
8251
- react.useEffect(() => {
8252
- ref.current = value;
8253
- }, [value]);
8254
- const wrapper = react.useMemo(() => ({
8255
- get previous() {
8256
- return ref.current;
8257
- },
8258
- }), []);
8259
- return wrapper.previous;
8250
+ const runMigrations = ({ data, fromVersion, toVersion, migrations, }) => {
8251
+ if (toVersion <= fromVersion) {
8252
+ return data;
8253
+ }
8254
+ const applicable = migrations
8255
+ .toSorted((a, b) => a.version - b.version)
8256
+ .filter(m => m.version > fromVersion && m.version <= toVersion);
8257
+ if (applicable.length === 0) {
8258
+ return data;
8259
+ }
8260
+ for (let i = 1; i < applicable.length; i++) {
8261
+ const prev = applicable[i - 1];
8262
+ const curr = applicable[i];
8263
+ if (prev && curr && curr.version !== prev.version + 1) {
8264
+ throw new Error(`Migration gap detected: found version ${prev.version} and ${curr.version} but no migration for version ${prev.version + 1}.`);
8265
+ }
8266
+ }
8267
+ return applicable.reduce((acc, migration) => migration.migrate(acc), data);
8260
8268
  };
8261
8269
 
8262
8270
  /**
8263
- * Validates the state object using a Zod schema.
8271
+ * Recursively builds a salvaging schema from a ZodObject by injecting `.catch(defaultValue[key])`
8272
+ * for each field. This means invalid field values fall back to defaultState values rather than
8273
+ * failing the entire parse.
8264
8274
  *
8265
- * @template TState - The type of the state.
8266
- * @param {ValidateStateOptions<TState>} params - The parameters for the validateState function.
8275
+ * Unwraps ZodOptional, ZodNullable, and ZodDefault to reach the inner schema.
8276
+ * ZodCatch is intentionally NOT unwrapped consumer-defined catches take precedence.
8277
+ * Non-ZodObject schemas (unions, discriminated unions, refinements, etc.) are returned unchanged
8278
+ * so they behave as atomic units.
8267
8279
  */
8268
- const validateState = ({ state, schema, onValidationFailed, onValidationSuccessful, defaultState, }) => {
8280
+ function buildSalvagingSchema(schema, defaultValue) {
8281
+ if (schema instanceof zod.z.ZodOptional) {
8282
+ return buildSalvagingSchema(schema.unwrap(), defaultValue).optional();
8283
+ }
8284
+ if (schema instanceof zod.z.ZodNullable) {
8285
+ return buildSalvagingSchema(schema.unwrap(), defaultValue).nullable();
8286
+ }
8287
+ if (schema instanceof zod.z.ZodDefault) {
8288
+ return buildSalvagingSchema(schema._def.innerType, defaultValue);
8289
+ }
8290
+ if (!(schema instanceof zod.z.ZodObject)) {
8291
+ return schema;
8292
+ }
8293
+ const defaultRecordResult = zod.z.record(zod.z.string(), zod.z.unknown()).safeParse(defaultValue);
8294
+ if (!defaultRecordResult.success) {
8295
+ return schema;
8296
+ }
8297
+ const defaultRecord = defaultRecordResult.data;
8298
+ const salvagingShape = {};
8299
+ for (const key of Object.keys(schema.shape)) {
8300
+ const fieldSchema = schema.shape[key];
8301
+ if (fieldSchema === undefined)
8302
+ continue;
8303
+ const fieldDefault = defaultRecord[key];
8304
+ salvagingShape[key] = buildSalvagingSchema(fieldSchema, fieldDefault).catch(fieldDefault);
8305
+ }
8306
+ return zod.z.object(salvagingShape);
8307
+ }
8308
+ /**
8309
+ * Attempts to salvage partial state from raw data when full schema validation has failed.
8310
+ *
8311
+ * For ZodObject schemas, rebuilds the schema with per-field `.catch(defaultState[key])` fallbacks
8312
+ * and reparses. Valid field values from the stored data are kept; invalid fields fall back to their
8313
+ * corresponding values in `defaultState`. Works recursively for nested ZodObject fields.
8314
+ *
8315
+ * Non-object schemas (discriminated unions, unions with `.refine()`, etc.) cannot be partially
8316
+ * salvaged and return `null`.
8317
+ *
8318
+ * Consumer-defined `.catch()` wrappers take precedence over the injected salvage catches, since
8319
+ * Zod processes catches inside-out.
8320
+ *
8321
+ * Note: when every field is invalid, the salvaged result will be deeply equal to `defaultState`.
8322
+ * Callers should compare the result against `defaultState` to distinguish a genuine partial
8323
+ * salvage from a total loss where no stored data survived.
8324
+ *
8325
+ * @template TState - The type of the stored state.
8326
+ * @param schema - Zod schema for validation.
8327
+ * @param rawData - The raw deserialized value that failed validation.
8328
+ * @param defaultState - The authoritative fallback value; used as per-field catch defaults.
8329
+ * @returns {TState | null} The salvaged state, or `null` if salvaging is not possible or also fails.
8330
+ */
8331
+ const salvageState = (schema, rawData, defaultState) => {
8269
8332
  try {
8270
- const parsedState = schema.parse(state);
8271
- onValidationSuccessful?.(parsedState);
8272
- return parsedState;
8333
+ const salvagingSchema = buildSalvagingSchema(schema, defaultState);
8334
+ const intermediate = salvagingSchema.safeParse(rawData);
8335
+ if (!intermediate.success) {
8336
+ return null;
8337
+ }
8338
+ // Re-validate through the original schema to ensure correctness and get the proper TState type.
8339
+ // By construction this should always succeed: each field value is either the valid stored value
8340
+ // or defaultState[key], both of which satisfy the original schema.
8341
+ const result = schema.safeParse(intermediate.data);
8342
+ return result.success ? result.data : null;
8273
8343
  }
8274
- catch (error) {
8275
- // eslint-disable-next-line no-console
8276
- console.error("Failed to parse and validate the state from local storage.", error);
8277
- onValidationFailed?.(error);
8278
- return defaultState;
8344
+ catch {
8345
+ return null;
8279
8346
  }
8280
8347
  };
8281
8348
 
8282
8349
  /**
8283
- * Initializes the state from local storage, parsing and validating it if a Zod schema is provided.
8350
+ * Internal envelope used to tag superjson-serialized data in web storage.
8351
+ *
8352
+ * `__serializer` is a **reserved internal key** — consumer state objects must
8353
+ * not include it as a top-level key. The double-underscore prefix is a
8354
+ * deliberate signal that this is a private implementation detail.
8284
8355
  *
8285
- * @template TState - The type of the state stored in local storage.
8286
- * @param {Omit<LocalStorageParams<TState>, "state">} params - The parameters for initializing the local storage state.
8287
- * @returns {TState} - The initialized state.
8356
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
8357
+ * detected in the value being written.
8288
8358
  */
8289
- const initLocalStorageState = ({ key, defaultState, schema, }) => {
8290
- const localStorageItem = localStorage.getItem(key);
8291
- if (!localStorageItem) {
8359
+ const taggedSuperjsonEnvelopeSchema = zod.z.object({
8360
+ __serializer: zod.z.literal("superjson"),
8361
+ json: zod.z.custom(),
8362
+ meta: zod.z.custom().optional(),
8363
+ });
8364
+ const storageSerializer = {
8365
+ serialize: (value) => {
8366
+ const serialized = superjson.serialize(value);
8367
+ return JSON.stringify({ __serializer: "superjson", ...serialized });
8368
+ },
8369
+ deserialize: (value) => {
8370
+ const parsed = JSON.parse(value);
8371
+ const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
8372
+ if (result.success) {
8373
+ return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
8374
+ }
8375
+ return parsed;
8376
+ },
8377
+ };
8378
+
8379
+ /**
8380
+ * Internal envelope that pairs stored data with a schema version number.
8381
+ *
8382
+ * `__version` and `__data` are **reserved internal keys** — consumer state
8383
+ * objects must not include them as top-level keys. The double-underscore
8384
+ * prefix is a deliberate signal that these are private implementation details.
8385
+ *
8386
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
8387
+ * detected in the value being written.
8388
+ */
8389
+ const storageVersionEnvelopeSchema = zod.z
8390
+ .object({
8391
+ __version: zod.z.number(),
8392
+ __data: zod.z.unknown(),
8393
+ })
8394
+ .refine((x) => "__data" in x, {
8395
+ message: "__data is required",
8396
+ });
8397
+ /** Wraps data and version into a versioned storage envelope. */
8398
+ const createStorageVersionEnvelope = (data, version) => ({
8399
+ __version: version,
8400
+ __data: data,
8401
+ });
8402
+
8403
+ /**
8404
+ * Validates raw data against the schema, attempting partial salvage on failure.
8405
+ *
8406
+ * On success: returns the parsed data.
8407
+ * On failure with a salvageable ZodObject schema where at least one stored field
8408
+ * survived: returns the salvaged state and calls `onValidationSalvaged`.
8409
+ * On failure where salvage produces a result identical to `defaultState` (i.e. every
8410
+ * field was invalid and caught to its default): treats this as a total failure and
8411
+ * calls `onValidationFailed` instead of `onValidationSalvaged`.
8412
+ * On failure with no salvage possible: returns `defaultState` and calls `onValidationFailed`.
8413
+ */
8414
+ const validateOrSalvage = ({ rawValue, schema, defaultState, key, onValidationFailed, onValidationSalvaged, }) => {
8415
+ const parseResult = schema.safeParse(rawValue);
8416
+ if (parseResult.success) {
8417
+ return parseResult.data;
8418
+ }
8419
+ const salvaged = salvageState(schema, rawValue, defaultState);
8420
+ if (salvaged !== null && !esToolkit.isEqual(salvaged, defaultState)) {
8421
+ // eslint-disable-next-line no-console
8422
+ console.warn(`Partially invalid data in storage key "${key}" — salvaged valid fields, reset invalid fields to defaults.`, parseResult.error);
8423
+ onValidationSalvaged?.(parseResult.error, salvaged);
8424
+ return salvaged;
8425
+ }
8426
+ // eslint-disable-next-line no-console
8427
+ console.error(`Failed to parse and validate the state from storage key "${key}". Returning default state.`, parseResult.error);
8428
+ onValidationFailed?.(parseResult.error);
8429
+ return defaultState;
8430
+ };
8431
+ /**
8432
+ * Reads and deserializes a value from web storage, validating it against the provided schema.
8433
+ * Falls back to defaultState if the key is missing or data is corrupt.
8434
+ *
8435
+ * On partial validation failure for ZodObject schemas, salvages valid fields rather than
8436
+ * resetting the entire state to `defaultState`. See `salvageState` for salvage mechanics.
8437
+ * If the salvaged result is deeply equal to `defaultState` (i.e. every field was invalid),
8438
+ * the salvage is considered a total loss and `onValidationFailed` is called instead of
8439
+ * `onValidationSalvaged`.
8440
+ *
8441
+ * Deserialization failures (unparseable/corrupt raw data) are routed through
8442
+ * `onValidationFailed` so consumers are always notified when stored data is unusable.
8443
+ *
8444
+ * When `migration` is provided, detects versioned envelopes, runs the
8445
+ * migration pipeline, and validates the migrated result. Non-versioned consumers are
8446
+ * unaffected — the migration path is fully opt-in.
8447
+ *
8448
+ * @template TState - The type of the stored state.
8449
+ * @param params - Storage, key, default state, Zod schema, optional migration config, and optional callbacks.
8450
+ * @param params.storage - The web Storage instance.
8451
+ * @param params.key - The storage key.
8452
+ * @param params.defaultState - Fallback value when no stored data exists or data is corrupt.
8453
+ * @param params.schema - Zod schema to validate the deserialized data.
8454
+ * @param params.migration - Optional migration configuration (version, steps, fromKey).
8455
+ * @param params.onValidationFailed - Called when validation fails and no salvage is possible, or when
8456
+ * deserialization of the raw storage value fails entirely.
8457
+ * @param params.onValidationSalvaged - Called when validation fails but at least one stored field
8458
+ * was recovered (salvaged result differs from defaultState).
8459
+ * @returns {TState} The validated or salvaged state, or defaultState.
8460
+ */
8461
+ const readFromStorage = ({ storage, key, defaultState, schema, migration, onValidationFailed, onValidationSalvaged, }) => {
8462
+ const version = migration?.version;
8463
+ const steps = migration?.steps;
8464
+ const fromKey = migration?.fromKey;
8465
+ let raw = storage.getItem(key);
8466
+ if (raw === null && fromKey !== undefined) {
8467
+ raw = storage.getItem(fromKey);
8468
+ }
8469
+ if (raw === null) {
8292
8470
  return defaultState;
8293
8471
  }
8294
- const localStorageItemJSON = JSON.parse(localStorageItem);
8295
- if (!schema) {
8296
- return localStorageItemJSON;
8472
+ try {
8473
+ const deserialized = storageSerializer.deserialize(raw);
8474
+ if (version === undefined) {
8475
+ return validateOrSalvage({
8476
+ rawValue: deserialized,
8477
+ schema,
8478
+ defaultState,
8479
+ key,
8480
+ onValidationFailed,
8481
+ onValidationSalvaged,
8482
+ });
8483
+ }
8484
+ let storedVersion;
8485
+ let data;
8486
+ const envelopeResult = storageVersionEnvelopeSchema.safeParse(deserialized);
8487
+ if (envelopeResult.success) {
8488
+ storedVersion = envelopeResult.data.__version;
8489
+ data = envelopeResult.data.__data;
8490
+ }
8491
+ else {
8492
+ storedVersion = 0;
8493
+ data = deserialized;
8494
+ }
8495
+ if (steps !== undefined && storedVersion < version) {
8496
+ try {
8497
+ data = runMigrations({ data, fromVersion: storedVersion, toVersion: version, migrations: steps });
8498
+ }
8499
+ catch (migrationError) {
8500
+ // eslint-disable-next-line no-console
8501
+ console.error(`Migration failed for storage key "${key}" (v${storedVersion} → v${version}). Returning default state.`, migrationError);
8502
+ return defaultState;
8503
+ }
8504
+ }
8505
+ return validateOrSalvage({ rawValue: data, schema, defaultState, key, onValidationFailed, onValidationSalvaged });
8506
+ }
8507
+ catch (deserializationError) {
8508
+ // eslint-disable-next-line no-console
8509
+ console.error(`Failed to deserialize storage key "${key}". Returning default state.`, deserializationError);
8510
+ onValidationFailed?.(deserializationError);
8511
+ return defaultState;
8297
8512
  }
8298
- return validateState({ state: localStorageItemJSON, defaultState, schema });
8299
8513
  };
8300
8514
 
8301
8515
  /**
8302
- * Sets a value in the local storage with the specified key.
8303
- * Thin wrapper around localStorage.setItem() that is slightly more type safe
8304
- * Stringifies value automatically.
8305
- * Useful if you for some reason can't use the useLocalStorage hook for React lifecycle reasons
8516
+ * Validates the state using a Zod schema. Returns the parsed state on success,
8517
+ * or defaultState on failure (with error logging and optional callbacks).
8306
8518
  *
8307
- * @template TValue - The type of value to be stored.
8308
- * @param {string} key - The key under which to store the value.
8309
- * @param {TValue} value - The value to store in the local storage.
8519
+ * @template TState - The type of the state.
8520
+ * @param params - The state, schema, defaultState, and optional callbacks.
8521
+ * @param params.state - The raw state to validate.
8522
+ * @param params.schema - The Zod schema for validation.
8523
+ * @param params.defaultState - The fallback value on failure.
8524
+ * @param params.onValidationFailed - Optional error callback.
8525
+ * @param params.onValidationSuccessful - Optional success callback.
8526
+ * @returns {TState} The validated state or defaultState.
8310
8527
  */
8311
- const setLocalStorage = (key, value) => {
8312
- localStorage.setItem(key, JSON.stringify(value));
8528
+ const validateState = ({ state, schema, onValidationFailed, onValidationSuccessful, defaultState, }) => {
8529
+ const result = schema.safeParse(state);
8530
+ if (result.success) {
8531
+ onValidationSuccessful?.(result.data);
8532
+ return result.data;
8533
+ }
8534
+ // eslint-disable-next-line no-console
8535
+ console.error("Failed to parse and validate the state from storage.", result.error);
8536
+ onValidationFailed?.(result.error);
8537
+ return defaultState;
8313
8538
  };
8314
8539
 
8315
8540
  /**
8316
- * Custom hook for synchronizing a state variable with local storage,
8317
- * with optional schema validation and callbacks.
8541
+ * Keys reserved for internal storage envelope metadata.
8542
+ * Consumer state objects must not use these as top-level keys.
8543
+ */
8544
+ const RESERVED_STORAGE_KEYS = ["__data", "__version", "__serializer"];
8545
+ const warnIfReservedKeys = (value) => {
8546
+ if (value === null || typeof value !== "object" || Array.isArray(value))
8547
+ return;
8548
+ for (const key of RESERVED_STORAGE_KEYS) {
8549
+ if (key in value) {
8550
+ // eslint-disable-next-line no-console
8551
+ console.warn(`[useWebStorage] "${key}" is a reserved internal storage key and must not be used in state objects. ` +
8552
+ `It will conflict with the storage serialization envelope.`);
8553
+ }
8554
+ }
8555
+ };
8556
+ /**
8557
+ * Serializes and writes a value to web storage.
8558
+ * When a version is provided, wraps the data in a versioned envelope
8559
+ * before serializing so the migration pipeline can detect it on read.
8318
8560
  *
8319
- * @template TState - The type of the state variable.
8320
- * @param {LocalStorageParams<TState> & LocalStorageCallbacks & { state: TState }} params - The parameters for the useLocalStorageEffect hook.
8321
- * @returns {void}
8561
+ * @param storage - The Storage instance (localStorage / sessionStorage).
8562
+ * @param key - The storage key.
8563
+ * @param value - The value to serialize and store.
8564
+ * @param version - Optional schema version to stamp onto the stored payload.
8322
8565
  */
8323
- const useLocalStorageEffect = ({ key, state, defaultState, schema, onValidationFailed, onValidationSuccessful, }) => {
8324
- const prevState = usePrevious(state);
8325
- const optionsRef = react.useRef({ schema, defaultState, onValidationFailed, onValidationSuccessful });
8566
+ const writeToStorage = (storage, key, value, version) => {
8567
+ warnIfReservedKeys(value);
8568
+ const payload = version === undefined ? value : createStorageVersionEnvelope(value, version);
8569
+ storage.setItem(key, storageSerializer.serialize(payload));
8570
+ };
8571
+
8572
+ /**
8573
+ * Effect that synchronizes React state to web storage on every state change,
8574
+ * validating through the schema before writing.
8575
+ *
8576
+ * @template TState - The type of the state.
8577
+ * @param params - Storage, key, state, schema, and optional callbacks.
8578
+ * @param params.storage - The web Storage instance.
8579
+ * @param params.key - The storage key.
8580
+ * @param params.state - The current state value.
8581
+ * @param params.defaultState - The fallback value on validation failure.
8582
+ * @param params.schema - Zod schema for validation.
8583
+ * @param params.version - Optional schema version passed to writeToStorage.
8584
+ * @param params.onValidationFailed - Optional error callback.
8585
+ * @param params.onValidationSuccessful - Optional success callback.
8586
+ * @param params.skipWriteRef - Optional ref flag to skip one write cycle (key-change guard).
8587
+ */
8588
+ const useStorageSyncEffect = ({ storage, key, state, defaultState, schema, version, onValidationFailed, onValidationSuccessful, skipWriteRef, }) => {
8589
+ const optionsRef = react.useRef({ schema, defaultState, onValidationFailed, onValidationSuccessful, version });
8326
8590
  react.useEffect(() => {
8327
- optionsRef.current = { schema, defaultState, onValidationFailed, onValidationSuccessful };
8328
- }, [schema, defaultState, onValidationFailed, onValidationSuccessful]);
8591
+ optionsRef.current = { schema, defaultState, onValidationFailed, onValidationSuccessful, version };
8592
+ }, [schema, defaultState, onValidationFailed, onValidationSuccessful, version]);
8329
8593
  react.useEffect(() => {
8330
- if (JSON.stringify(prevState) === JSON.stringify(state)) {
8594
+ if (skipWriteRef?.current) {
8595
+ skipWriteRef.current = false;
8331
8596
  return;
8332
8597
  }
8333
- const { schema: refSchema, defaultState: refDefaultState, onValidationFailed: refOnValidationFailed, onValidationSuccessful: refOnValidationSuccessful, } = optionsRef.current;
8334
- if (refSchema) {
8335
- validateState({
8336
- state,
8337
- schema: refSchema,
8338
- defaultState: refDefaultState,
8339
- onValidationFailed: error => {
8340
- // eslint-disable-next-line no-console
8341
- console.error(`State validation failed. Resetting local storage to default state: ${refDefaultState}.`, error);
8342
- localStorage.removeItem(key);
8343
- refOnValidationFailed?.(error);
8344
- },
8345
- onValidationSuccessful: data => {
8346
- setLocalStorage(key, data);
8347
- refOnValidationSuccessful?.(data);
8348
- },
8349
- });
8350
- }
8351
- else {
8352
- const stringifiedState = JSON.stringify(state);
8353
- localStorage.setItem(key, stringifiedState);
8354
- }
8355
- }, [state, key, prevState]);
8598
+ const { schema: currentSchema, defaultState: currentDefault, onValidationFailed: currentOnFailed, onValidationSuccessful: currentOnSuccess, version: currentVersion, } = optionsRef.current;
8599
+ validateState({
8600
+ state,
8601
+ schema: currentSchema,
8602
+ defaultState: currentDefault,
8603
+ onValidationFailed: error => {
8604
+ // eslint-disable-next-line no-console
8605
+ console.error(`State validation failed for key "${key}". Removing from storage.`, error);
8606
+ storage.removeItem(key);
8607
+ currentOnFailed?.(error);
8608
+ },
8609
+ onValidationSuccessful: data => {
8610
+ writeToStorage(storage, key, data, currentVersion);
8611
+ currentOnSuccess?.(data);
8612
+ },
8613
+ });
8614
+ }, [storage, key, state, skipWriteRef]);
8356
8615
  };
8357
8616
 
8358
8617
  /**
8359
- * Works like a normal useState, but saves to local storage and has optional schema validation.
8618
+ * Internal storage-agnostic hook that backs useLocalStorage and useSessionStorage.
8360
8619
  *
8361
- * @template TState - The type of the value stored in local storage.
8362
- * @param {Omit<LocalStorageParams<TState>, "state"> & LocalStorageCallbacks} options - The options for useLocalStorage.
8363
- * @returns {[TState, Dispatch<SetStateAction<TState>>, () => void]} - A tuple containing the current value, a function to update the value, and a function to remove the value from local storage.
8620
+ * @template TState - The type of the stored state.
8621
+ * @param storage - The web Storage instance.
8622
+ * @param options - Key, defaultState, schema, and optional callbacks.
8623
+ * @param options.key - The storage key.
8624
+ * @param options.defaultState - Fallback value when no stored data exists.
8625
+ * @param options.schema - Zod schema for validation.
8626
+ * @param options.migration - Optional migration configuration (version, steps, fromKey).
8627
+ * @param options.onValidationFailed - Called when validation fails and no salvage is possible.
8628
+ * @param options.onValidationSuccessful - Called when validation succeeds during the write-sync.
8629
+ * @param options.onValidationSalvaged - Called when validation fails but partial state was recovered.
8630
+ * @returns {Array} A tuple of [state, setState, reset].
8364
8631
  */
8365
- const useLocalStorage = ({ key, defaultState, schema, onValidationFailed, onValidationSuccessful, }) => {
8632
+ const useWebStorage = (storage, { key, defaultState, schema, migration, onValidationFailed, onValidationSuccessful, onValidationSalvaged, }) => {
8366
8633
  if (!key) {
8367
- throw new Error("useLocalStorage key must be defined");
8634
+ throw new Error("useWebStorage: key must be a non-empty string");
8368
8635
  }
8369
- const [state, setState] = react.useState(() => initLocalStorageState({ key, defaultState, schema }));
8370
- const prevKey = usePrevious(key);
8636
+ const [state, setState] = react.useState(() => readFromStorage({ storage, key, defaultState, schema, migration, onValidationFailed, onValidationSalvaged }));
8637
+ const prevKeyRef = react.useRef(key);
8371
8638
  const defaultStateRef = react.useRef(defaultState);
8639
+ const schemaRef = react.useRef(schema);
8640
+ const migrationRef = react.useRef(migration);
8641
+ const onValidationFailedRef = react.useRef(onValidationFailed);
8642
+ const onValidationSalvagedRef = react.useRef(onValidationSalvaged);
8372
8643
  react.useEffect(() => {
8373
8644
  defaultStateRef.current = defaultState;
8374
8645
  }, [defaultState]);
8375
- const schemaRef = react.useRef(schema);
8376
8646
  react.useEffect(() => {
8377
8647
  schemaRef.current = schema;
8378
8648
  }, [schema]);
8379
8649
  react.useEffect(() => {
8380
- if (key !== prevKey) {
8381
- setState(initLocalStorageState({ key, defaultState: defaultStateRef.current, schema: schemaRef.current }));
8382
- }
8383
- }, [key, prevKey]);
8384
- const localStorageProps = react.useMemo(() => ({
8650
+ migrationRef.current = migration;
8651
+ }, [migration]);
8652
+ react.useEffect(() => {
8653
+ onValidationFailedRef.current = onValidationFailed;
8654
+ }, [onValidationFailed]);
8655
+ react.useEffect(() => {
8656
+ onValidationSalvagedRef.current = onValidationSalvaged;
8657
+ }, [onValidationSalvaged]);
8658
+ const keyChangedRef = react.useRef(false);
8659
+ react.useEffect(() => {
8660
+ if (key !== prevKeyRef.current) {
8661
+ prevKeyRef.current = key;
8662
+ keyChangedRef.current = true;
8663
+ setState(readFromStorage({
8664
+ storage,
8665
+ key,
8666
+ defaultState: defaultStateRef.current,
8667
+ schema: schemaRef.current,
8668
+ migration: migrationRef.current,
8669
+ onValidationFailed: onValidationFailedRef.current,
8670
+ onValidationSalvaged: onValidationSalvagedRef.current,
8671
+ }));
8672
+ }
8673
+ }, [key, storage]);
8674
+ useStorageSyncEffect({
8675
+ storage,
8385
8676
  key,
8386
8677
  state,
8387
8678
  defaultState,
8388
8679
  schema,
8680
+ version: migration?.version,
8389
8681
  onValidationFailed,
8390
8682
  onValidationSuccessful,
8391
- }), [key, state, defaultState, schema, onValidationFailed, onValidationSuccessful]);
8392
- useLocalStorageEffect(localStorageProps);
8683
+ skipWriteRef: keyChangedRef,
8684
+ });
8685
+ react.useEffect(() => {
8686
+ const fromKey = migration?.fromKey;
8687
+ if (fromKey && storage.getItem(key) !== null) {
8688
+ storage.removeItem(fromKey);
8689
+ }
8690
+ }, [migration?.fromKey, storage, key]);
8393
8691
  const reset = react.useCallback(() => {
8394
8692
  setState(defaultStateRef.current);
8395
8693
  }, []);
8396
- return react.useMemo(() => [state, setState, reset], [state, setState, reset]);
8694
+ return react.useMemo(() => [state, setState, reset], [state, reset]);
8397
8695
  };
8398
8696
 
8399
8697
  /**
8400
- * Works like a normal useReducer, but saves to local storage and has optional schema validation.
8698
+ * Works like useState, but persists to localStorage with Zod schema validation
8699
+ * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
8401
8700
  *
8402
- * @template TState - The type of the state.
8403
- * @template TAction - The type of the action.
8404
- * @param {LocalStorageParams<TState> & LocalStorageCallbacks} params - The parameters for the useLocalStorageReducer function.
8405
- * @returns {[TState, Dispatch<TAction>]} - A tuple containing the state and the dispatch function.
8701
+ * @template TState - The type of the stored state.
8702
+ * @param options - Key, defaultState, schema, and optional callbacks.
8703
+ * @param options.key - The storage key.
8704
+ * @param options.defaultState - Fallback value when no stored data exists.
8705
+ * @param options.schema - Zod schema for validation.
8706
+ * @param options.onValidationFailed - Optional error callback.
8707
+ * @param options.onValidationSuccessful - Optional success callback.
8708
+ * @returns {Array} A tuple of [state, setState, reset].
8406
8709
  */
8407
- const useLocalStorageReducer = ({ key, defaultState, reducer, schema, onValidationFailed, onValidationSuccessful, }) => {
8710
+ const useLocalStorage = (options) => useWebStorage(globalThis.localStorage, options);
8711
+
8712
+ /**
8713
+ * Internal storage-agnostic reducer hook that backs useLocalStorageReducer and useSessionStorageReducer.
8714
+ *
8715
+ * @template TState - The type of the stored state.
8716
+ * @template TAction - The type of the reducer action.
8717
+ * @param storage - The web Storage instance.
8718
+ * @param options - Key, defaultState, schema, reducer, and optional callbacks.
8719
+ * @param options.key - The storage key.
8720
+ * @param options.defaultState - Fallback value when no stored data exists.
8721
+ * @param options.schema - Zod schema for validation.
8722
+ * @param options.migration - Optional migration configuration (version, steps, fromKey).
8723
+ * @param options.reducer - The reducer function.
8724
+ * @param options.onValidationFailed - Called when validation fails and no salvage is possible.
8725
+ * @param options.onValidationSuccessful - Called when validation succeeds during the write-sync.
8726
+ * @param options.onValidationSalvaged - Called when validation fails but partial state was recovered.
8727
+ * @returns {Array} A tuple of [state, dispatch].
8728
+ */
8729
+ const useWebStorageReducer = (storage, { key, defaultState, schema, migration, reducer, onValidationFailed, onValidationSuccessful, onValidationSalvaged, }) => {
8408
8730
  if (!key) {
8409
- throw new Error("useLocalStorage key may not be falsy");
8731
+ throw new Error("useWebStorageReducer: key must be a non-empty string");
8410
8732
  }
8411
- const [state, dispatch] = react.useReducer(reducer, defaultState, () => initLocalStorageState({ key, defaultState, schema }));
8412
- useLocalStorageEffect({ key, state, defaultState, schema, onValidationFailed, onValidationSuccessful });
8413
- return [state, dispatch];
8733
+ const internalReducer = (prevState, internal) => {
8734
+ switch (internal.kind) {
8735
+ case "reinit":
8736
+ return internal.state;
8737
+ case "user":
8738
+ return reducer(prevState, internal.action);
8739
+ default:
8740
+ return internal;
8741
+ }
8742
+ };
8743
+ const [state, internalDispatch] = react.useReducer(internalReducer, undefined, () => readFromStorage({ storage, key, defaultState, schema, migration, onValidationFailed, onValidationSalvaged }));
8744
+ const userDispatch = react.useCallback((action) => internalDispatch({ kind: "user", action }), []);
8745
+ const prevKeyRef = react.useRef(key);
8746
+ const defaultStateRef = react.useRef(defaultState);
8747
+ const schemaRef = react.useRef(schema);
8748
+ const migrationRef = react.useRef(migration);
8749
+ const onValidationFailedRef = react.useRef(onValidationFailed);
8750
+ const onValidationSalvagedRef = react.useRef(onValidationSalvaged);
8751
+ react.useEffect(() => {
8752
+ defaultStateRef.current = defaultState;
8753
+ }, [defaultState]);
8754
+ react.useEffect(() => {
8755
+ schemaRef.current = schema;
8756
+ }, [schema]);
8757
+ react.useEffect(() => {
8758
+ migrationRef.current = migration;
8759
+ }, [migration]);
8760
+ react.useEffect(() => {
8761
+ onValidationFailedRef.current = onValidationFailed;
8762
+ }, [onValidationFailed]);
8763
+ react.useEffect(() => {
8764
+ onValidationSalvagedRef.current = onValidationSalvaged;
8765
+ }, [onValidationSalvaged]);
8766
+ const keyChangedRef = react.useRef(false);
8767
+ react.useEffect(() => {
8768
+ if (key !== prevKeyRef.current) {
8769
+ prevKeyRef.current = key;
8770
+ keyChangedRef.current = true;
8771
+ internalDispatch({
8772
+ kind: "reinit",
8773
+ state: readFromStorage({
8774
+ storage,
8775
+ key,
8776
+ defaultState: defaultStateRef.current,
8777
+ schema: schemaRef.current,
8778
+ migration: migrationRef.current,
8779
+ onValidationFailed: onValidationFailedRef.current,
8780
+ onValidationSalvaged: onValidationSalvagedRef.current,
8781
+ }),
8782
+ });
8783
+ }
8784
+ }, [key, storage]);
8785
+ useStorageSyncEffect({
8786
+ storage,
8787
+ key,
8788
+ state,
8789
+ defaultState,
8790
+ schema,
8791
+ version: migration?.version,
8792
+ onValidationFailed,
8793
+ onValidationSuccessful,
8794
+ skipWriteRef: keyChangedRef,
8795
+ });
8796
+ react.useEffect(() => {
8797
+ const fromKey = migration?.fromKey;
8798
+ if (fromKey && storage.getItem(key) !== null) {
8799
+ storage.removeItem(fromKey);
8800
+ }
8801
+ }, [migration?.fromKey, storage, key]);
8802
+ return react.useMemo(() => [state, userDispatch], [state, userDispatch]);
8414
8803
  };
8415
8804
 
8805
+ /**
8806
+ * Works like useReducer, but persists to localStorage with Zod schema validation
8807
+ * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
8808
+ *
8809
+ * @template TState - The type of the stored state.
8810
+ * @template TAction - The type of the reducer action.
8811
+ * @param options - Key, defaultState, schema, reducer, and optional callbacks.
8812
+ * @param options.key - The storage key.
8813
+ * @param options.defaultState - Fallback value when no stored data exists.
8814
+ * @param options.schema - Zod schema for validation.
8815
+ * @param options.reducer - The reducer function.
8816
+ * @param options.onValidationFailed - Optional error callback.
8817
+ * @param options.onValidationSuccessful - Optional success callback.
8818
+ * @returns {Array} A tuple of [state, dispatch].
8819
+ */
8820
+ const useLocalStorageReducer = (options) => useWebStorageReducer(globalThis.localStorage, options);
8821
+
8822
+ /**
8823
+ * Works like useState, but persists to sessionStorage with Zod schema validation
8824
+ * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
8825
+ *
8826
+ * @template TState - The type of the stored state.
8827
+ * @param options - Key, defaultState, schema, and optional callbacks.
8828
+ * @param options.key - The storage key.
8829
+ * @param options.defaultState - Fallback value when no stored data exists.
8830
+ * @param options.schema - Zod schema for validation.
8831
+ * @param options.onValidationFailed - Optional error callback.
8832
+ * @param options.onValidationSuccessful - Optional success callback.
8833
+ * @returns {Array} A tuple of [state, setState, reset].
8834
+ */
8835
+ const useSessionStorage = (options) => useWebStorage(globalThis.sessionStorage, options);
8836
+
8837
+ /**
8838
+ * Works like useReducer, but persists to sessionStorage with Zod schema validation
8839
+ * and superjson serialization (supports Date, Map, Set, BigInt, etc.).
8840
+ *
8841
+ * @template TState - The type of the stored state.
8842
+ * @template TAction - The type of the reducer action.
8843
+ * @param options - Key, defaultState, schema, reducer, and optional callbacks.
8844
+ * @param options.key - The storage key.
8845
+ * @param options.defaultState - Fallback value when no stored data exists.
8846
+ * @param options.schema - Zod schema for validation.
8847
+ * @param options.reducer - The reducer function.
8848
+ * @param options.onValidationFailed - Optional error callback.
8849
+ * @param options.onValidationSuccessful - Optional success callback.
8850
+ * @returns {Array} A tuple of [state, dispatch].
8851
+ */
8852
+ const useSessionStorageReducer = (options) => useWebStorageReducer(globalThis.sessionStorage, options);
8853
+
8416
8854
  const OVERSCAN = 10;
8417
8855
  const DEFAULT_ROW_HEIGHT = 50;
8418
8856
  /**
@@ -9406,6 +9844,26 @@ const useModifierKey = ({ exclude = [] } = {}) => {
9406
9844
  return isModifierPressed;
9407
9845
  };
9408
9846
 
9847
+ /**
9848
+ * The usePrevious hook is a useful tool for tracking the previous value of a variable in a functional component. This can be particularly handy in scenarios where it is necessary to compare the current value with the previous one, such as triggering actions or rendering based on changes.
9849
+ *
9850
+ * @example
9851
+ * const [color, setColor] = React.useState(getRandomColor());
9852
+ const previousColor = usePrevious(color);
9853
+ */
9854
+ const usePrevious = (value) => {
9855
+ const ref = react.useRef(undefined);
9856
+ react.useEffect(() => {
9857
+ ref.current = value;
9858
+ }, [value]);
9859
+ const wrapper = react.useMemo(() => ({
9860
+ get previous() {
9861
+ return ref.current;
9862
+ },
9863
+ }), []);
9864
+ return wrapper.previous;
9865
+ };
9866
+
9409
9867
  const defaultPageSize = 50;
9410
9868
  /**
9411
9869
  * Custom hook for handling Relay pagination in tables.
@@ -9914,6 +10372,8 @@ exports.useResize = useResize;
9914
10372
  exports.useScrollBlock = useScrollBlock;
9915
10373
  exports.useScrollDetection = useScrollDetection;
9916
10374
  exports.useSelfUpdatingRef = useSelfUpdatingRef;
10375
+ exports.useSessionStorage = useSessionStorage;
10376
+ exports.useSessionStorageReducer = useSessionStorageReducer;
9917
10377
  exports.useTextSearch = useTextSearch;
9918
10378
  exports.useTimeout = useTimeout;
9919
10379
  exports.useViewportBreakpoints = useViewportBreakpoints;