Skip to content

Commit

Permalink
🐛 cannot read state from IndexDB after page refresh, resolve #1
Browse files Browse the repository at this point in the history
  • Loading branch information
astoilkov committed Nov 22, 2024
1 parent b5cb87e commit b2b23f6
Show file tree
Hide file tree
Showing 2 changed files with 145 additions and 38 deletions.
112 changes: 87 additions & 25 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -45,6 +51,7 @@ function useDbStorage<T>(
defaultValue: T | undefined,
optimistic: boolean,
): DbState<T | undefined> {
const [ready] = useState(() => createReady());
const value = useSyncExternalStore(
// useSyncExternalStore.subscribe
useCallback(
Expand Down Expand Up @@ -75,36 +82,46 @@ function useDbStorage<T>(

const setState = useCallback(
(newValue: SetStateAction<T | undefined>): Promise<void> => {
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<void> => {
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],
);

const removeItem = useCallback(() => {
const prev = syncData.get(key);
const hasPrev = syncData.has(key);

if (optimistic) {
syncData.delete(key);
triggerCallbacks(key);
Expand All @@ -115,13 +132,36 @@ function useDbStorage<T>(
}
});
} 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],
Expand All @@ -135,3 +175,25 @@ function triggerCallbacks(key: string): void {
callback(key);
}
}

function createReady(): {
promise: Promise<void>;
resolve: () => void;
is: boolean;
} {
let resolveFn: () => void;
let completed = false;
const promise = new Promise<void>((resolve) => {
resolveFn = () => {
completed = true;
resolve();
};
});
return {
promise,
resolve: resolveFn!,
get is() {
return completed;
},
};
}
71 changes: 58 additions & 13 deletions test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -31,14 +31,16 @@ describe("use-db", () => {
expect(todos).toStrictEqual(["first", "second"]);
});

test("updates state", () => {
test("updates state", async () => {
const key = crypto.randomUUID();
const { result } = renderHook(() =>
useDb(key, {
defaultValue: ["first", "second"],
}),
);

await wait(5);

act(() => {
const setTodos = result.current[1];
setTodos(["third", "forth"]);
Expand All @@ -48,14 +50,16 @@ 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, {
defaultValue: ["first", "second"],
}),
);

await wait(5);

act(() => {
const setTodos = result.current[1];

Expand All @@ -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"]);
Expand All @@ -93,14 +99,16 @@ 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, {
defaultValue: ["first", "second"],
}),
);

await wait(5);

act(() => {
const setTodos = result.current[1];
setTodos(["third", "fourth"]);
Expand All @@ -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" },
};
Expand All @@ -121,6 +129,8 @@ describe("use-db", () => {
useDb(key, { defaultValue: complexObject }),
);

await wait(5);

const [storedObject] = result.current;
expect(storedObject).toEqual(complexObject);

Expand All @@ -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();

Expand All @@ -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");
});
});

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -290,3 +329,9 @@ describe("use-db", () => {
});
});
});

function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

0 comments on commit b2b23f6

Please sign in to comment.