Skip to content

Commit

Permalink
✨ new storage values + new memoryFallback option
Browse files Browse the repository at this point in the history
  • Loading branch information
astoilkov committed Sep 4, 2024
1 parent ae18555 commit 61a4f1b
Show file tree
Hide file tree
Showing 5 changed files with 85 additions and 29 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@
{
"name": "import *",
"path": "index.js",
"limit": "1.7 kB",
"limit": "1.9 kB",
"brotli": false
},
{
"name": "import *",
"path": "index.js",
"limit": "750 B"
"limit": "800 B"
}
]
}
28 changes: 25 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,21 @@ The default value. You can think of it as the same as `useState(defaultValue)`.

#### `options.storage`

Type: [`Storage`](https://developer.mozilla.org/en-US/docs/Web/API/Storage)
Type: `"local" | "session" | Storage`

Default: `localStorage` (if `localStorage` is disabled in Safari it fallbacks to `sessionStorage`).
Default: `"local"`

You can set `localStorage`, `sessionStorage`, or other any [`Storage`](https://developer.mozilla.org/en-US/docs/Web/API/Storage) compatible class like [`memorystorage`](https://github.com/download/memorystorage).
You can set `localStorage`, `sessionStorage`, or other any [`Storage`](https://developer.mozilla.org/en-US/docs/Web/API/Storage) compatible class.

_Note:_ Prefer to use the `"local"` and `"session"` literals instead of `localStorage` or `sessionStorage` objects directly, as both can throw an error when accessed if user has configured the browser to not store any site data.

#### `options.memoryFallback`

Type: `boolean`

Default: `true`

If `localStorage` or `sessionStorage` throw an error when accessed (possible when the browser is configured to not store any site data on device), the library uses a memory storage fallback so at least it allows for the hook to be functional. You can disable this behavior by setting this option to `false`.

#### `options.sync`

Expand All @@ -158,6 +168,18 @@ Default: `JSON`

JSON does not serialize `Date`, `Regex`, or `BigInt` data. You can pass in [superjson](https://github.com/blitz-js/superjson) or other `JSON`-compatible serialization library for more advanced serialization.

#### `memoryStorage`

The library exports a `memoryStorage` object that's used when the `memoryFallback` option is set to `true` (the default).

```ts
import { memoryStorage } from 'use-storage-state'

memoryStorage.getItem(key)
memoryStorage.setItem(key, value)
memoryStorage.removeItem(key)
```

## Related

- [`use-local-storage-state`](https://github.com/astoilkov/use-local-storage-state) — Similar to this hook but for `localStorage` only.
Expand Down
52 changes: 36 additions & 16 deletions src/useStorageState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {

export type StorageStateOptions<T> = {
defaultValue?: T | (() => T);
storage?: StorageLike;
storage?: "local" | "session" | StorageLike | undefined;
memoryFallback?: boolean;
sync?: boolean;
storeDefault?: boolean;
serializer?: {
Expand Down Expand Up @@ -52,13 +53,24 @@ export default function useStorageState<T = undefined>(
): StorageState<T | undefined> {
const serializer = options?.serializer;
const [defaultValue] = useState(options?.defaultValue);
const storageOption =
options !== undefined && "storage" in options
? options.storage
: "local";
const storageObj =
storageOption === "local"
? goodTry(() => localStorage)
: storageOption === "session"
? goodTry(() => sessionStorage)
: storageOption;
const resolvedStorage =
storageObj === undefined && options?.memoryFallback !== false
? memoryStorage
: storageObj;
return useStorage(
key,
defaultValue,
options?.storage ??
goodTry(() => localStorage) ??
goodTry(() => sessionStorage) ??
memoryStorage,
resolvedStorage,
options?.sync,
options?.storeDefault,
serializer?.parse,
Expand All @@ -69,7 +81,7 @@ export default function useStorageState<T = undefined>(
function useStorage<T>(
key: string,
defaultValue: T | undefined,
storage: StorageLike,
storage: StorageLike | undefined,
sync: boolean = true,
storeDefault: boolean = false,
parse: (value: string) => unknown = parseJSON,
Expand Down Expand Up @@ -103,7 +115,10 @@ function useStorage<T>(

// useSyncExternalStore.getSnapshot
() => {
const string = goodTry(() => storage.getItem(key)) ?? null;
const string =
storage === undefined
? null
: goodTry(() => storage.getItem(key)) ?? null;

if (string !== storageItem.current.string) {
let parsed: T | undefined;
Expand All @@ -120,12 +135,15 @@ function useStorage<T>(

storageItem.current.string = string;

// store default value in localStorage:
// - initial issue: https://github.com/astoilkov/use-local-storage-state/issues/26
// issues that were caused by incorrect initial and secondary implementations:
// - https://github.com/astoilkov/use-local-storage-state/issues/30
// - https://github.com/astoilkov/use-local-storage-state/issues/33
if (storeDefault && defaultValue !== undefined && string === null) {
// related issues:
// - https://github.com/astoilkov/use-local-storage-state/issues/26
// - https://github.com/astoilkov/use-storage-state/issues/1
if (
storeDefault &&
string === null &&
storage !== undefined &&
defaultValue !== undefined
) {
// reasons for `localStorage` to throw an error:
// - maximum quota is exceeded
// - under Mobile Safari (since iOS 5) when the user enters private mode
Expand Down Expand Up @@ -160,16 +178,18 @@ function useStorage<T>(
// `localStorage.setItem()` will throw
// - trying to access `localStorage` object when cookies are disabled in Safari throws
// "SecurityError: The operation is insecure."
goodTry(() => storage.setItem(key, stringify(value)));
goodTry(() => storage?.setItem(key, stringify(value)));

triggerCallbacks(key);
},
[key, storage, stringify],
);

const removeItem = useCallback(() => {
goodTry(() => storage.removeItem(key));
triggerCallbacks(key);
if (storage !== undefined) {
goodTry(() => storage.removeItem(key));
triggerCallbacks(key);
}
}, [key, storage]);

// - syncs change across tabs, windows, iframes
Expand Down
29 changes: 21 additions & 8 deletions test/browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { act, render, renderHook } from "@testing-library/react";
import React, { useEffect, useLayoutEffect, useMemo } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import useStorageState from "../src/useStorageState.js";
import memoryStorage from "../src/memoryStorage";
import memoryStorage from "../src/memoryStorage.js";

beforeEach(() => {
// Throw an error when `console.error()` is called. This is especially useful in a React tests
Expand Down Expand Up @@ -690,17 +690,14 @@ describe("useLocalStorageState()", () => {
describe("storage option", () => {
test("switching between two storage values", () => {
const { result, rerender } = renderHook(
({ memory }: { memory: "session" | "local" }) =>
({ storage }: { storage: "session" | "local" }) =>
useStorageState<number>("count", {
defaultValue: 0,
storeDefault: true,
storage:
memory === "session"
? sessionStorage
: localStorage,
storage: storage,
}),
{
initialProps: { memory: "session" },
initialProps: { storage: "session" },
},
);

Expand All @@ -715,7 +712,7 @@ describe("useLocalStorageState()", () => {
expect(sessionStorage.getItem("count")).toBe("1");
expect(localStorage.getItem("count")).toBe(null);

rerender({ memory: "local" });
rerender({ storage: "local" });

expect(sessionStorage.getItem("count")).toBe("1");
expect(localStorage.getItem("count")).toBe("0");
Expand All @@ -729,6 +726,22 @@ describe("useLocalStorageState()", () => {
expect(localStorage.getItem("count")).toBe("2");
});

test("undefined with memoryFallback: false", () => {
const { result } = renderHook(() =>
useStorageState("count", {
storage: undefined,
memoryFallback: false,
defaultValue: 99,
}),
);
const [, setCount] = result.current;

act(() => {
setCount(100);
});
expect(result.current[0]).toBe(99);
});

test("memoryStorage", () => {
const { result } = renderHook(() =>
useStorageState<number>("count", {
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"jsx": "react",
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
Expand Down

0 comments on commit 61a4f1b

Please sign in to comment.