From ec5e79c2cc7dd8250eca69ab0af392fab43b21ec Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 18 Aug 2026 10:14:02 +0900 Subject: [PATCH 1/4] feat(favorites): add FavoritesLookupProvider and useFavoritesLookup hook --- .../favorites/FavoritesLookupProvider.tsx | 33 ++++++++++ .../FavoritesLookupProvider.test.tsx | 62 +++++++++++++++++++ .../favorites/hooks/use-favorites-lookup.ts | 10 +++ 3 files changed, 105 insertions(+) create mode 100644 client/src/app/features/favorites/FavoritesLookupProvider.tsx create mode 100644 client/src/app/features/favorites/__tests__/FavoritesLookupProvider.test.tsx create mode 100644 client/src/app/features/favorites/hooks/use-favorites-lookup.ts diff --git a/client/src/app/features/favorites/FavoritesLookupProvider.tsx b/client/src/app/features/favorites/FavoritesLookupProvider.tsx new file mode 100644 index 0000000..c8360cc --- /dev/null +++ b/client/src/app/features/favorites/FavoritesLookupProvider.tsx @@ -0,0 +1,33 @@ +import React, { createContext, useMemo } from "react"; +import type { ReactNode } from "react"; +import { useAuth } from "@shared/auth/AuthHooks.ts"; +import { useIsFavorited } from "./hooks/use-is-favorited"; + +type FavoritesLookupContextValue = { + isFavorited: (heritageId: number) => boolean; + isLoading: boolean; + error: unknown; +}; + +const FavoritesLookupContext = createContext(undefined); + +function FavoritesLookupInner({ children }: { children: ReactNode }) { + const { isFavorited, isLoading, error } = useIsFavorited(); + + const value = useMemo(() => ({ isFavorited, isLoading, error }), [isFavorited, isLoading, error]); + + return ( + {children} + ); +} + +// GET /api/v1/favorites を一覧ページのカード枚数分バラバラに叩かないよう、 +// 取得は最上位でこのProviderが1回だけ行い、各カードは共有のlookupを参照する。 +export const FavoritesLookupProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const { user } = useAuth(); + + // keyでuser切り替え時にuseIsFavoritedを再マウントし、ログイン/ログアウトを反映する。 + return {children}; +}; + +export default FavoritesLookupContext; diff --git a/client/src/app/features/favorites/__tests__/FavoritesLookupProvider.test.tsx b/client/src/app/features/favorites/__tests__/FavoritesLookupProvider.test.tsx new file mode 100644 index 0000000..0ac282c --- /dev/null +++ b/client/src/app/features/favorites/__tests__/FavoritesLookupProvider.test.tsx @@ -0,0 +1,62 @@ +/** @jest-environment jsdom */ + +import { jest } from "@jest/globals"; + +const useAuthMock = jest.fn(); + +jest.mock("@shared/auth/AuthHooks.ts", () => ({ + useAuth: () => useAuthMock(), +})); + +const useIsFavoritedMock = jest.fn(); + +jest.mock("../hooks/use-is-favorited", () => ({ + useIsFavorited: () => useIsFavoritedMock(), +})); + +import { render, screen } from "@testing-library/react"; +import { describe, test, expect, beforeEach } from "@jest/globals"; +import { FavoritesLookupProvider } from "../FavoritesLookupProvider"; +import { useFavoritesLookup } from "../hooks/use-favorites-lookup"; + +function Probe() { + const { isFavorited, isLoading } = useFavoritesLookup(); + return ( +
+ {isLoading ? "loading" : isFavorited(1) ? "favorited" : "not-favorited"} +
+ ); +} + +describe("FavoritesLookupProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("useIsFavoritedの結果をcontext経由で公開する", () => { + useAuthMock.mockReturnValue({ user: { id: 1 } }); + useIsFavoritedMock.mockReturnValue({ + isFavorited: (id: number) => id === 1, + isLoading: false, + error: null, + }); + + render( + + + , + ); + + expect(screen.getByTestId("state")).toHaveTextContent("favorited"); + }); + + test("Provider外でuseFavoritesLookupを呼ぶとエラーになる", () => { + const spy = jest.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => render()).toThrow( + "useFavoritesLookup must be used within FavoritesLookupProvider", + ); + + spy.mockRestore(); + }); +}); diff --git a/client/src/app/features/favorites/hooks/use-favorites-lookup.ts b/client/src/app/features/favorites/hooks/use-favorites-lookup.ts new file mode 100644 index 0000000..554fdf6 --- /dev/null +++ b/client/src/app/features/favorites/hooks/use-favorites-lookup.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import FavoritesLookupContext from "../FavoritesLookupProvider.tsx"; + +export const useFavoritesLookup = () => { + const context = useContext(FavoritesLookupContext); + if (!context) { + throw new Error("useFavoritesLookup must be used within FavoritesLookupProvider"); + } + return context; +}; From 84a17cf8e6cc3e97e440ffc0cfc9508e2cfb72cf Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 18 Aug 2026 10:14:09 +0900 Subject: [PATCH 2/4] feat(favorites): make useAddFavorite/useRemoveFavorite submit report success --- .../favorites/hooks/__tests__/use-add-favorite.test.ts | 8 ++++---- .../favorites/hooks/__tests__/use-remove-favorite.test.ts | 8 ++++---- .../src/app/features/favorites/hooks/use-add-favorite.ts | 4 +++- .../app/features/favorites/hooks/use-remove-favorite.ts | 4 +++- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/client/src/app/features/favorites/hooks/__tests__/use-add-favorite.test.ts b/client/src/app/features/favorites/hooks/__tests__/use-add-favorite.test.ts index 61e8aef..e3b6a18 100644 --- a/client/src/app/features/favorites/hooks/__tests__/use-add-favorite.test.ts +++ b/client/src/app/features/favorites/hooks/__tests__/use-add-favorite.test.ts @@ -59,7 +59,7 @@ describe("useAddFavorite", () => { const { result } = renderHook(() => useAddFavorite()); - let submitPromise!: Promise; + let submitPromise!: Promise; act(() => { submitPromise = result.current.submit(42); }); @@ -104,7 +104,7 @@ describe("useAddFavorite", () => { const { result } = renderHook(() => useAddFavorite()); const boom = new Error("boom"); - let submitPromise!: Promise; + let submitPromise!: Promise; act(() => { submitPromise = result.current.submit(42); }); @@ -125,7 +125,7 @@ describe("useAddFavorite", () => { const { result } = renderHook(() => useAddFavorite()); - let submitPromise!: Promise; + let submitPromise!: Promise; act(() => { submitPromise = result.current.submit(42); }); @@ -158,7 +158,7 @@ describe("useAddFavorite", () => { await waitFor(() => expect(signals).toHaveLength(1)); - let secondSubmit!: Promise; + let secondSubmit!: Promise; act(() => { secondSubmit = result.current.submit(42); }); diff --git a/client/src/app/features/favorites/hooks/__tests__/use-remove-favorite.test.ts b/client/src/app/features/favorites/hooks/__tests__/use-remove-favorite.test.ts index a09729e..870e59e 100644 --- a/client/src/app/features/favorites/hooks/__tests__/use-remove-favorite.test.ts +++ b/client/src/app/features/favorites/hooks/__tests__/use-remove-favorite.test.ts @@ -59,7 +59,7 @@ describe("useRemoveFavorite", () => { const { result } = renderHook(() => useRemoveFavorite()); - let submitPromise!: Promise; + let submitPromise!: Promise; act(() => { submitPromise = result.current.submit(42); }); @@ -104,7 +104,7 @@ describe("useRemoveFavorite", () => { const { result } = renderHook(() => useRemoveFavorite()); const boom = new Error("boom"); - let submitPromise!: Promise; + let submitPromise!: Promise; act(() => { submitPromise = result.current.submit(42); }); @@ -125,7 +125,7 @@ describe("useRemoveFavorite", () => { const { result } = renderHook(() => useRemoveFavorite()); - let submitPromise!: Promise; + let submitPromise!: Promise; act(() => { submitPromise = result.current.submit(42); }); @@ -158,7 +158,7 @@ describe("useRemoveFavorite", () => { await waitFor(() => expect(signals).toHaveLength(1)); - let secondSubmit!: Promise; + let secondSubmit!: Promise; act(() => { secondSubmit = result.current.submit(42); }); diff --git a/client/src/app/features/favorites/hooks/use-add-favorite.ts b/client/src/app/features/favorites/hooks/use-add-favorite.ts index c1d2a65..2386420 100644 --- a/client/src/app/features/favorites/hooks/use-add-favorite.ts +++ b/client/src/app/features/favorites/hooks/use-add-favorite.ts @@ -16,7 +16,7 @@ export function useAddFavorite() { return () => abortRef.current?.abort(); }, []); - const submit = useCallback(async (worldHeritageId: number) => { + const submit = useCallback(async (worldHeritageId: number): Promise => { abortRef.current?.abort(); const abortController = new AbortController(); abortRef.current = abortController; @@ -34,8 +34,10 @@ export function useAddFavorite() { signal: abortController.signal, }); setIsAdded(true); + return true; } catch (element) { if (!isAbortError(element)) setError(element); + return false; } finally { setLoading(false); } diff --git a/client/src/app/features/favorites/hooks/use-remove-favorite.ts b/client/src/app/features/favorites/hooks/use-remove-favorite.ts index f191e44..466ec1d 100644 --- a/client/src/app/features/favorites/hooks/use-remove-favorite.ts +++ b/client/src/app/features/favorites/hooks/use-remove-favorite.ts @@ -16,7 +16,7 @@ export function useRemoveFavorite() { return () => abortRef.current?.abort(); }, []); - const submit = useCallback(async (worldHeritageId: number) => { + const submit = useCallback(async (worldHeritageId: number): Promise => { abortRef.current?.abort(); const abortController = new AbortController(); abortRef.current = abortController; @@ -34,8 +34,10 @@ export function useRemoveFavorite() { signal: abortController.signal, }); setIsRemoved(true); + return true; } catch (element) { if (!isAbortError(element)) setError(element); + return false; } finally { setLoading(false); } From f4a2c5d027fda5e02c1c046df6841c3b31c2b43b Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 18 Aug 2026 10:14:16 +0900 Subject: [PATCH 3/4] feat(favorites): wire useFavoritesLookup into FavoriteButtonContainer --- .../favorite-button-container.test.tsx | 73 ++++++++++++------- .../containers/favorite-button-container.tsx | 18 +++-- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/client/src/app/features/favorites/containers/__tests__/favorite-button-container.test.tsx b/client/src/app/features/favorites/containers/__tests__/favorite-button-container.test.tsx index ab4cdce..63bf6b3 100644 --- a/client/src/app/features/favorites/containers/__tests__/favorite-button-container.test.tsx +++ b/client/src/app/features/favorites/containers/__tests__/favorite-button-container.test.tsx @@ -3,14 +3,11 @@ import "@testing-library/jest-dom/jest-globals"; import { jest } from "@jest/globals"; -const submitAddMock = jest.fn(); -const submitRemoveMock = jest.fn(); -let useAddFavoriteReturn: { submit: typeof submitAddMock; isAdded: boolean; isLoading: boolean }; -let useRemoveFavoriteReturn: { - submit: typeof submitRemoveMock; - isRemoved: boolean; - isLoading: boolean; -}; +const submitAddMock = jest.fn<() => Promise>(); +const submitRemoveMock = jest.fn<() => Promise>(); +let useAddFavoriteReturn: { submit: typeof submitAddMock; isLoading: boolean }; +let useRemoveFavoriteReturn: { submit: typeof submitRemoveMock; isLoading: boolean }; +let useFavoritesLookupReturn: { isFavorited: (id: number) => boolean; isLoading: boolean }; let useAuthReturn: { user: { id: number } | null }; jest.mock("../../hooks/use-add-favorite", () => ({ @@ -21,19 +18,24 @@ jest.mock("../../hooks/use-remove-favorite", () => ({ useRemoveFavorite: () => useRemoveFavoriteReturn, })); +jest.mock("../../hooks/use-favorites-lookup", () => ({ + useFavoritesLookup: () => useFavoritesLookupReturn, +})); + jest.mock("@shared/auth/AuthHooks.ts", () => ({ useAuth: () => useAuthReturn, })); -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, test, expect, beforeEach } from "@jest/globals"; import { FavoriteButtonContainer } from "../favorite-button-container"; describe("FavoriteButtonContainer", () => { beforeEach(() => { jest.clearAllMocks(); - useAddFavoriteReturn = { submit: submitAddMock, isAdded: false, isLoading: false }; - useRemoveFavoriteReturn = { submit: submitRemoveMock, isRemoved: false, isLoading: false }; + useAddFavoriteReturn = { submit: submitAddMock, isLoading: false }; + useRemoveFavoriteReturn = { submit: submitRemoveMock, isLoading: false }; + useFavoritesLookupReturn = { isFavorited: () => false, isLoading: false }; useAuthReturn = { user: { id: 1 } }; }); @@ -45,7 +47,9 @@ describe("FavoriteButtonContainer", () => { expect(container).toBeEmptyDOMElement(); }); - test("未登録状態でクリックするとaddのsubmitが呼ばれる", () => { + test("lookupで未登録の場合は未登録表示になり、クリックでaddのsubmitが呼ばれる", () => { + submitAddMock.mockResolvedValue(true); + render(); const button = screen.getByRole("button", { name: "Add to favorites" }); @@ -55,43 +59,58 @@ describe("FavoriteButtonContainer", () => { expect(submitRemoveMock).not.toHaveBeenCalled(); }); - test("isAdded=trueの場合は登録済み表示になり、クリックするとremoveのsubmitが呼ばれる", () => { - useAddFavoriteReturn = { submit: submitAddMock, isAdded: true, isLoading: false }; + test("lookupで登録済みの場合は登録済み表示になり、クリックでremoveのsubmitが呼ばれる", () => { + useFavoritesLookupReturn = { isFavorited: (id) => id === 42, isLoading: false }; + submitRemoveMock.mockResolvedValue(true); render(); const button = screen.getByRole("button", { name: "Favorited" }); - expect(button).not.toBeDisabled(); - fireEvent.click(button); expect(submitRemoveMock).toHaveBeenCalledWith(42); expect(submitAddMock).not.toHaveBeenCalled(); }); - test("isAdded=true かつ isRemoved=true の場合は未登録表示に戻る", () => { - useAddFavoriteReturn = { submit: submitAddMock, isAdded: true, isLoading: false }; - useRemoveFavoriteReturn = { submit: submitRemoveMock, isRemoved: true, isLoading: false }; + test("add成功後は登録済み表示に切り替わる", async () => { + submitAddMock.mockResolvedValue(true); render(); - expect(screen.getByRole("button", { name: "Add to favorites" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Add to favorites" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Favorited" })).toBeVisible()); }); - test("add処理中はボタンが無効化される", () => { - useAddFavoriteReturn = { submit: submitAddMock, isAdded: false, isLoading: true }; + test("remove成功後は未登録表示に切り替わる", async () => { + useFavoritesLookupReturn = { isFavorited: (id) => id === 42, isLoading: false }; + submitRemoveMock.mockResolvedValue(true); render(); - expect(screen.getByRole("button", { name: "Add to favorites" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Favorited" })); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Add to favorites" })).toBeVisible(), + ); }); - test("remove処理中はボタンが無効化される", () => { - useAddFavoriteReturn = { submit: submitAddMock, isAdded: true, isLoading: false }; - useRemoveFavoriteReturn = { submit: submitRemoveMock, isRemoved: false, isLoading: true }; + test("add失敗時は未登録表示のまま", async () => { + submitAddMock.mockResolvedValue(false); render(); - expect(screen.getByRole("button", { name: "Favorited" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Add to favorites" })); + + await waitFor(() => expect(submitAddMock).toHaveBeenCalled()); + expect(screen.getByRole("button", { name: "Add to favorites" })).toBeInTheDocument(); + }); + + test("add/remove処理中はボタンが無効化される", () => { + useAddFavoriteReturn = { submit: submitAddMock, isLoading: true }; + + render(); + + expect(screen.getByRole("button", { name: "Add to favorites" })).toBeDisabled(); }); }); diff --git a/client/src/app/features/favorites/containers/favorite-button-container.tsx b/client/src/app/features/favorites/containers/favorite-button-container.tsx index 1d4420d..4990752 100644 --- a/client/src/app/features/favorites/containers/favorite-button-container.tsx +++ b/client/src/app/features/favorites/containers/favorite-button-container.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; import { useAddFavorite } from "../hooks/use-add-favorite"; import { useRemoveFavorite } from "../hooks/use-remove-favorite"; +import { useFavoritesLookup } from "../hooks/use-favorites-lookup"; import { useAuth } from "@shared/auth/AuthHooks.ts"; import { FavoriteHeartButton } from "../components/FavoriteHeartButton"; @@ -11,18 +13,24 @@ export function FavoriteButtonContainer({ className?: string; }) { const { user } = useAuth(); - const { submit: submitAdd, isAdded, isLoading: isAdding } = useAddFavorite(); - const { submit: submitRemove, isRemoved, isLoading: isRemoving } = useRemoveFavorite(); + const { isFavorited: isFavoritedInLookup } = useFavoritesLookup(); + const { submit: submitAdd, isLoading: isAdding } = useAddFavorite(); + const { submit: submitRemove, isLoading: isRemoving } = useRemoveFavorite(); + const [override, setOverride] = useState(null); if (!user) return null; - const isFavorited = isAdded && !isRemoved; + const isFavorited = override ?? isFavoritedInLookup(heritageId); const handleClick = () => { if (isFavorited) { - void submitRemove(heritageId); + void submitRemove(heritageId).then((ok) => { + if (ok) setOverride(false); + }); } else { - void submitAdd(heritageId); + void submitAdd(heritageId).then((ok) => { + if (ok) setOverride(true); + }); } }; From 46acd2cef25daba5926d929ab2e56682afe71c1e Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 18 Aug 2026 10:14:22 +0900 Subject: [PATCH 4/4] feat(favorites): mount FavoritesLookupProvider in AppRoutes --- client/src/app/routes/AppRoutes.tsx | 41 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/client/src/app/routes/AppRoutes.tsx b/client/src/app/routes/AppRoutes.tsx index f9989a7..2316479 100644 --- a/client/src/app/routes/AppRoutes.tsx +++ b/client/src/app/routes/AppRoutes.tsx @@ -9,6 +9,7 @@ import { MyPageContainer } from "@features/auth/containers/mypage-container.tsx" import { UserCreateContainer } from "@features/user/containers/user-create-container.tsx"; import { UserGetContainer } from "@features/user/containers/user-get-container.tsx"; import { FavoritesContainer } from "@features/favorites/containers/favorites-container.tsx"; +import { FavoritesLookupProvider } from "@features/favorites/FavoritesLookupProvider.tsx"; import { BreadcrumbProvider } from "@features/breadcrumbs/BreadCrumbProvider.tsx"; import { LocaleProvider } from "@shared/locale/LocaleProvider.tsx"; import { AppLayout } from "@shared/layout/AppLayout.tsx"; @@ -19,25 +20,27 @@ export function AppRoutes() { return ( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - - } /> - - - + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + );