diff --git a/index.ts b/index.ts index d00a040..887b340 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,11 @@ import type { SetStateAction } from "react"; -import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import { DbStorage } from "local-db-storage"; const dbStorage = new DbStorage({ @@ -45,6 +51,7 @@ function useDbStorage( defaultValue: T | undefined, optimistic: boolean, ): DbState { + const [ready] = useState(() => createReady()); const value = useSyncExternalStore( // useSyncExternalStore.subscribe useCallback( @@ -75,29 +82,38 @@ function useDbStorage( const setState = useCallback( (newValue: SetStateAction): Promise => { - const hasPrev = syncData.has(key); - const prev = syncData.has(key) - ? (syncData.get(key) as T | undefined) - : defaultValue; - const next = - newValue instanceof Function ? newValue(prev) : newValue; - if (optimistic) { - syncData.set(key, next); - triggerCallbacks(key); - return dbStorage.setItem(key, next).catch(() => { - if (hasPrev) { - syncData.set(key, prev); - } else { - syncData.delete(key); - } - triggerCallbacks(key); - }); - } else { - return dbStorage.setItem(key, next).then(() => { + const set = (): Promise => { + const hasPrev = syncData.has(key); + const prev = syncData.has(key) + ? (syncData.get(key) as T | undefined) + : defaultValue; + const next = + newValue instanceof Function ? newValue(prev) : newValue; + + if (optimistic) { syncData.set(key, next); triggerCallbacks(key); - }); + return dbStorage.setItem(key, next).catch(() => { + if (hasPrev) { + syncData.set(key, prev); + } else { + syncData.delete(key); + } + }); + } else { + return dbStorage + .setItem(key, next) + .then(() => { + syncData.set(key, next); + triggerCallbacks(key); + }) + .catch(() => {}); + } + }; + if (!ready.is) { + return ready.promise.then(() => set()) } + return set(); }, [key], ); @@ -105,6 +121,7 @@ function useDbStorage( const removeItem = useCallback(() => { const prev = syncData.get(key); const hasPrev = syncData.has(key); + if (optimistic) { syncData.delete(key); triggerCallbacks(key); @@ -115,13 +132,36 @@ function useDbStorage( } }); } else { - return dbStorage.removeItem(key).then(() => { - syncData.delete(key); - triggerCallbacks(key); - }); + return dbStorage + .removeItem(key) + .then(() => { + syncData.delete(key); + triggerCallbacks(key); + }) + .catch(() => {}); } }, [key]); + const [, forceRender] = useState(0); + useEffect(() => { + if (ready.is) return; + let disposed = false; + dbStorage + .getItem(key) + .then((value) => { + ready.resolve(); + if (!disposed && syncData.get(key) !== value) { + syncData.set(key, value); + forceRender((prev) => prev + 1); + } + }) + .catch(() => {}) + .finally(() => ready.resolve()); + return () => { + disposed = true; + }; + }); + return useMemo( () => [value, setState, removeItem], [value, setState, removeItem], @@ -135,3 +175,25 @@ function triggerCallbacks(key: string): void { callback(key); } } + +function createReady(): { + promise: Promise; + resolve: () => void; + is: boolean; +} { + let resolveFn: () => void; + let completed = false; + const promise = new Promise((resolve) => { + resolveFn = () => { + completed = true; + resolve(); + }; + }); + return { + promise, + resolve: resolveFn!, + get is() { + return completed; + }, + }; +} diff --git a/test.ts b/test.ts index e5249d5..2389492 100644 --- a/test.ts +++ b/test.ts @@ -2,7 +2,7 @@ import "fake-indexeddb/auto"; import { describe, expect, test, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; -import useDb, { type UseDbOptions } from "./index.js"; +import useDb from "./index.js"; import { DbStorage } from "local-db-storage"; describe("use-db", () => { @@ -31,7 +31,7 @@ describe("use-db", () => { expect(todos).toStrictEqual(["first", "second"]); }); - test("updates state", () => { + test("updates state", async () => { const key = crypto.randomUUID(); const { result } = renderHook(() => useDb(key, { @@ -39,6 +39,8 @@ describe("use-db", () => { }), ); + await wait(5); + act(() => { const setTodos = result.current[1]; setTodos(["third", "forth"]); @@ -48,7 +50,7 @@ describe("use-db", () => { expect(todos).toStrictEqual(["third", "forth"]); }); - test("updates state with callback function", () => { + test("updates state with callback function", async () => { const key = crypto.randomUUID(); const { result } = renderHook(() => useDb(key, { @@ -56,6 +58,8 @@ describe("use-db", () => { }), ); + await wait(5); + act(() => { const setTodos = result.current[1]; @@ -74,10 +78,12 @@ describe("use-db", () => { }), ); + await wait(5); + { - await act(() => { + act(() => { const setTodos = result.current[1]; - return setTodos(["third", "forth"]); + setTodos(["third", "forth"]); }); const [todos] = result.current; expect(todos).toStrictEqual(["third", "forth"]); @@ -93,7 +99,7 @@ describe("use-db", () => { } }); - test("persists state across hook re-renders", () => { + test("persists state across hook re-renders", async () => { const key = crypto.randomUUID(); const { result, rerender } = renderHook(() => useDb(key, { @@ -101,6 +107,8 @@ describe("use-db", () => { }), ); + await wait(5); + act(() => { const setTodos = result.current[1]; setTodos(["third", "fourth"]); @@ -112,7 +120,7 @@ describe("use-db", () => { expect(todos).toStrictEqual(["third", "fourth"]); }); - test("handles complex objects", () => { + test("handles complex objects", async () => { const complexObject = { nested: { array: [1, 2, 3], value: "test" }, }; @@ -121,6 +129,8 @@ describe("use-db", () => { useDb(key, { defaultValue: complexObject }), ); + await wait(5); + const [storedObject] = result.current; expect(storedObject).toEqual(complexObject); @@ -138,10 +148,12 @@ describe("use-db", () => { }); }); - test("handles undefined as a valid state", () => { + test("handles undefined as a valid state", async () => { const key = crypto.randomUUID(); const { result } = renderHook(() => useDb(key)); + await wait(5); + const [initialState] = result.current; expect(initialState).toBeUndefined(); @@ -168,17 +180,21 @@ describe("use-db", () => { unmount(); }); - test("set state throws an error", () => { + test("set state throws an error", async () => { const key = crypto.randomUUID(); - const { result } = renderHook(() => useDb(key)); + const hook = renderHook(() => useDb(key)); + + // no idea why this is needed. + // otherwise, it throws "unhadled error -- Vitest caught 1 error during the test run." + await wait(5); vi.spyOn(DbStorage.prototype, "setItem").mockReturnValue( Promise.reject("QuotaExceededError"), ); - act(() => { - const setState = result.current[1]; - setState("defined"); + await act(() => { + const [, setState] = hook.result.current; + return setState("defined"); }); }); @@ -227,6 +243,29 @@ describe("use-db", () => { const [number] = result.current; expect(number).toBe(2); }); + + // https://github.com/astoilkov/use-db/issues/1 + test("cannot read state from IndexDB after page refresh", async () => { + const key = crypto.randomUUID(); + + const dbStorage = new DbStorage({ + name: "node_modules/use-db", + }); + await dbStorage.setItem(key, ["first", "second"]); + + const hook = renderHook(() => useDb(key)); + const todos = await vi.waitUntil( + () => { + const [todos] = hook.result.current; + return todos; + }, + { + timeout: 100, + interval: 10, + }, + ); + expect(todos).toStrictEqual(["first", "second"]); + }); }); describe("non-optimistic", () => { @@ -290,3 +329,9 @@ describe("use-db", () => { }); }); }); + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}