diff --git a/docs/MOBILE_SSR_RESPONSIVE.md b/docs/MOBILE_SSR_RESPONSIVE.md index 331ce6c1..e3e42d3e 100644 --- a/docs/MOBILE_SSR_RESPONSIVE.md +++ b/docs/MOBILE_SSR_RESPONSIVE.md @@ -21,6 +21,9 @@ This doc describes how dStruct mitigates SSR flicker on mobile and the CSS-first | `src/themes.ts` | `createCustomTheme(deviceType)` — injects `MuiUseMediaQuery.defaultProps.ssrMatchMedia` so media queries resolve correctly during SSR. `queryMatchesViewport` returns `false` for unsupported query types (conservative). | | `src/shared/ui/providers/StateThemeProvider.tsx` | Accepts `ssrDeviceType`, creates theme via `createCustomTheme(ssrDeviceType)`. | | `src/pages/playground/[[...slug]].tsx` | Uses `getServerSideProps` to resolve `ssrDeviceType` from `req.headers`, calls `setDeviceHintResponseHeaders(res)`, passes `ssrDeviceType` into page props → `_app` → `StateThemeProvider`. | +| `src/app/(default-locale)/layout.tsx` | Shared `LocaleAppLayout` for all unprefixed App routes (single provider tree). | +| `src/app/locale-app/LocaleAppLayout.tsx` | Reads proxy-set `x-dstruct-ssr-device-type` on playground requests → `AppShellProviders`. | +| `src/proxy.ts` | Sets `x-dstruct-ssr-device-type`, `Accept-CH`, and `Vary` on playground paths. | | `src/pages/_app.tsx` | Passes `pageProps.ssrDeviceType` into `StateThemeProvider`. No `getInitialProps` — device hint is page-scoped. | ## Header Strategy diff --git a/e2e/api-smoke.spec.ts b/e2e/api-smoke.spec.ts index 4a0cdbad..3adb7720 100644 --- a/e2e/api-smoke.spec.ts +++ b/e2e/api-smoke.spec.ts @@ -23,4 +23,12 @@ test.describe("API routes", () => { expect(trpcResponse.headers()["x-matched-path"]).toBe("/api/trpc/[trpc]"); } }); + + test("playground sets Accept-CH for SSR device hints", async ({ + request, + }) => { + const response = await request.get("/playground"); + expect(response.ok()).toBe(true); + expect(response.headers()["accept-ch"]).toContain("Sec-CH-UA-Mobile"); + }); }); diff --git a/next.config.mjs b/next.config.mjs index 4e23aa5f..c18e267e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -11,9 +11,9 @@ void ( const config = { reactStrictMode: true, productionBrowserSourceMaps: true, - // TODO(Instant Nav): enable after root layout avoids blocking `headers()` under Cache Components. - // cacheComponents: true, - // partialPrefetching: true, + // L5: incremental Cache Components adoption (`instant = false` on runtime segments). + cacheComponents: true, + partialPrefetching: true, // Bundled docs: `node_modules/next/dist/docs/02-pages/04-api-reference/04-config/01-next-config-js/poweredByHeader.md` poweredByHeader: false, // Bundled docs: `node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/reactCompiler.md` diff --git a/src/app/(default-locale)/layout.tsx b/src/app/(default-locale)/layout.tsx index 32370ecb..7d57bc43 100644 --- a/src/app/(default-locale)/layout.tsx +++ b/src/app/(default-locale)/layout.tsx @@ -2,9 +2,10 @@ import { baseLocale } from "#/i18n/i18n-util"; import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Default-locale (`en`) public App shell at unprefixed URLs (L2). */ -export const dynamic = "force-dynamic"; +/** Locale shell uses session/i18n loaders — opt out until cached (L5). */ +export const instant = false; +/** Default-locale (`en`) public App shell at unprefixed URLs (L2). */ export default async function DefaultLocaleLayout({ children, }: { diff --git a/src/app/AppRootLayoutClient.tsx b/src/app/AppRootLayoutClient.tsx index 6e414a0f..6c3334eb 100644 --- a/src/app/AppRootLayoutClient.tsx +++ b/src/app/AppRootLayoutClient.tsx @@ -14,12 +14,14 @@ import { type I18nProps } from "#/i18n/getI18nProps"; import type { Locales } from "#/i18n/i18n-types"; import { AppShellProviders } from "#/shared/ui/providers/AppShellProviders"; import { AppRouterI18nProvider } from "#/shared/ui/providers/I18nProvider"; +import type { SsrDeviceType } from "#/themes"; type AppRootLayoutClientProps = { children: ReactNode; i18n: I18nProps; session: Session | null; locale: Locales; + ssrDeviceType?: SsrDeviceType; }; /** @@ -30,10 +32,11 @@ export const AppRootLayoutClient: React.FC = ({ i18n, session, locale, + ssrDeviceType, }) => { return ( - + diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index 54c0eff2..c38e4ee6 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,7 +1,7 @@ import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale marketing + app shell under `app/[lang]` (L1; Pages `i18n` still canonical for unprefixed `en` URLs). */ -export const dynamic = "force-dynamic"; +/** Locale shell uses session/i18n loaders — opt out until cached (L5). */ +export const instant = false; export default async function LangLayout({ children, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 23973c6c..3f1be35c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -17,6 +17,9 @@ import "overlayscrollbars/overlayscrollbars.css"; export { appDocumentMetadata as metadata, appDocumentViewport as viewport }; +/** Root reads request locale header — opt out of instant validation until Suspense split (L5). */ +export const instant = false; + /** * Minimal root shell for App Router only. Locale comes from {@link APP_ROUTER_LOCALE_HEADER} * (set in proxy for App Router locale paths). diff --git a/src/app/locale-app/LocaleAppLayout.tsx b/src/app/locale-app/LocaleAppLayout.tsx index 3e2b7dff..8a56ed1f 100644 --- a/src/app/locale-app/LocaleAppLayout.tsx +++ b/src/app/locale-app/LocaleAppLayout.tsx @@ -1,14 +1,17 @@ import { getServerSession } from "next-auth"; +import { headers } from "next/headers"; import { notFound } from "next/navigation"; import type { Locales } from "#/i18n/i18n-types"; import { locales } from "#/i18n/i18n-util"; import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; import { authOptions } from "#/server/auth/authOptions"; +import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; +import { parseSsrDeviceTypeHeader } from "#/shared/lib/ssrDevice"; import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; -/** Shared App Router locale layout for `app/[lang]`. */ +/** Shared App Router locale layout for `app/[lang]` and `(default-locale)`. */ export async function LocaleAppLayout({ children, localeParam, @@ -22,9 +25,18 @@ export async function LocaleAppLayout({ const locale = localeParam as Locales; const session = await getServerSession(authOptions); const i18n = await loadI18nForLocale(locale); + const headerList = await headers(); + const ssrDeviceType = parseSsrDeviceTypeHeader( + headerList.get(APP_ROUTER_SSR_DEVICE_TYPE_HEADER), + ); return ( - + {children} ); diff --git a/src/proxy.ts b/src/proxy.ts index ffdefc6d..7f7a02b9 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,13 +3,31 @@ import { type NextRequest, NextResponse } from "next/server"; import { baseLocale, locales } from "#/i18n/i18n-util"; import { isDefaultLocalePublicMarketingPath } from "#/i18n/localeMigrationRouting"; -import { APP_ROUTER_LOCALE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; +import { + APP_ROUTER_LOCALE_HEADER, + APP_ROUTER_SSR_DEVICE_TYPE_HEADER, +} from "#/shared/lib/appRouterLocaleHeader"; +import { parsePlaygroundPathname } from "#/shared/lib/playgroundRoute"; +import { + applyDeviceHintResponseHeaders, + resolveSsrDeviceType, +} from "#/shared/lib/ssrDevice"; const localeSet = new Set(locales); -function withLocaleHeader(request: NextRequest, locale: string): Headers { +function withAppRouterRequestHeaders( + request: NextRequest, + locale: string, + pathname: string, +): Headers { const requestHeaders = new Headers(request.headers); requestHeaders.set(APP_ROUTER_LOCALE_HEADER, locale); + if (parsePlaygroundPathname(pathname)) { + requestHeaders.set( + APP_ROUTER_SSR_DEVICE_TYPE_HEADER, + resolveSsrDeviceType(request.headers), + ); + } return requestHeaders; } @@ -21,12 +39,29 @@ function localeFromPathname(pathname: string): string | null { return null; } +function nextWithLocaleHeader( + request: NextRequest, + locale: string, + pathname: string, +): NextResponse { + const response = NextResponse.next({ + request: { + headers: withAppRouterRequestHeaders(request, locale, pathname), + }, + }); + if (parsePlaygroundPathname(pathname)) { + applyDeviceHintResponseHeaders(response); + } + return response; +} + /** * Next.js 16+ request proxy (replaces `middleware.ts`). * * - Serves `/api/config` from Edge Config. * - Sets {@link APP_ROUTER_LOCALE_HEADER} for App Router locale paths * (`/[lang]/*` and L2 unprefixed default-locale marketing). + * - Sets {@link APP_ROUTER_SSR_DEVICE_TYPE_HEADER} + `Accept-CH` on playground paths. * * Unprefixed `/`, `/privacy`, … are App `(default-locale)` routes (L2). */ @@ -39,16 +74,12 @@ export async function proxy(request: NextRequest) { } if (isDefaultLocalePublicMarketingPath(pathname)) { - return NextResponse.next({ - request: { headers: withLocaleHeader(request, baseLocale) }, - }); + return nextWithLocaleHeader(request, baseLocale, pathname); } const locale = localeFromPathname(pathname); if (locale) { - return NextResponse.next({ - request: { headers: withLocaleHeader(request, locale) }, - }); + return nextWithLocaleHeader(request, locale, pathname); } return NextResponse.next(); diff --git a/src/shared/lib/__tests__/ssrDevice.test.ts b/src/shared/lib/__tests__/ssrDevice.test.ts new file mode 100644 index 00000000..e077f16e --- /dev/null +++ b/src/shared/lib/__tests__/ssrDevice.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { + applyDeviceHintResponseHeaders, + parseSsrDeviceTypeHeader, + resolveSsrDeviceType, +} from "#/shared/lib/ssrDevice"; + +describe("ssrDevice", () => { + describe("resolveSsrDeviceType", () => { + it("prefers Sec-CH-UA-Mobile when present", () => { + const headers = new Headers({ "sec-ch-ua-mobile": "?1" }); + expect(resolveSsrDeviceType(headers)).toBe("mobile"); + }); + + it("falls back to mobile User-Agent", () => { + const headers = new Headers({ + "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", + }); + expect(resolveSsrDeviceType(headers)).toBe("mobile"); + }); + + it("defaults to desktop when hints are absent", () => { + expect(resolveSsrDeviceType(new Headers())).toBe("desktop"); + }); + }); + + describe("parseSsrDeviceTypeHeader", () => { + it("returns mobile or desktop when valid", () => { + expect(parseSsrDeviceTypeHeader("mobile")).toBe("mobile"); + expect(parseSsrDeviceTypeHeader("desktop")).toBe("desktop"); + }); + + it("returns undefined for missing or invalid values", () => { + expect(parseSsrDeviceTypeHeader(null)).toBeUndefined(); + expect(parseSsrDeviceTypeHeader("tablet")).toBeUndefined(); + }); + }); + + describe("applyDeviceHintResponseHeaders", () => { + it("sets Accept-CH and Vary for client hint negotiation", () => { + const response = { headers: new Headers() }; + applyDeviceHintResponseHeaders(response); + + expect(response.headers.get("Accept-CH")).toBe("Sec-CH-UA-Mobile"); + expect(response.headers.get("Vary")).toBe("User-Agent, Sec-CH-UA-Mobile"); + }); + + it("merges with existing Accept-CH and Vary values", () => { + const response = { + headers: new Headers({ + "Accept-CH": "Viewport-Width", + Vary: "Accept-Language", + }), + }; + applyDeviceHintResponseHeaders(response); + + expect(response.headers.get("Accept-CH")).toBe( + "Viewport-Width, Sec-CH-UA-Mobile", + ); + expect(response.headers.get("Vary")).toBe( + "Accept-Language, User-Agent, Sec-CH-UA-Mobile", + ); + }); + }); +}); diff --git a/src/shared/lib/appRouterLocaleHeader.ts b/src/shared/lib/appRouterLocaleHeader.ts index 39cd20f6..46bf2cc6 100644 --- a/src/shared/lib/appRouterLocaleHeader.ts +++ b/src/shared/lib/appRouterLocaleHeader.ts @@ -1,2 +1,5 @@ -/** Request header set by `src/middleware.ts` for App Router HTML `lang` / `dir`. */ +/** Request header set by `src/proxy.ts` for App Router HTML `lang` / `dir`. */ export const APP_ROUTER_LOCALE_HEADER = "x-dstruct-app-locale"; + +/** Playground-only SSR device hint (`mobile` | `desktop`) set by `src/proxy.ts`. */ +export const APP_ROUTER_SSR_DEVICE_TYPE_HEADER = "x-dstruct-ssr-device-type"; diff --git a/src/shared/lib/ssrDevice.ts b/src/shared/lib/ssrDevice.ts index 34f95a12..e873786d 100644 --- a/src/shared/lib/ssrDevice.ts +++ b/src/shared/lib/ssrDevice.ts @@ -78,6 +78,16 @@ export const resolveSsrDeviceType = ( return "desktop"; }; +/** Parses proxy-set {@link APP_ROUTER_SSR_DEVICE_TYPE_HEADER} for playground SSR theme. */ +export const parseSsrDeviceTypeHeader = ( + value: string | null, +): SsrDeviceType | undefined => { + if (value === "mobile" || value === "desktop") { + return value; + } + return undefined; +}; + export const setDeviceHintResponseHeaders = (res?: ServerResponse) => { if (!res) return; @@ -91,3 +101,22 @@ export const setDeviceHintResponseHeaders = (res?: ServerResponse) => { mergeHeaderList(res.getHeader("Vary"), ["User-Agent", "Sec-CH-UA-Mobile"]), ); }; + +/** Edge/proxy variant — merge device-hint response headers onto a NextResponse. */ +export const applyDeviceHintResponseHeaders = (response: { + headers: Headers; +}) => { + response.headers.set( + "Accept-CH", + mergeHeaderList(response.headers.get("Accept-CH") ?? undefined, [ + "Sec-CH-UA-Mobile", + ]), + ); + response.headers.set( + "Vary", + mergeHeaderList(response.headers.get("Vary") ?? undefined, [ + "User-Agent", + "Sec-CH-UA-Mobile", + ]), + ); +}; diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index 0e8a83ce..ea27234f 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -23,34 +23,25 @@ - [x] Remove `i18n` from `next.config.mjs` — L2 - [x] Retire `/internal-marketing/*` pilot (L3b redirects + delete tree) -## Phase 2 — App Router pilot +## Phase 2 — App Router shell (locale migration complete) - [x] `TrpcProvider` + `AppRootLayoutClient` - [x] `src/app/layout.tsx` + public App locale routes -- [x] `MarketingHomeView` shared by Pages home + App pilot -- [x] Dual-router shell (`next/compat/router`) so App pilot does not throw -- [x] Public `/` served from App `(default-locale)` (Pages marketing removed L2/L3) +- [x] `MarketingHomeView` shared by App marketing routes +- [x] Dual-router shell (`next/compat/router`) +- [x] Public `/` served from App `(default-locale)` - [x] `proxy.ts`: `/api/config` + locale header for App Router paths - [x] App layout metadata (viewport, icons, Material Icons) -- [x] ~~Pilot noindex metadata~~ (removed with L3b internal-marketing pilot) -- [ ] `cacheComponents` / `partialPrefetching` (blocked: root `headers()` + need 16.3) -- [ ] `unstable_instant` on marketing routes (blocked until `cacheComponents`) +- [x] Playground SSR device hints via proxy header + `Accept-CH` (single shared App shell) - [x] Remove unused `@trpc/next` dependency - [x] Extract `authOptions` to `src/server/auth/authOptions.ts` - [x] Extract `AppShellProviders` shared by `_app` and `AppRootLayoutClient` -- [x] SSR i18n preload for playground + profile (`loadI18nServerProps` / `withI18nServerSideProps`) +- [x] SSR i18n preload for playground + profile - [x] Localized SEO titles/descriptions for home, playground landing, profile -## Phase 3+ — Playground / full migration - -- [x] Playground App route shell (`app/[lang]/playground`, `(default-locale)/playground`) -- [x] `PlaygroundPageView` shared by Pages + App pilot -- [x] `usePlaygroundRoute` bridge for slug navigation under App Router -- [x] Profile App route shell (`app/[lang]/profile`, `(default-locale)/profile`) -- [x] `ProfilePageView` shared by Pages + App pilot -- [x] `useProfileUserId` bridge for App vs Pages route param -- [x] Playwright locale migration e2e (`e2e/locale-migration-l*.spec.ts`, `e2e/api-smoke.spec.ts`) -- [x] `pnpm preview-smoke` script for Vercel merge-gate checks -- [x] GitHub Actions e2e on Vercel preview (`.github/workflows/e2e-preview.yml`, `deployment_status`) -- [ ] `@next/playwright` `instant()` tests (blocked: `cacheComponents` + `unstable_instant` + 16.3.x) -- [ ] Locale migration epic — **`vibe-docs/Locale-Migration-Design.md`** +## Phase 3 — Instant Nav / Cache Components (L5 in progress) + +- [x] **`cacheComponents` + `partialPrefetching`** enabled (incremental — `instant = false` on runtime segments) +- [ ] Remove `instant = false` from marketing routes (cache session/i18n or Suspense-split root `headers()`) +- [ ] `unstable_instant` on marketing routes +- [ ] `@next/playwright` `instant()` tests diff --git a/vibe-docs/Locale-Migration-Design.md b/vibe-docs/Locale-Migration-Design.md index 3233114b..47e50333 100644 --- a/vibe-docs/Locale-Migration-Design.md +++ b/vibe-docs/Locale-Migration-Design.md @@ -88,11 +88,12 @@ Pages `i18n` auto-redirects `/en/*` → unprefixed URLs, so **`next.config` rewr 2. ~~Delete Pages `playground` / `profile`~~ (done L2). 3. ~~**`i18n` block removed** from `next.config.mjs`~~ (done L2). -### L5 — Instant Nav flags +### L5 — Instant Nav flags (in progress) -1. Resolve root `headers()` / Cache Components blockers in `app/layout.tsx`. -2. Enable `cacheComponents`, `partialPrefetching`, `unstable_instant` on marketing routes. -3. Add `@next/playwright` `instant()` tests. +1. ~~Enable `cacheComponents`, `partialPrefetching`~~ — enabled with `instant = false` on runtime segments. +2. Resolve root `headers()` / Cache Components blockers in `app/layout.tsx` (Suspense split or `'use cache: private'`). +3. Remove `instant = false` from marketing routes; add `unstable_instant` where validated. +4. Add `@next/playwright` `instant()` tests. ---