Skip to content

Commit 01ea136

Browse files
devmgnclaude
andcommitted
feat(hooks): add useSessionStorage with shared useWebStorage core
共有コアとして `useWebStorage` と `webStorageStore`(localStorage / sessionStorage 両対応の pub/sub 付き薄ラッパー)を切り出し、`useSessionStorage` を新設。同じ契約で `useLocalStorage` も再実装。API は Web Storage ネイティブと揃えて string 前提・ null sentinel に簡素化し、汎用型述語 `isFunction` は `src/utils/isFunction` へ移動。 Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 7ccff41 commit 01ea136

18 files changed

Lines changed: 1297 additions & 247 deletions

oxlint.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,13 @@ export default defineConfig({
8888
"import/unambiguous": "off",
8989

9090
// ── eslint-plugin-jsdoc (built-in) ──
91+
// TS ファイルではシグネチャに型があるため JSDoc の型は重複(ts(80004))。
9192
"jsdoc/require-param": "off",
9293
"jsdoc/require-param-description": "off",
9394
"jsdoc/require-param-type": "off",
9495
"jsdoc/require-returns": "off",
9596
"jsdoc/require-returns-description": "off",
97+
"jsdoc/require-returns-type": "off",
9698

9799
// ── eslint-plugin-node (built-in) ──
98100
"node/no-process-env": "off",

src/app/(sandbox)/dummy/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useDisclosure } from "../../../hooks/useDisclosure";
1010
import { useIsComposing } from "../../../hooks/useIsComposing";
1111
import { useLocalStorage } from "../../../hooks/useLocalStorage";
1212
import { useMediaQuery } from "../../../hooks/useMediaQuery";
13+
import { useSessionStorage } from "../../../hooks/useSessionStorage";
1314
import { createCustomEvent } from "../../../utils/createCustomEvent";
1415
import { isKeyOf } from "../../../utils/isKeyOf";
1516
import { isValueOf } from "../../../utils/isValueOf";
@@ -21,7 +22,8 @@ export default function Page() {
2122
// noop
2223
}, 1000);
2324
useIsComposing();
24-
useLocalStorage("dummy", "");
25+
useLocalStorage("dummy");
26+
useSessionStorage("dummy");
2527
useMediaQuery("(min-width: 768px)");
2628
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
2729
createCustomEvent("" as keyof GlobalEventHandlersEventMap);

src/hooks/useLocalStorage/useLocalStorage.stories.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ import { Button } from "../../components/Button";
66
import { Input } from "../../components/Input";
77

88
function UseLocalStorageDemo() {
9-
const [value, setValue, removeValue] = useLocalStorage(
10-
"storybook-demo",
11-
"initial",
12-
);
9+
const [value, setValue, removeValue] = useLocalStorage("storybook-demo");
1310
const inputRef = useRef<HTMLInputElement>(null);
1411
const valueId = useId();
1512
const inputId = useId();
@@ -18,7 +15,7 @@ function UseLocalStorageDemo() {
1815
<div className="flex flex-col gap-4">
1916
<div className="flex gap-2">
2017
<label htmlFor={valueId}>Current Value: </label>
21-
<Input id={valueId} readOnly value={value} />
18+
<Input id={valueId} readOnly value={value ?? "null"} />
2219
</div>
2320
<div className="flex gap-2">
2421
<label htmlFor={inputId}>New Value: </label>
@@ -52,9 +49,7 @@ const meta = {
5249
export default meta;
5350
type Story = StoryObj;
5451

55-
export const Default: Story = {};
56-
57-
export const InteractionTest: Story = {
52+
export const Default: Story = {
5853
play: async ({ canvas }) => {
5954
const [currentValue, newValueInput] = canvas.getAllByRole("textbox");
6055
await expect(currentValue).toHaveValue("initial");

src/hooks/useLocalStorage/useLocalStorage.test.ts

Lines changed: 60 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -31,54 +31,49 @@ describe(useLocalStorage, () => {
3131
});
3232

3333
describe("基本動作", () => {
34-
it("初期値を返すこと", () => {
34+
it("未設定なら null を返す", () => {
3535
const key = uniqueKey();
36-
const { result } = renderHook(() => useLocalStorage(key, "default"));
37-
expect(result.current[0]).toBe("default");
36+
const { result } = renderHook(() => useLocalStorage(key));
37+
expect(result.current[0]).toBeNull();
3838
});
3939

40-
it("localStorageの既存値を読み取ること", () => {
40+
it("localStorageの既存値を読み取る", () => {
4141
const key = uniqueKey();
42-
mockStorage.setItem(key, JSON.stringify("stored"));
43-
const { result } = renderHook(() => useLocalStorage(key, "default"));
42+
mockStorage.setItem(key, "stored");
43+
const { result } = renderHook(() => useLocalStorage(key));
4444
expect(result.current[0]).toBe("stored");
4545
});
4646

47-
it("setValueで値を更新できること", () => {
47+
it("setValueで値を更新できる", () => {
4848
const key = uniqueKey();
49-
const { result } = renderHook(() => useLocalStorage(key, "default"));
49+
const { result } = renderHook(() => useLocalStorage(key));
5050

5151
act(() => {
5252
result.current[1]("updated");
5353
});
5454

5555
expect(result.current[0]).toBe("updated");
56-
expect(mockStorage.setItem).toHaveBeenCalledWith(
57-
key,
58-
JSON.stringify("updated"),
59-
);
56+
expect(mockStorage.setItem).toHaveBeenCalledWith(key, "updated");
6057
});
6158

62-
it("関数型更新をサポートすること", () => {
59+
it("関数型更新をサポートする(prev は string | null)", () => {
6360
const key = uniqueKey();
64-
const { result } = renderHook(() => useLocalStorage(key, 0));
61+
const { result } = renderHook(() => useLocalStorage(key));
6562

6663
act(() => {
67-
result.current[1]((prev) => prev + 1);
64+
result.current[1]((prev) => `${prev ?? ""}a`);
6865
});
69-
70-
expect(result.current[0]).toBe(1);
66+
expect(result.current[0]).toBe("a");
7167

7268
act(() => {
73-
result.current[1]((prev) => prev + 10);
69+
result.current[1]((prev) => `${prev ?? ""}b`);
7470
});
75-
76-
expect(result.current[0]).toBe(11);
71+
expect(result.current[0]).toBe("ab");
7772
});
7873

79-
it("removeValueで値を削除して初期値に戻ること", () => {
74+
it("removeValue 後は null に戻る", () => {
8075
const key = uniqueKey();
81-
const { result } = renderHook(() => useLocalStorage(key, "default"));
76+
const { result } = renderHook(() => useLocalStorage(key));
8277

8378
act(() => {
8479
result.current[1]("stored");
@@ -89,92 +84,26 @@ describe(useLocalStorage, () => {
8984
result.current[2]();
9085
});
9186

92-
expect(result.current[0]).toBe("default");
93-
expect(mockStorage.removeItem).toHaveBeenCalledWith(key);
94-
});
95-
});
96-
97-
describe("シリアライズ", () => {
98-
it("オブジェクトを正しく保存・読み取りできること", () => {
99-
const key = uniqueKey();
100-
const obj = { name: "test", count: 42 };
101-
const { result } = renderHook(() =>
102-
useLocalStorage(key, { name: "", count: 0 }),
103-
);
104-
105-
act(() => {
106-
result.current[1](obj);
107-
});
108-
109-
expect(result.current[0]).toStrictEqual(obj);
110-
});
111-
112-
it("配列を正しく保存・読み取りできること", () => {
113-
const key = uniqueKey();
114-
const arr = [1, 2, 3];
115-
const { result } = renderHook(() => useLocalStorage<number[]>(key, []));
116-
117-
act(() => {
118-
result.current[1](arr);
119-
});
120-
121-
expect(result.current[0]).toStrictEqual(arr);
122-
});
123-
124-
it("数値を正しく処理すること", () => {
125-
const key = uniqueKey();
126-
const { result } = renderHook(() => useLocalStorage(key, 0));
127-
128-
act(() => {
129-
result.current[1](42);
130-
});
131-
132-
expect(result.current[0]).toBe(42);
133-
});
134-
135-
it("nullを正しく処理すること", () => {
136-
const key = uniqueKey();
137-
const { result } = renderHook(() =>
138-
useLocalStorage<string | null>(key, null),
139-
);
140-
141-
expect(result.current[0]).toBeNull();
142-
143-
act(() => {
144-
result.current[1]("value");
145-
});
146-
expect(result.current[0]).toBe("value");
147-
148-
act(() => {
149-
result.current[1](null);
150-
});
15187
expect(result.current[0]).toBeNull();
88+
expect(mockStorage.removeItem).toHaveBeenCalledWith(key);
15289
});
15390
});
15491

15592
describe("エラーハンドリング", () => {
156-
it("localStorage利用不可時に初期値を返すこと", () => {
93+
it("localStorage 利用不可時に null を返す", () => {
15794
const key = uniqueKey();
15895
mockStorage.getItem.mockImplementation(() => {
15996
throw new Error("localStorage unavailable");
16097
});
16198

162-
const { result } = renderHook(() => useLocalStorage(key, "fallback"));
163-
expect(result.current[0]).toBe("fallback");
164-
});
165-
166-
it("破損JSONの場合に初期値を返すこと", () => {
167-
const key = uniqueKey();
168-
mockStorage.setItem(key, "invalid-json{{{");
169-
170-
const { result } = renderHook(() => useLocalStorage(key, "fallback"));
171-
expect(result.current[0]).toBe("fallback");
99+
const { result } = renderHook(() => useLocalStorage(key));
100+
expect(result.current[0]).toBeNull();
172101
});
173102

174-
it("QuotaExceededError時にconsole.warnを出力し状態を変更しないこと", () => {
103+
it("QuotaExceededError 時に console.warn を出力し状態を変更しない", () => {
175104
const key = uniqueKey();
176105
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
177-
const { result } = renderHook(() => useLocalStorage(key, "default"));
106+
const { result } = renderHook(() => useLocalStorage(key));
178107

179108
mockStorage.setItem.mockImplementation(() => {
180109
throw new DOMException("quota exceeded", "QuotaExceededError");
@@ -185,32 +114,33 @@ describe(useLocalStorage, () => {
185114
});
186115

187116
expect(warnSpy).toHaveBeenCalledTimes(1);
188-
expect(result.current[0]).toBe("default");
189-
// localStorage に書き込まれていないことを確認
117+
expect(result.current[0]).toBeNull();
190118
expect(mockStorage.getItem(key)).toBeNull();
191119
});
192120
});
193121

194122
describe("タブ間同期", () => {
195-
it("storageイベントで値が更新されること", () => {
123+
it("storage イベントで値が更新される", () => {
196124
const key = uniqueKey();
197-
const { result } = renderHook(() => useLocalStorage(key, "default"));
125+
const { result } = renderHook(() => useLocalStorage(key));
198126

199127
act(() => {
128+
mockStorage.setItem(key, "from-other-tab");
200129
window.dispatchEvent(
201130
new StorageEvent("storage", {
202131
key,
203-
newValue: JSON.stringify("from-other-tab"),
132+
newValue: "from-other-tab",
133+
storageArea: window.localStorage,
204134
}),
205135
);
206136
});
207137

208138
expect(result.current[0]).toBe("from-other-tab");
209139
});
210140

211-
it("storageイベントで削除を検知すること", () => {
141+
it("storage イベントで削除を検知する", () => {
212142
const key = uniqueKey();
213-
const { result } = renderHook(() => useLocalStorage(key, "default"));
143+
const { result } = renderHook(() => useLocalStorage(key));
214144

215145
act(() => {
216146
result.current[1]("stored");
@@ -223,39 +153,37 @@ describe(useLocalStorage, () => {
223153
new StorageEvent("storage", {
224154
key,
225155
newValue: null,
156+
storageArea: window.localStorage,
226157
}),
227158
);
228159
});
229160

230-
expect(result.current[0]).toBe("default");
161+
expect(result.current[0]).toBeNull();
231162
});
232163

233-
it("無関係なキーのstorageイベントを無視すること", () => {
164+
it("無関係なキーの storage イベントを無視する", () => {
234165
const key = uniqueKey();
235-
const { result } = renderHook(() => useLocalStorage(key, "default"));
166+
const { result } = renderHook(() => useLocalStorage(key));
236167

237168
act(() => {
238169
window.dispatchEvent(
239170
new StorageEvent("storage", {
240171
key: "other-key",
241-
newValue: JSON.stringify("other-value"),
172+
newValue: "other-value",
173+
storageArea: window.localStorage,
242174
}),
243175
);
244176
});
245177

246-
expect(result.current[0]).toBe("default");
178+
expect(result.current[0]).toBeNull();
247179
});
248180
});
249181

250182
describe("同一タブ同期", () => {
251-
it("同じキーの複数インスタンスが同期すること", () => {
183+
it("同じキーの複数インスタンスが同期する", () => {
252184
const key = uniqueKey();
253-
const { result: result1 } = renderHook(() =>
254-
useLocalStorage(key, "default"),
255-
);
256-
const { result: result2 } = renderHook(() =>
257-
useLocalStorage(key, "default"),
258-
);
185+
const { result: result1 } = renderHook(() => useLocalStorage(key));
186+
const { result: result2 } = renderHook(() => useLocalStorage(key));
259187

260188
act(() => {
261189
result1.current[1]("synced");
@@ -267,18 +195,29 @@ describe(useLocalStorage, () => {
267195
});
268196

269197
describe("クリーンアップ", () => {
270-
it("アンマウント時にリスナーが解除されること", () => {
198+
it("アンマウント後は同タブの書き込みで再レンダーされない", () => {
271199
const key = uniqueKey();
272-
const addSpy = vi.spyOn(window, "addEventListener");
273-
const removeSpy = vi.spyOn(window, "removeEventListener");
274-
275-
const { unmount } = renderHook(() => useLocalStorage(key, "default"));
276-
277-
expect(addSpy).toHaveBeenCalledWith("storage", expect.any(Function));
200+
const renderSpy = vi.fn();
201+
const { unmount } = renderHook(() => {
202+
renderSpy();
203+
return useLocalStorage(key);
204+
});
278205

206+
const renderCountBeforeUnmount = renderSpy.mock.calls.length;
279207
unmount();
280208

281-
expect(removeSpy).toHaveBeenCalledWith("storage", expect.any(Function));
209+
act(() => {
210+
window.localStorage.setItem(key, "after-unmount");
211+
window.dispatchEvent(
212+
new StorageEvent("storage", {
213+
key,
214+
newValue: "after-unmount",
215+
storageArea: window.localStorage,
216+
}),
217+
);
218+
});
219+
220+
expect(renderSpy).toHaveBeenCalledTimes(renderCountBeforeUnmount);
282221
});
283222
});
284223
});

0 commit comments

Comments
 (0)