From 8672610deb8e2ba0ab0ae995065dc73496165b74 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:47:03 +0900 Subject: [PATCH 01/15] perf: create api-cache utility with TTL-based get/set Co-Authored-By: Claude Sonnet 4.6 --- client/src/shared/cache/api-cache.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 client/src/shared/cache/api-cache.ts diff --git a/client/src/shared/cache/api-cache.ts b/client/src/shared/cache/api-cache.ts new file mode 100644 index 0000000..5d6a64c --- /dev/null +++ b/client/src/shared/cache/api-cache.ts @@ -0,0 +1,13 @@ +type Entry = { data: T; at: number }; + +const store = new Map>(); + +export function getCached(key: string, maxAgeMs: number): T | null { + const entry = store.get(key) as Entry | undefined; + if (!entry || Date.now() - entry.at > maxAgeMs) return null; + return entry.data; +} + +export function setCached(key: string, data: T): void { + store.set(key, { data, at: Date.now() }); +} From 0a5b53d2eb7f19be74d565e86e885da3c6276acf Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:52:51 +0900 Subject: [PATCH 02/15] =?UTF-8?q?perf:=20apply=20SWR=20cache=20to=20useTop?= =?UTF-8?q?Page=20=E2=80=94=20serve=20stale=20data=20instantly,=20revalida?= =?UTF-8?q?te=20in=20background?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../app/features/top/hooks/use-top-page.ts | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/client/src/app/features/top/hooks/use-top-page.ts b/client/src/app/features/top/hooks/use-top-page.ts index 5142efc..b9959f3 100644 --- a/client/src/app/features/top/hooks/use-top-page.ts +++ b/client/src/app/features/top/hooks/use-top-page.ts @@ -10,6 +10,9 @@ import type { } from "../../../../domain/types"; import { fetchTopPage } from "@features/top/apis"; import { useLocale } from "@shared/locale/LocaleHooks.ts"; +import { getCached, setCached } from "@shared/cache/api-cache.ts"; + +const CACHE_MAX_AGE_MS = 60_000; type State = { data: WorldHeritageVm[]; @@ -67,11 +70,22 @@ export function useTopPage(args: { currentPage: number; perPage: number; order?: const abortController = new AbortController(); abortRef.current = abortController; - setState((prev) => ({ - ...prev, - loading: true, - error: null, - })); + const cacheKey = `top:${targetPage}:${targetPerPage}:${targetOrder}:${locale}`; + const cached = getCached<{ vmList: WorldHeritageVm[]; pagination: Pagination }>( + cacheKey, + CACHE_MAX_AGE_MS, + ); + + if (cached) { + setState({ + data: cached.vmList, + pagination: cached.pagination, + loading: false, + error: null, + }); + } else { + setState((prev) => ({ ...prev, loading: true, error: null })); + } fetchTopPage({ currentPage: targetPage, @@ -84,13 +98,9 @@ export function useTopPage(args: { currentPage: number; perPage: number; order?: if (abortController.signal.aborted) return; const vmList = toWorldHeritageListVm(res.items, locale); + setCached(cacheKey, { vmList, pagination: res.pagination }); - setState({ - data: vmList, - pagination: res.pagination, - loading: false, - error: null, - }); + setState({ data: vmList, pagination: res.pagination, loading: false, error: null }); }) .catch((e: unknown) => { if (!mountedRef.current) return; @@ -104,11 +114,7 @@ export function useTopPage(args: { currentPage: number; perPage: number; order?: return; } - setState((prev) => ({ - ...prev, - loading: false, - error: e, - })); + setState((prev) => ({ ...prev, loading: false, error: e })); }); }, [locale], From fc910d5ebda1c83e1d6c7cd95ebabd6b31759766 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:53:08 +0900 Subject: [PATCH 03/15] perf: apply SWR cache to useHeritageSearchQuery keyed by serialized search params Co-Authored-By: Claude Sonnet 4.6 --- .../search/hooks/use-search-heritage-query.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/client/src/app/features/search/hooks/use-search-heritage-query.ts b/client/src/app/features/search/hooks/use-search-heritage-query.ts index d9d1b28..5354bc9 100644 --- a/client/src/app/features/search/hooks/use-search-heritage-query.ts +++ b/client/src/app/features/search/hooks/use-search-heritage-query.ts @@ -3,6 +3,9 @@ import type { HeritageSearchParams } from "../../../../domain/types.ts"; import { fetchSearchHeritagesResult } from "../apis"; import type { SearchParams } from "../apis/search-api"; import type { ApiWorldHeritageDto, ListResult } from "../../../../domain/types"; +import { getCached, setCached } from "@shared/cache/api-cache.ts"; + +const CACHE_MAX_AGE_MS = 60_000; const isAbortError = (e: unknown): boolean => { return e instanceof DOMException && e.name === "AbortError"; @@ -44,12 +47,20 @@ export function useHeritageSearchQuery( } const abortController = new AbortController(); + const cacheKey = `search:${JSON.stringify(request)}`; + const cached = getCached>(cacheKey, CACHE_MAX_AGE_MS); - setLoading(true); + if (cached) { + setData(cached); + setLoading(false); + } else { + setLoading(true); + } setError(null); fetchSearchHeritagesResult(request, { signal: abortController.signal }) .then((res) => { + setCached(cacheKey, res); setData(res); }) .catch((e: unknown) => { From 1fad9ee0b08f16450e2ac3ee77a4314ac6350ae5 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:53:29 +0900 Subject: [PATCH 04/15] =?UTF-8?q?perf(card):=20add=20isPriority=20prop=20?= =?UTF-8?q?=E2=80=94=20eager=20loading=20and=20fetchpriority=20for=20above?= =?UTF-8?q?-fold=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- client/src/app/features/top/cards/HeritageCard.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/app/features/top/cards/HeritageCard.tsx b/client/src/app/features/top/cards/HeritageCard.tsx index e4862ec..b0a9d05 100644 --- a/client/src/app/features/top/cards/HeritageCard.tsx +++ b/client/src/app/features/top/cards/HeritageCard.tsx @@ -5,9 +5,11 @@ import { useText } from "@shared/locale/ui-text.ts"; export function HeritageCard({ item, onClickItem, + isPriority = false, }: { item: WorldHeritageVm; onClickItem?: (id: number) => void; + isPriority?: boolean; }) { const text = useText(); @@ -25,7 +27,11 @@ export function HeritageCard({ src={item.thumbnailUrl} alt={title} referrerPolicy="no-referrer" - loading="lazy" + loading={isPriority ? "eager" : "lazy"} + fetchPriority={isPriority ? "high" : "auto"} + decoding="async" + width={400} + height={320} className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" /> ) : ( From 84bd1c8bcd5c6b7ed966b4552a18ae8238a8eac5 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:53:41 +0900 Subject: [PATCH 05/15] perf(list): pass isPriority to first 3 cards in HeritageList Co-Authored-By: Claude Sonnet 4.6 --- client/src/app/features/top/components/HeritageList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/app/features/top/components/HeritageList.tsx b/client/src/app/features/top/components/HeritageList.tsx index 155d2e2..63a4657 100644 --- a/client/src/app/features/top/components/HeritageList.tsx +++ b/client/src/app/features/top/components/HeritageList.tsx @@ -18,9 +18,9 @@ export function HeritageList({ return (
    - {items.map((it) => ( + {items.map((it, index) => (
  • - +
  • ))}
From eec7294fe9a46cd9be46b0c8e940c4f5701ab42f Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:53:51 +0900 Subject: [PATCH 06/15] perf(search): pass isPriority to first 3 cards in SearchResultsPage Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/features/search/components/SearchResultsPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/app/features/search/components/SearchResultsPage.tsx b/client/src/app/features/search/components/SearchResultsPage.tsx index 54f83bf..f31837b 100644 --- a/client/src/app/features/search/components/SearchResultsPage.tsx +++ b/client/src/app/features/search/components/SearchResultsPage.tsx @@ -92,9 +92,9 @@ export default function SearchResultsPage({ ) : (
    - {items.map((it) => ( + {items.map((it, index) => (
  • - +
  • ))}
From 9c4b92b7df9825525a24c7f1a2e6f7ede602457a Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:54:10 +0900 Subject: [PATCH 07/15] perf: create HeritageCardSkeleton component matching thumbnail card dimensions Co-Authored-By: Claude Sonnet 4.6 --- client/src/shared/uis/HeritageCardSkeleton.tsx | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 client/src/shared/uis/HeritageCardSkeleton.tsx diff --git a/client/src/shared/uis/HeritageCardSkeleton.tsx b/client/src/shared/uis/HeritageCardSkeleton.tsx new file mode 100644 index 0000000..87215bf --- /dev/null +++ b/client/src/shared/uis/HeritageCardSkeleton.tsx @@ -0,0 +1,5 @@ +export function HeritageCardSkeleton() { + return ( +
+ ); +} From 5b44d66fb038f860b2c08b26beff5586c0cb2bbd Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:54:26 +0900 Subject: [PATCH 08/15] =?UTF-8?q?perf(list):=20add=20isLoading=20prop=20to?= =?UTF-8?q?=20HeritageList=20=E2=80=94=20show=20skeleton=20grid=20while=20?= =?UTF-8?q?fetching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../features/top/components/HeritageList.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/src/app/features/top/components/HeritageList.tsx b/client/src/app/features/top/components/HeritageList.tsx index 63a4657..232b0b9 100644 --- a/client/src/app/features/top/components/HeritageList.tsx +++ b/client/src/app/features/top/components/HeritageList.tsx @@ -1,13 +1,30 @@ import type { WorldHeritageVm } from "../../../../domain/types.ts"; import { HeritageCard } from "../cards/HeritageCard"; +import { HeritageCardSkeleton } from "@shared/uis/HeritageCardSkeleton.tsx"; + +const SKELETON_COUNT = 6; export function HeritageList({ items, onClickItem, + isLoading = false, }: { items: ReadonlyArray; onClickItem?: (id: number) => void; + isLoading?: boolean; }) { + if (isLoading) { + return ( +
    + {Array.from({ length: SKELETON_COUNT }).map((_, i) => ( +
  • + +
  • + ))} +
+ ); + } + if (items.length === 0) { return (
From 726ca6d8a609b5cf874f08a3e02bff108e9b141f Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:54:37 +0900 Subject: [PATCH 09/15] =?UTF-8?q?perf(search):=20add=20isLoading=20prop=20?= =?UTF-8?q?to=20SearchResultsPage=20=E2=80=94=20show=20skeleton=20grid=20w?= =?UTF-8?q?hile=20fetching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../search/components/SearchResultsPage.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/client/src/app/features/search/components/SearchResultsPage.tsx b/client/src/app/features/search/components/SearchResultsPage.tsx index f31837b..3211e37 100644 --- a/client/src/app/features/search/components/SearchResultsPage.tsx +++ b/client/src/app/features/search/components/SearchResultsPage.tsx @@ -8,6 +8,9 @@ import { Pagination } from "@features/top/components/Pagination.tsx"; import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx"; import { LocaleToggle } from "@shared/locale/LocaleToggle.tsx"; import { useText } from "@shared/locale/ui-text.ts"; +import { HeritageCardSkeleton } from "@shared/uis/HeritageCardSkeleton.tsx"; + +const SKELETON_COUNT = 6; type Props = { header?: ReactNode; @@ -20,6 +23,7 @@ type Props = { errorMessage?: string; onPageChange?: (page: number) => void; onBackToAllSites?: () => void; + isLoading?: boolean; }; export default function SearchResultsPage({ @@ -31,6 +35,7 @@ export default function SearchResultsPage({ errorMessage, onPageChange, onBackToAllSites, + isLoading = false, }: Props) { const text = useText(); return ( @@ -86,7 +91,15 @@ export default function SearchResultsPage({
- {items.length === 0 ? ( + {isLoading ? ( +
    + {Array.from({ length: SKELETON_COUNT }).map((_, i) => ( +
  • + +
  • + ))} +
+ ) : items.length === 0 ? (

{text.noSitesFound}

From 28d0f1353e7fa656347fb580f217057c32d1ff5f Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:54:45 +0900 Subject: [PATCH 10/15] =?UTF-8?q?perf(container):=20remove=20Loading?= =?UTF-8?q?=E2=80=A6=20early=20return=20from=20TopPageContainer=20?= =?UTF-8?q?=E2=80=94=20pass=20isLoading=20to=20HeritageList?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../features/top/containers/top-page-container.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/client/src/app/features/top/containers/top-page-container.tsx b/client/src/app/features/top/containers/top-page-container.tsx index 128d33b..210a919 100644 --- a/client/src/app/features/top/containers/top-page-container.tsx +++ b/client/src/app/features/top/containers/top-page-container.tsx @@ -89,17 +89,6 @@ export default function TopPageContainer(): React.ReactElement { const header = ; - if (isLoading) { - return ( - <> - {header} -
-
Loading…
-
- - ); - } - if (isError) { return ( <> @@ -121,7 +110,7 @@ export default function TopPageContainer(): React.ReactElement { } header={header} - content={} + content={} pagination={ Date: Fri, 22 May 2026 08:54:54 +0900 Subject: [PATCH 11/15] perf(container): pass isLoading to SearchResultsPage in SearchHeritageResultsContainer Co-Authored-By: Claude Sonnet 4.6 --- .../search/containers/search-heritage-result-container.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/app/features/search/containers/search-heritage-result-container.tsx b/client/src/app/features/search/containers/search-heritage-result-container.tsx index 41a5c52..8d8a7fd 100644 --- a/client/src/app/features/search/containers/search-heritage-result-container.tsx +++ b/client/src/app/features/search/containers/search-heritage-result-container.tsx @@ -201,7 +201,7 @@ export function SearchHeritageResultsContainer(): React.ReactElement { }; if (isLoading) { - return ; + return ; } if (error) { From 065286f8a96fc03c9e21dd517c41faa696f4f705 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:55:23 +0900 Subject: [PATCH 12/15] perf(top): lazy-load Map component with React.lazy and Suspense pulse fallback Co-Authored-By: Claude Sonnet 4.6 --- client/src/app/features/top/components/TopPage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/app/features/top/components/TopPage.tsx b/client/src/app/features/top/components/TopPage.tsx index d75089a..307a309 100644 --- a/client/src/app/features/top/components/TopPage.tsx +++ b/client/src/app/features/top/components/TopPage.tsx @@ -1,5 +1,6 @@ -import type { ReactNode } from "react"; -import { Map } from "./Map.tsx"; +import { lazy, Suspense, type ReactNode } from "react"; + +const Map = lazy(() => import("./Map.tsx").then((m) => ({ default: m.Map }))); export default function TopPage({ hero, @@ -23,7 +24,9 @@ export default function TopPage({
{header}
- + }> + +
From 85a27b0e6f12a413c3071e390c84fa797c047b59 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 08:55:30 +0900 Subject: [PATCH 13/15] perf(detail): lazy-load DetailHeritageMap with React.lazy and Suspense pulse fallback Co-Authored-By: Claude Sonnet 4.6 --- .../heritage-detail/HeritageDetailLayout.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx index 84110dc..4c37b90 100644 --- a/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx +++ b/client/src/app/features/top/components/heritage-detail/HeritageDetailLayout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, lazy, Suspense } from "react"; import { useNavigate } from "react-router-dom"; import type { WorldHeritageDetailVm, SearchValues } from "../../../../../domain/types.ts"; import { HeritageSubHeader } from "../HeritageSubHeader.tsx"; @@ -6,7 +6,11 @@ import { HeritageHero } from "./HeritageHero"; import { HeritageOverViewSection } from "./HeritageOverviewSection"; import { HeritageSidebar } from "./HeritageSidebar"; import { HeritageGallery } from "./HeritageGallery"; -import { DetailHeritageMap } from "@features/top/components/heritage-detail/DetailHeritageMap.tsx"; +const DetailHeritageMap = lazy(() => + import("@features/top/components/heritage-detail/DetailHeritageMap.tsx").then((m) => ({ + default: m.DetailHeritageMap, + })), +); import { textType } from "@shared/styles/typography"; import { useSetBreadcrumbLabel } from "@features/breadcrumbs/BreadCrumbHooks.ts"; import { BreadcrumbList } from "@shared/components/BreadcrumbList.tsx"; @@ -181,7 +185,9 @@ export function HeritageDetailLayout({ item }: { item: WorldHeritageDetailVm }) {/* Map: mobile only (PC shows in sidebar) */}
- + }> + +
From f56d85cec85d922dbae1cc1de5b6144fa2018acf Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 09:29:09 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix(card):=20remove=20isPriority/fetchPri?= =?UTF-8?q?ority/eager=20loading=20=E2=80=94=20caused=20403=20on=20UNESCO?= =?UTF-8?q?=20image=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchPriority="high" and loading="eager" triggered rate-limiting on UNESCO's document server. Revert to loading="lazy" for all images. Keep width, height, and decoding="async" for CLS prevention. Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/features/search/components/SearchResultsPage.tsx | 4 ++-- client/src/app/features/top/cards/HeritageCard.tsx | 5 +---- client/src/app/features/top/components/HeritageList.tsx | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/client/src/app/features/search/components/SearchResultsPage.tsx b/client/src/app/features/search/components/SearchResultsPage.tsx index 3211e37..c226b28 100644 --- a/client/src/app/features/search/components/SearchResultsPage.tsx +++ b/client/src/app/features/search/components/SearchResultsPage.tsx @@ -105,9 +105,9 @@ export default function SearchResultsPage({
) : (
    - {items.map((it, index) => ( + {items.map((it) => (
  • - +
  • ))}
diff --git a/client/src/app/features/top/cards/HeritageCard.tsx b/client/src/app/features/top/cards/HeritageCard.tsx index b0a9d05..095978b 100644 --- a/client/src/app/features/top/cards/HeritageCard.tsx +++ b/client/src/app/features/top/cards/HeritageCard.tsx @@ -5,11 +5,9 @@ import { useText } from "@shared/locale/ui-text.ts"; export function HeritageCard({ item, onClickItem, - isPriority = false, }: { item: WorldHeritageVm; onClickItem?: (id: number) => void; - isPriority?: boolean; }) { const text = useText(); @@ -27,8 +25,7 @@ export function HeritageCard({ src={item.thumbnailUrl} alt={title} referrerPolicy="no-referrer" - loading={isPriority ? "eager" : "lazy"} - fetchPriority={isPriority ? "high" : "auto"} + loading="lazy" decoding="async" width={400} height={320} diff --git a/client/src/app/features/top/components/HeritageList.tsx b/client/src/app/features/top/components/HeritageList.tsx index 232b0b9..0b09029 100644 --- a/client/src/app/features/top/components/HeritageList.tsx +++ b/client/src/app/features/top/components/HeritageList.tsx @@ -35,9 +35,9 @@ export function HeritageList({ return (
    - {items.map((it, index) => ( + {items.map((it) => (
  • - +
  • ))}
From 0b5d45d6cafdfbd2c92c6e728bb52b5eadcbb54c Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 22 May 2026 09:36:06 +0900 Subject: [PATCH 15/15] fix(card): revert image dimension attrs added for #332 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop decoding="async", width/height hints — image optimization issue is being closed without implementation. Co-Authored-By: Claude Sonnet 4.6 --- client/src/app/features/top/cards/HeritageCard.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/src/app/features/top/cards/HeritageCard.tsx b/client/src/app/features/top/cards/HeritageCard.tsx index 095978b..e4862ec 100644 --- a/client/src/app/features/top/cards/HeritageCard.tsx +++ b/client/src/app/features/top/cards/HeritageCard.tsx @@ -26,9 +26,6 @@ export function HeritageCard({ alt={title} referrerPolicy="no-referrer" loading="lazy" - decoding="async" - width={400} - height={320} className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" /> ) : (