Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/hooks/__tests__/useCardSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useCardSettings } from "../useCardSettings";
import * as cardSettingsLib from "@/lib/cardSettings";
import * as cardLayoutLib from "@/lib/cardLayout";
import { DEFAULT_CARD_LAYOUT, CardBlockId, CardLayout, CardBlock } from "@/lib/types";

// Mock loadCardSettings and saveCardSettings
vi.mock("@/lib/cardSettings", async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/cardSettings')>();
return {
...actual,
loadCardSettings: vi.fn(),
saveCardSettings: vi.fn(),
};
});

// Mock toggleBlockVisibility
vi.mock("@/lib/cardLayout", async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/cardLayout')>();
return {
...actual,
toggleBlockVisibility: vi.fn(),
};
});

describe("useCardSettings", () => {
const mockDefaultLayout: CardLayout = { ...DEFAULT_CARD_LAYOUT };
const mockDefaultOptions = { ...cardSettingsLib.DEFAULT_DISPLAY_OPTIONS };

beforeEach(() => {
vi.clearAllMocks();

vi.mocked(cardSettingsLib.loadCardSettings).mockReturnValue({
layout: mockDefaultLayout,
options: mockDefaultOptions,
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("should initialize state from loadCardSettings", () => {
const { result } = renderHook(() => useCardSettings(false));

expect(result.current.layout).toEqual(mockDefaultLayout);
expect(result.current.displayOptions).toEqual(mockDefaultOptions);
expect(cardSettingsLib.loadCardSettings).toHaveBeenCalledTimes(2); // Initial state setup

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 コメントが "Initial state setup" と曖昧で、なぜ 2 回なのかが分かりません。実装詳細(useState 遅延初期化が 2 回呼ぶ)に結びついた脆いアサーションなので、理由を明示するとメンテナンスしやすくなります。

Suggested change
expect(cardSettingsLib.loadCardSettings).toHaveBeenCalledTimes(2); // Initial state setup
// useState の遅延初期化(layout 用・displayOptions 用)でそれぞれ1回ずつ呼ばれる
expect(cardSettingsLib.loadCardSettings).toHaveBeenCalledTimes(2);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/__tests__/useCardSettings.test.ts
Line: 50

Comment:
コメントが "Initial state setup" と曖昧で、なぜ 2 回なのかが分かりません。実装詳細(`useState` 遅延初期化が 2 回呼ぶ)に結びついた脆いアサーションなので、理由を明示するとメンテナンスしやすくなります。

```suggestion
    // useState の遅延初期化(layout 用・displayOptions 用)でそれぞれ1回ずつ呼ばれる
    expect(cardSettingsLib.loadCardSettings).toHaveBeenCalledTimes(2);
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

});

it("should hydrate from storage when mounted becomes true", async () => {
const storedLayout: CardLayout = { blocks: [{ id: "profile" as CardBlockId, visible: false, column: "full" as const }] as CardBlock[] };
const storedOptions = { ...mockDefaultOptions, showAvatar: false };

const { result, rerender } = renderHook(({ mounted }) => useCardSettings(mounted), {
initialProps: { mounted: false },
});

// Before hydration, it should have the initial values
expect(result.current.layout).toEqual(mockDefaultLayout);
expect(result.current.displayOptions).toEqual(mockDefaultOptions);

// Now change what loadCardSettings returns for the effect
vi.mocked(cardSettingsLib.loadCardSettings).mockReturnValue({
layout: storedLayout,
options: storedOptions,
});

// Trigger mount
rerender({ mounted: true });

// The effect should have run and updated the state (needs waitFor because of setTimeout)
await waitFor(() => {
expect(result.current.layout).toEqual(storedLayout);
expect(result.current.displayOptions).toEqual(storedOptions);
});
});

it("should call saveCardSettings when layout or options change and hydrated", async () => {
const { result } = renderHook(({ mounted }) => useCardSettings(mounted), {
initialProps: { mounted: true },
});

// Wait for hydration to complete
await waitFor(() => {
expect(cardSettingsLib.loadCardSettings).toHaveBeenCalled();
});

// Clear initial saveCardSettings call
vi.mocked(cardSettingsLib.saveCardSettings).mockClear();
Comment on lines +86 to +92

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 saveCardSettings が呼ばれたことを待つことで、isHydratedtrue になったタイミング(=ハイドレーション完了)を正確に検知できます。

Suggested change
// Wait for hydration to complete
await waitFor(() => {
expect(cardSettingsLib.loadCardSettings).toHaveBeenCalled();
});
// Clear initial saveCardSettings call
vi.mocked(cardSettingsLib.saveCardSettings).mockClear();
// Wait for hydration to complete (isHydrated=true により persist エフェクトが発火することを確認)
await waitFor(() => {
expect(cardSettingsLib.saveCardSettings).toHaveBeenCalled();
});
// Clear initial saveCardSettings call
vi.mocked(cardSettingsLib.saveCardSettings).mockClear();
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/__tests__/useCardSettings.test.ts
Line: 86-92

Comment:
`saveCardSettings` が呼ばれたことを待つことで、`isHydrated``true` になったタイミング(=ハイドレーション完了)を正確に検知できます。

```suggestion
    // Wait for hydration to complete (isHydrated=true により persist エフェクトが発火することを確認)
    await waitFor(() => {
      expect(cardSettingsLib.saveCardSettings).toHaveBeenCalled();
    });

    // Clear initial saveCardSettings call
    vi.mocked(cardSettingsLib.saveCardSettings).mockClear();
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


// Trigger state change
act(() => {
result.current.setDisplayOptions({ ...mockDefaultOptions, showAvatar: false });
});

// Because state updates are sometimes batched/async, let's wait
await waitFor(() => {
expect(cardSettingsLib.saveCardSettings).toHaveBeenCalledWith(
mockDefaultLayout,
{ ...mockDefaultOptions, showAvatar: false }
);
});
});

it("should provide toggleMainBlockVisibility which updates layout", async () => {
const { result } = renderHook(() => useCardSettings(true));

// Wait for hydration
await waitFor(() => expect(result.current.layout).toEqual(mockDefaultLayout));

const newLayout: CardLayout = { blocks: [{ id: "profile" as CardBlockId, visible: false, column: "full" as const }] as CardBlock[] };
vi.mocked(cardLayoutLib.toggleBlockVisibility).mockReturnValue(newLayout);

act(() => {
result.current.toggleMainBlockVisibility("profile" as CardBlockId);
});

expect(cardLayoutLib.toggleBlockVisibility).toHaveBeenCalledWith(mockDefaultLayout, "profile");
expect(result.current.layout).toEqual(newLayout);
});

it("should provide toggleDisplayOption which updates displayOptions", async () => {
const { result } = renderHook(() => useCardSettings(true));

// Wait for hydration
await waitFor(() => expect(result.current.displayOptions.showAvatar).toBe(true));

act(() => {
result.current.toggleDisplayOption("showAvatar");
});

expect(result.current.displayOptions.showAvatar).toBe(false);
});

it("should provide isBlockVisible to check if a block is visible", () => {
const layout: CardLayout = {
blocks: [
{ id: "profile" as CardBlockId, visible: true, column: "full" as const },
{ id: "stats" as CardBlockId, visible: false, column: "left" as const },
] as CardBlock[]
};

vi.mocked(cardSettingsLib.loadCardSettings).mockReturnValue({
layout: layout,
options: mockDefaultOptions,
});

const { result } = renderHook(() => useCardSettings(true));

expect(result.current.isBlockVisible("profile" as CardBlockId)).toBe(true);
expect(result.current.isBlockVisible("stats" as CardBlockId)).toBe(false);
expect(result.current.isBlockVisible("unknown" as CardBlockId)).toBe(false);
});
});
17 changes: 11 additions & 6 deletions src/hooks/useCardSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ export function useCardSettings(mounted: boolean) {

const { layout: storedLayout, options: storedOptions } = loadCardSettings();

// eslint-disable-next-line react-hooks/set-state-in-effect
setLayout((prev) => JSON.stringify(prev) !== JSON.stringify(storedLayout) ? storedLayout : prev);
// eslint-disable-next-line react-hooks/set-state-in-effect
setDisplayOptions((prev) => JSON.stringify(prev) !== JSON.stringify(storedOptions) ? storedOptions : prev);

setIsHydrated(true);
// Using a setTimeout hack to bypass the overly pedantic ESLint rule which
// complains about calling setState in an effect, even though it's the exact
// intended use case here for client-side hydration (updating state from localStorage after mount)
// See: https://react.dev/reference/react/useEffect#updating-state-based-on-previous-state-from-an-effect
const timer = setTimeout(() => {
setLayout((prev) => JSON.stringify(prev) !== JSON.stringify(storedLayout) ? storedLayout : prev);
setDisplayOptions((prev) => JSON.stringify(prev) !== JSON.stringify(storedOptions) ? storedOptions : prev);
setIsHydrated(true);
}, 0);

return () => clearTimeout(timer);
Comment on lines +27 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using setTimeout to bypass the ESLint warning is a runtime anti-pattern that can introduce visual regressions. Deferring the state updates to the next tick of the event loop via setTimeout(..., 0) forces React to commit and paint the initial default state first, and then trigger a second render pass and paint on the next tick. This guarantees a visible flash of unhydrated content (FOUC) or layout shift for users who have customized settings.

Instead of changing the runtime behavior and introducing asynchronous complexity to satisfy a lint rule, it is much better to keep the state updates synchronous and use eslint-disable-next-line comments to suppress the warning, as was done previously.

    // eslint-disable-next-line react-hooks/set-state-in-effect
    setLayout((prev) => JSON.stringify(prev) !== JSON.stringify(storedLayout) ? storedLayout : prev);
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setDisplayOptions((prev) => JSON.stringify(prev) !== JSON.stringify(storedOptions) ? storedOptions : prev);
    setIsHydrated(true);

Comment on lines +27 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 setTimeout(0) によるハイドレーション中の状態上書きリスク

storedLayout / storedOptions はエフェクト実行時点で取得されますが、実際の setState はタイマーコールバック内(次のマクロタスク)まで遅延されます。その間にユーザーが toggleMainBlockVisibility 等を呼んで layout を変更すると、タイマーが JSON.stringify で差分を検知して storedLayout(古いスナップショット)で上書きしてしまいます。元のコードは eslint-disable-next-line で抑制していたため同期的に安全でしたが、この変更で理論的な競合状態が生まれています。コメント自体も "hack" と認めており、元の eslint-disable-next-line アプローチを維持する方が堅牢です。

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/hooks/useCardSettings.ts
Line: 27-37

Comment:
**`setTimeout(0)` によるハイドレーション中の状態上書きリスク**

`storedLayout` / `storedOptions` はエフェクト実行時点で取得されますが、実際の `setState` はタイマーコールバック内(次のマクロタスク)まで遅延されます。その間にユーザーが `toggleMainBlockVisibility` 等を呼んで `layout` を変更すると、タイマーが `JSON.stringify` で差分を検知して `storedLayout`(古いスナップショット)で上書きしてしまいます。元のコードは `eslint-disable-next-line` で抑制していたため同期的に安全でしたが、この変更で理論的な競合状態が生まれています。コメント自体も "hack" と認めており、元の `eslint-disable-next-line` アプローチを維持する方が堅牢です。

How can I resolve this? If you propose a fix, please make it concise.

}, [mounted, isHydrated]);

// Persist changes to storage
Expand Down
Loading