From ef63825f80fd85f6b0cd9c5b1ba5b881be6da964 Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Mon, 13 Jul 2026 00:19:49 +0900 Subject: [PATCH] =?UTF-8?q?perf(web):=20make=20[locale]=20tree=20staticall?= =?UTF-8?q?y=20renderable=20(ISR)=20=E2=80=94=20home=20no=20longer=20dynam?= =?UTF-8?q?ic=20per-request=20(#942)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home page and every /[locale]/* route were server-rendered on demand (`ƒ`) on every request: prod returned `cache-control: no-store` + `x-vercel-cache: MISS` on each hit, so every anonymous visitor paid a full SSR + 6 backend round-trips, with the function running in iad1 while the edge was icn1 (~0.75-1s HTML TTFB). Root cause was next-intl (v4) static-rendering opt-out, not the data: the home server fetches carry no auth (always the public/anon view) and all personalization is client-side, so the HTML is identical for anonymous users and fully cacheable. Three things forced dynamic rendering: - `NextIntlClientProvider` rendered without explicit `locale`/`now`/ `timeZone` (next-intl opts the tree into dynamic when these are omitted), - no `generateStaticParams` for `[locale]`, - `setRequestLocale` missing from the (shell) layout and the page. This change adds all three. The app uses zero next-intl formatters (date "time ago" is computed manually in components), so the pinned `now`/`timeZone` have no rendering effect — they only satisfy the static opt-in. Also drops the unused `searchParams` from the home page (awaited but never read). Result (verified via `next build` + local `bun start`): - `/[locale]` → `● (SSG)` with `Revalidate: 1m`; prod-equivalent header becomes `s-maxage=60, stale-while-revalidate` + `x-nextjs-prerender: 1` (edge-cacheable — anonymous HTML served from the edge, no iad1 hit; substantially mitigates the region gap in #943 too). - `library`/`profile`/`settings`/`style-dna`/`decoded` also flipped from needless `ƒ` to `●` (they were client-data shells, dynamic only because of the shared provider); genuinely dynamic routes (`explore`, `posts/[id]`, `items/[id]`, `profile/[userId]`) correctly stay `ƒ`. - Browser-verified logged-out (rails, mood tabs) and logged-in (greeting by name, personalized sections, all 5 flipped routes) with zero hydration errors. 819 unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/home-locale-prefetch.test.tsx | 9 +++++++-- packages/web/app/[locale]/(shell)/layout.tsx | 10 +++++++++- packages/web/app/[locale]/(shell)/page.tsx | 7 ++++--- packages/web/app/[locale]/layout.tsx | 20 ++++++++++++++++++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/web/app/[locale]/(shell)/__tests__/home-locale-prefetch.test.tsx b/packages/web/app/[locale]/(shell)/__tests__/home-locale-prefetch.test.tsx index 9775ed393..bd0c4829c 100644 --- a/packages/web/app/[locale]/(shell)/__tests__/home-locale-prefetch.test.tsx +++ b/packages/web/app/[locale]/(shell)/__tests__/home-locale-prefetch.test.tsx @@ -5,6 +5,13 @@ vi.mock("@/lib/components/home/HomeShell", () => ({ HomeShell: () => null, })); +// `setRequestLocale` (static-render enabler) resolves to next-intl's +// client build under vitest and throws "not supported in Client Components". +// It's orthogonal to locale propagation — no-op it here. +vi.mock("next-intl/server", () => ({ + setRequestLocale: vi.fn(), +})); + // Spy on the server fetch util; route by path so each helper gets a usable shape. const { serverApiGet } = vi.hoisted(() => ({ serverApiGet: vi.fn(async (path: string, _options?: { locale?: string }) => { @@ -27,7 +34,6 @@ describe("Home RSC prefetch locale propagation (#838)", () => { await Home({ params: Promise.resolve({ locale: "ko" }), - searchParams: Promise.resolve({}), }); expect(serverApiGet.mock.calls.length).toBeGreaterThan(0); @@ -41,7 +47,6 @@ describe("Home RSC prefetch locale propagation (#838)", () => { await Home({ params: Promise.resolve({ locale: "en" }), - searchParams: Promise.resolve({}), }); for (const [, options] of serverApiGet.mock.calls) { diff --git a/packages/web/app/[locale]/(shell)/layout.tsx b/packages/web/app/[locale]/(shell)/layout.tsx index b69b756f5..11bcdb172 100644 --- a/packages/web/app/[locale]/(shell)/layout.tsx +++ b/packages/web/app/[locale]/(shell)/layout.tsx @@ -1,9 +1,17 @@ +import { setRequestLocale } from "next-intl/server"; import { AppShell } from "@/lib/components/shell/AppShell"; -export default function ShellLayout({ +export default async function ShellLayout({ children, + params, }: { children: React.ReactNode; + params: Promise<{ locale: string }>; }) { + // 정적 렌더에는 [locale] 트리의 모든 layout·page 가 setRequestLocale 을 + // 호출해야 한다 — 하나라도 빠지면 세그먼트가 dynamic 으로 떨어진다(#942). + const { locale } = await params; + setRequestLocale(locale); + return {children}; } diff --git a/packages/web/app/[locale]/(shell)/page.tsx b/packages/web/app/[locale]/(shell)/page.tsx index b1fa86670..fe02af74f 100644 --- a/packages/web/app/[locale]/(shell)/page.tsx +++ b/packages/web/app/[locale]/(shell)/page.tsx @@ -18,6 +18,7 @@ import type { CurationListResponse, FeedItem, } from "@/lib/api/generated/models"; +import { setRequestLocale } from "next-intl/server"; import { serverApiGet } from "@/lib/api/server-instance"; import { isLocale, type Locale } from "@/lib/i18n/locale"; import { getPostMatch } from "@/lib/api/adapters/postMatch"; @@ -188,18 +189,18 @@ async function fetchHeroSlides( export default async function Home({ params, - searchParams, }: { params: Promise<{ locale: string }>; - searchParams: Promise<{ variant?: string }>; }) { const { locale: rawLocale } = await params; + // Enable static rendering — every layout/page in the [locale] tree must call + // this or the segment falls back to on-demand dynamic (#942). + setRequestLocale(rawLocale); // params.locale is a raw string; narrow to Locale so serverApiGet forwards // the request locale (invalid values fall back to the util's RENDERED_LOCALE). const locale: Locale | undefined = isLocale(rawLocale) ? rawLocale : undefined; - await searchParams; const [popularPosts, recentPosts, moodPool, artistProfileMap, curations] = await Promise.all([ diff --git a/packages/web/app/[locale]/layout.tsx b/packages/web/app/[locale]/layout.tsx index 366d5f9f7..80f00a32a 100644 --- a/packages/web/app/[locale]/layout.tsx +++ b/packages/web/app/[locale]/layout.tsx @@ -4,6 +4,18 @@ import { notFound } from "next/navigation"; import { routing } from "@/i18n/routing"; import { LazyTasteOnboardingPrompt } from "@/lib/components/onboarding/LazyTasteOnboardingPrompt"; +// 빌드타임에 locale 을 확정해 [locale] 트리를 정적/ISR 렌더 가능하게 한다 +// (없으면 Next 가 세그먼트 전체를 on-demand dynamic 으로 처리 — #942). +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +// 앱 전체 canonical 타임존. next-intl 포매터를 서버/클라 어디서도 쓰지 않으므로 +// (날짜 "time ago" 는 컴포넌트에서 수동 계산) 이 값은 렌더 결과에 영향이 없고, +// 오직 NextIntlClientProvider 의 정적 렌더 조건(locale·now·timeZone 명시)을 +// 충족시키기 위한 것이다 — ko-first 제품에 맞춰 Asia/Seoul 로 고정. +const APP_TIME_ZONE = "Asia/Seoul"; + export default async function LocaleLayout({ children, modal, @@ -18,7 +30,13 @@ export default async function LocaleLayout({ setRequestLocale(locale); return ( - + // locale·now·timeZone 을 명시해야 provider 가 정적 렌더된다 — 명시하지 + // 않으면 next-intl 이 이 트리를 dynamic 으로 opt-in 시킨다(#942). + {children} {modal} {/* 로그인 유저 taste 넛지 — next-intl 컨텍스트가 필요해 [locale] 레이아웃