From e307af9f1e078f69da4e3787dd9b041587989656 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 18:42:51 +0000 Subject: [PATCH 1/3] feat(app): playground SSR device hints + L5 Cache Components prep Restore mobile SSR theme hints on App playground routes via dedicated playground segment layouts and proxy Accept-CH headers. Enable cacheComponents and partialPrefetching incrementally with instant=false on runtime segments (root headers, session/i18n layouts). --- docs/MOBILE_SSR_RESPONSIVE.md | 3 ++ next.config.mjs | 6 +-- .../{ => (marketing)}/daily/page.tsx | 0 .../(default-locale)/(marketing)/layout.tsx | 15 ++++++ .../{ => (marketing)}/page.tsx | 0 .../{ => (marketing)}/privacy/page.tsx | 0 .../profile/[userId]/page.tsx | 0 src/app/(default-locale)/layout.tsx | 12 ++--- .../(default-locale)/playground/layout.tsx | 25 +++++++++ src/app/AppRootLayoutClient.tsx | 5 +- .../[lang]/{ => (marketing)}/daily/page.tsx | 0 src/app/[lang]/(marketing)/layout.tsx | 16 ++++++ src/app/[lang]/{ => (marketing)}/page.tsx | 0 .../[lang]/{ => (marketing)}/privacy/page.tsx | 0 .../profile/[userId]/page.tsx | 0 src/app/[lang]/layout.tsx | 13 ++--- src/app/[lang]/playground/layout.tsx | 27 ++++++++++ src/app/layout.tsx | 3 ++ src/app/locale-app/LocaleAppLayout.tsx | 12 ++++- src/proxy.ts | 25 ++++++--- src/shared/lib/__tests__/ssrDevice.test.ts | 53 +++++++++++++++++++ src/shared/lib/ssrDevice.ts | 19 +++++++ vibe-docs/Instant-Navigations-TODO.md | 35 +++++------- vibe-docs/Locale-Migration-Design.md | 9 ++-- 24 files changed, 222 insertions(+), 56 deletions(-) rename src/app/(default-locale)/{ => (marketing)}/daily/page.tsx (100%) create mode 100644 src/app/(default-locale)/(marketing)/layout.tsx rename src/app/(default-locale)/{ => (marketing)}/page.tsx (100%) rename src/app/(default-locale)/{ => (marketing)}/privacy/page.tsx (100%) rename src/app/(default-locale)/{ => (marketing)}/profile/[userId]/page.tsx (100%) create mode 100644 src/app/(default-locale)/playground/layout.tsx rename src/app/[lang]/{ => (marketing)}/daily/page.tsx (100%) create mode 100644 src/app/[lang]/(marketing)/layout.tsx rename src/app/[lang]/{ => (marketing)}/page.tsx (100%) rename src/app/[lang]/{ => (marketing)}/privacy/page.tsx (100%) rename src/app/[lang]/{ => (marketing)}/profile/[userId]/page.tsx (100%) create mode 100644 src/app/[lang]/playground/layout.tsx create mode 100644 src/shared/lib/__tests__/ssrDevice.test.ts diff --git a/docs/MOBILE_SSR_RESPONSIVE.md b/docs/MOBILE_SSR_RESPONSIVE.md index 331ce6c1..2394fda6 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)/playground/layout.tsx` | App Router default-locale playground: reads `headers()` via `resolveSsrDeviceType`, passes `ssrDeviceType` into `LocaleAppLayout` → `AppShellProviders`. | +| `src/app/[lang]/playground/layout.tsx` | Same SSR device hint for locale-prefixed playground (`/de/playground`, …). | +| `src/proxy.ts` | Sets `Accept-CH` / `Vary` on playground paths via `applyDeviceHintResponseHeaders` (Edge-safe helper in `ssrDevice.ts`). | | `src/pages/_app.tsx` | Passes `pageProps.ssrDeviceType` into `StateThemeProvider`. No `getInitialProps` — device hint is page-scoped. | ## Header Strategy 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)/daily/page.tsx b/src/app/(default-locale)/(marketing)/daily/page.tsx similarity index 100% rename from src/app/(default-locale)/daily/page.tsx rename to src/app/(default-locale)/(marketing)/daily/page.tsx diff --git a/src/app/(default-locale)/(marketing)/layout.tsx b/src/app/(default-locale)/(marketing)/layout.tsx new file mode 100644 index 00000000..d812a10f --- /dev/null +++ b/src/app/(default-locale)/(marketing)/layout.tsx @@ -0,0 +1,15 @@ +import { baseLocale } from "#/i18n/i18n-util"; + +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; + +/** Locale layouts use session/i18n loaders — opt out until cached (L5). */ +export const instant = false; + +/** Default-locale marketing shell at unprefixed URLs (L2). */ +export default async function DefaultLocaleMarketingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(default-locale)/page.tsx b/src/app/(default-locale)/(marketing)/page.tsx similarity index 100% rename from src/app/(default-locale)/page.tsx rename to src/app/(default-locale)/(marketing)/page.tsx diff --git a/src/app/(default-locale)/privacy/page.tsx b/src/app/(default-locale)/(marketing)/privacy/page.tsx similarity index 100% rename from src/app/(default-locale)/privacy/page.tsx rename to src/app/(default-locale)/(marketing)/privacy/page.tsx diff --git a/src/app/(default-locale)/profile/[userId]/page.tsx b/src/app/(default-locale)/(marketing)/profile/[userId]/page.tsx similarity index 100% rename from src/app/(default-locale)/profile/[userId]/page.tsx rename to src/app/(default-locale)/(marketing)/profile/[userId]/page.tsx diff --git a/src/app/(default-locale)/layout.tsx b/src/app/(default-locale)/layout.tsx index 32370ecb..440f34a8 100644 --- a/src/app/(default-locale)/layout.tsx +++ b/src/app/(default-locale)/layout.tsx @@ -1,14 +1,8 @@ -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"; - -export default async function DefaultLocaleLayout({ +/** Passthrough; locale shells live in `(marketing)` and `playground` segment layouts. */ +export default function DefaultLocaleRootLayout({ children, }: { children: React.ReactNode; }) { - return {children}; + return children; } diff --git a/src/app/(default-locale)/playground/layout.tsx b/src/app/(default-locale)/playground/layout.tsx new file mode 100644 index 00000000..05a93495 --- /dev/null +++ b/src/app/(default-locale)/playground/layout.tsx @@ -0,0 +1,25 @@ +import { headers } from "next/headers"; + +import { baseLocale } from "#/i18n/i18n-util"; +import { resolveSsrDeviceType } from "#/shared/lib/ssrDevice"; + +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; + +/** Playground layout reads request headers for SSR device hint (L5). */ +export const instant = false; + +/** Playground shell with SSR device hint for mobile layout (parity with Pages GSSP). */ +export default async function DefaultLocalePlaygroundLayout({ + children, +}: { + children: React.ReactNode; +}) { + const headerList = await headers(); + const ssrDeviceType = resolveSsrDeviceType(headerList); + + return ( + + {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]/daily/page.tsx b/src/app/[lang]/(marketing)/daily/page.tsx similarity index 100% rename from src/app/[lang]/daily/page.tsx rename to src/app/[lang]/(marketing)/daily/page.tsx diff --git a/src/app/[lang]/(marketing)/layout.tsx b/src/app/[lang]/(marketing)/layout.tsx new file mode 100644 index 00000000..6af171fb --- /dev/null +++ b/src/app/[lang]/(marketing)/layout.tsx @@ -0,0 +1,16 @@ +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; + +/** Locale layouts use session/i18n loaders — opt out until cached (L5). */ +export const instant = false; + +/** Locale marketing shell under `app/[lang]/(marketing)`. */ +export default async function LangMarketingLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + return {children}; +} diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/(marketing)/page.tsx similarity index 100% rename from src/app/[lang]/page.tsx rename to src/app/[lang]/(marketing)/page.tsx diff --git a/src/app/[lang]/privacy/page.tsx b/src/app/[lang]/(marketing)/privacy/page.tsx similarity index 100% rename from src/app/[lang]/privacy/page.tsx rename to src/app/[lang]/(marketing)/privacy/page.tsx diff --git a/src/app/[lang]/profile/[userId]/page.tsx b/src/app/[lang]/(marketing)/profile/[userId]/page.tsx similarity index 100% rename from src/app/[lang]/profile/[userId]/page.tsx rename to src/app/[lang]/(marketing)/profile/[userId]/page.tsx diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index 54c0eff2..80f834a0 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,15 +1,8 @@ -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"; - -export default async function LangLayout({ +/** Passthrough; locale shells live in `(marketing)` and `playground` segment layouts. */ +export default function LangRootLayout({ children, - params, }: { children: React.ReactNode; - params: Promise<{ lang: string }>; }) { - const { lang } = await params; - return {children}; + return children; } diff --git a/src/app/[lang]/playground/layout.tsx b/src/app/[lang]/playground/layout.tsx new file mode 100644 index 00000000..2315c675 --- /dev/null +++ b/src/app/[lang]/playground/layout.tsx @@ -0,0 +1,27 @@ +import { headers } from "next/headers"; + +import { resolveSsrDeviceType } from "#/shared/lib/ssrDevice"; + +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; + +/** Playground layout reads request headers for SSR device hint (L5). */ +export const instant = false; + +/** Playground shell with SSR device hint for mobile layout (parity with Pages GSSP). */ +export default async function LangPlaygroundLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ lang: string }>; +}) { + const { lang } = await params; + const headerList = await headers(); + const ssrDeviceType = resolveSsrDeviceType(headerList); + + return ( + + {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..841575e3 100644 --- a/src/app/locale-app/LocaleAppLayout.tsx +++ b/src/app/locale-app/LocaleAppLayout.tsx @@ -5,16 +5,19 @@ 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 type { SsrDeviceType } from "#/themes"; 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, + ssrDeviceType, }: { children: React.ReactNode; localeParam: string; + ssrDeviceType?: SsrDeviceType; }) { if (!locales.includes(localeParam as Locales)) { notFound(); @@ -24,7 +27,12 @@ export async function LocaleAppLayout({ const i18n = await loadI18nForLocale(locale); return ( - + {children} ); diff --git a/src/proxy.ts b/src/proxy.ts index ffdefc6d..0a70001d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,6 +4,8 @@ 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 { parsePlaygroundPathname } from "#/shared/lib/playgroundRoute"; +import { applyDeviceHintResponseHeaders } from "#/shared/lib/ssrDevice"; const localeSet = new Set(locales); @@ -21,12 +23,27 @@ function localeFromPathname(pathname: string): string | null { return null; } +function nextWithLocaleHeader( + request: NextRequest, + locale: string, + pathname: string, +): NextResponse { + const response = NextResponse.next({ + request: { headers: withLocaleHeader(request, locale) }, + }); + 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 `Accept-CH` / `Vary` on playground paths for SSR device hints. * * Unprefixed `/`, `/privacy`, … are App `(default-locale)` routes (L2). */ @@ -39,16 +56,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..cacb9db3 --- /dev/null +++ b/src/shared/lib/__tests__/ssrDevice.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + applyDeviceHintResponseHeaders, + 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("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/ssrDevice.ts b/src/shared/lib/ssrDevice.ts index 34f95a12..c0b6df5e 100644 --- a/src/shared/lib/ssrDevice.ts +++ b/src/shared/lib/ssrDevice.ts @@ -91,3 +91,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..860136b4 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -23,34 +23,27 @@ - [x] Remove `i18n` from `next.config.mjs` — L2 - [x] Retire `/internal-marketing/*` pilot (L3b redirects + delete tree) -## Phase 2 — App Router pilot +- [x] Retire `/internal-marketing/*` pilot (L3b redirects + delete tree) + +## 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 on App routes (`playground/layout.tsx` + proxy `Accept-CH`) - [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. --- From 2c235640768f39dd176abd1ee27b53683a2fa5be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 20:07:22 +0000 Subject: [PATCH 2/3] fix(app): unified App shell for playground SSR device hints Restore single LocaleAppLayout at parent layouts instead of sibling (marketing)/playground shells that remounted tRPC/query providers on nav. Proxy sets x-dstruct-ssr-device-type on playground paths; LocaleAppLayout reads it. Add Accept-CH e2e, parseSsrDeviceTypeHeader tests, doc fixes. --- docs/MOBILE_SSR_RESPONSIVE.md | 6 ++-- e2e/api-smoke.spec.ts | 8 ++++++ .../(default-locale)/(marketing)/layout.tsx | 15 ---------- src/app/(default-locale)/layout.tsx | 13 +++++++-- .../(default-locale)/playground/layout.tsx | 25 ----------------- src/app/[lang]/(marketing)/layout.tsx | 16 ----------- src/app/[lang]/layout.tsx | 13 +++++++-- src/app/[lang]/playground/layout.tsx | 27 ------------------ src/app/locale-app/LocaleAppLayout.tsx | 10 +++++-- src/proxy.ts | 28 +++++++++++++++---- src/shared/lib/__tests__/ssrDevice.test.ts | 13 +++++++++ src/shared/lib/appRouterLocaleHeader.ts | 5 +++- src/shared/lib/ssrDevice.ts | 10 +++++++ vibe-docs/Instant-Navigations-TODO.md | 4 +-- 14 files changed, 89 insertions(+), 104 deletions(-) delete mode 100644 src/app/(default-locale)/(marketing)/layout.tsx delete mode 100644 src/app/(default-locale)/playground/layout.tsx delete mode 100644 src/app/[lang]/(marketing)/layout.tsx delete mode 100644 src/app/[lang]/playground/layout.tsx diff --git a/docs/MOBILE_SSR_RESPONSIVE.md b/docs/MOBILE_SSR_RESPONSIVE.md index 2394fda6..e3e42d3e 100644 --- a/docs/MOBILE_SSR_RESPONSIVE.md +++ b/docs/MOBILE_SSR_RESPONSIVE.md @@ -21,9 +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)/playground/layout.tsx` | App Router default-locale playground: reads `headers()` via `resolveSsrDeviceType`, passes `ssrDeviceType` into `LocaleAppLayout` → `AppShellProviders`. | -| `src/app/[lang]/playground/layout.tsx` | Same SSR device hint for locale-prefixed playground (`/de/playground`, …). | -| `src/proxy.ts` | Sets `Accept-CH` / `Vary` on playground paths via `applyDeviceHintResponseHeaders` (Edge-safe helper in `ssrDevice.ts`). | +| `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/src/app/(default-locale)/(marketing)/layout.tsx b/src/app/(default-locale)/(marketing)/layout.tsx deleted file mode 100644 index d812a10f..00000000 --- a/src/app/(default-locale)/(marketing)/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { baseLocale } from "#/i18n/i18n-util"; - -import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; - -/** Locale layouts use session/i18n loaders — opt out until cached (L5). */ -export const instant = false; - -/** Default-locale marketing shell at unprefixed URLs (L2). */ -export default async function DefaultLocaleMarketingLayout({ - children, -}: { - children: React.ReactNode; -}) { - return {children}; -} diff --git a/src/app/(default-locale)/layout.tsx b/src/app/(default-locale)/layout.tsx index 440f34a8..7d57bc43 100644 --- a/src/app/(default-locale)/layout.tsx +++ b/src/app/(default-locale)/layout.tsx @@ -1,8 +1,15 @@ -/** Passthrough; locale shells live in `(marketing)` and `playground` segment layouts. */ -export default function DefaultLocaleRootLayout({ +import { baseLocale } from "#/i18n/i18n-util"; + +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; + +/** 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, }: { children: React.ReactNode; }) { - return children; + return {children}; } diff --git a/src/app/(default-locale)/playground/layout.tsx b/src/app/(default-locale)/playground/layout.tsx deleted file mode 100644 index 05a93495..00000000 --- a/src/app/(default-locale)/playground/layout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { headers } from "next/headers"; - -import { baseLocale } from "#/i18n/i18n-util"; -import { resolveSsrDeviceType } from "#/shared/lib/ssrDevice"; - -import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; - -/** Playground layout reads request headers for SSR device hint (L5). */ -export const instant = false; - -/** Playground shell with SSR device hint for mobile layout (parity with Pages GSSP). */ -export default async function DefaultLocalePlaygroundLayout({ - children, -}: { - children: React.ReactNode; -}) { - const headerList = await headers(); - const ssrDeviceType = resolveSsrDeviceType(headerList); - - return ( - - {children} - - ); -} diff --git a/src/app/[lang]/(marketing)/layout.tsx b/src/app/[lang]/(marketing)/layout.tsx deleted file mode 100644 index 6af171fb..00000000 --- a/src/app/[lang]/(marketing)/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; - -/** Locale layouts use session/i18n loaders — opt out until cached (L5). */ -export const instant = false; - -/** Locale marketing shell under `app/[lang]/(marketing)`. */ -export default async function LangMarketingLayout({ - children, - params, -}: { - children: React.ReactNode; - params: Promise<{ lang: string }>; -}) { - const { lang } = await params; - return {children}; -} diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index 80f834a0..c38e4ee6 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,8 +1,15 @@ -/** Passthrough; locale shells live in `(marketing)` and `playground` segment layouts. */ -export default function LangRootLayout({ +import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; + +/** Locale shell uses session/i18n loaders — opt out until cached (L5). */ +export const instant = false; + +export default async function LangLayout({ children, + params, }: { children: React.ReactNode; + params: Promise<{ lang: string }>; }) { - return children; + const { lang } = await params; + return {children}; } diff --git a/src/app/[lang]/playground/layout.tsx b/src/app/[lang]/playground/layout.tsx deleted file mode 100644 index 2315c675..00000000 --- a/src/app/[lang]/playground/layout.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { headers } from "next/headers"; - -import { resolveSsrDeviceType } from "#/shared/lib/ssrDevice"; - -import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; - -/** Playground layout reads request headers for SSR device hint (L5). */ -export const instant = false; - -/** Playground shell with SSR device hint for mobile layout (parity with Pages GSSP). */ -export default async function LangPlaygroundLayout({ - children, - params, -}: { - children: React.ReactNode; - params: Promise<{ lang: string }>; -}) { - const { lang } = await params; - const headerList = await headers(); - const ssrDeviceType = resolveSsrDeviceType(headerList); - - return ( - - {children} - - ); -} diff --git a/src/app/locale-app/LocaleAppLayout.tsx b/src/app/locale-app/LocaleAppLayout.tsx index 841575e3..8a56ed1f 100644 --- a/src/app/locale-app/LocaleAppLayout.tsx +++ b/src/app/locale-app/LocaleAppLayout.tsx @@ -1,11 +1,13 @@ 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 type { SsrDeviceType } from "#/themes"; +import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; +import { parseSsrDeviceTypeHeader } from "#/shared/lib/ssrDevice"; import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; @@ -13,11 +15,9 @@ import { AppRootLayoutClient } from "#/app/AppRootLayoutClient"; export async function LocaleAppLayout({ children, localeParam, - ssrDeviceType, }: { children: React.ReactNode; localeParam: string; - ssrDeviceType?: SsrDeviceType; }) { if (!locales.includes(localeParam as Locales)) { notFound(); @@ -25,6 +25,10 @@ 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 ( (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; } @@ -29,7 +45,9 @@ function nextWithLocaleHeader( pathname: string, ): NextResponse { const response = NextResponse.next({ - request: { headers: withLocaleHeader(request, locale) }, + request: { + headers: withAppRouterRequestHeaders(request, locale, pathname), + }, }); if (parsePlaygroundPathname(pathname)) { applyDeviceHintResponseHeaders(response); @@ -43,7 +61,7 @@ function nextWithLocaleHeader( * - 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 `Accept-CH` / `Vary` on playground paths for SSR device hints. + * - Sets {@link APP_ROUTER_SSR_DEVICE_TYPE_HEADER} + `Accept-CH` on playground paths. * * Unprefixed `/`, `/privacy`, … are App `(default-locale)` routes (L2). */ diff --git a/src/shared/lib/__tests__/ssrDevice.test.ts b/src/shared/lib/__tests__/ssrDevice.test.ts index cacb9db3..e077f16e 100644 --- a/src/shared/lib/__tests__/ssrDevice.test.ts +++ b/src/shared/lib/__tests__/ssrDevice.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyDeviceHintResponseHeaders, + parseSsrDeviceTypeHeader, resolveSsrDeviceType, } from "#/shared/lib/ssrDevice"; @@ -24,6 +25,18 @@ describe("ssrDevice", () => { }); }); + 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() }; 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 c0b6df5e..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; diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index 860136b4..ea27234f 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -23,8 +23,6 @@ - [x] Remove `i18n` from `next.config.mjs` — L2 - [x] Retire `/internal-marketing/*` pilot (L3b redirects + delete tree) -- [x] Retire `/internal-marketing/*` pilot (L3b redirects + delete tree) - ## Phase 2 — App Router shell (locale migration complete) - [x] `TrpcProvider` + `AppRootLayoutClient` @@ -34,7 +32,7 @@ - [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] Playground SSR device hints on App routes (`playground/layout.tsx` + proxy `Accept-CH`) +- [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` From a528e3379e0663ebfaac6e0b82d59f3330f575c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 20:18:30 +0000 Subject: [PATCH 3/3] refactor(app): remove unused (marketing) route group Flatten pages back under (default-locale)/ and [lang]/ now that a single LocaleAppLayout handles all routes; the group had no layout or URL effect. --- src/app/(default-locale)/{(marketing) => }/daily/page.tsx | 0 src/app/(default-locale)/{(marketing) => }/page.tsx | 0 src/app/(default-locale)/{(marketing) => }/privacy/page.tsx | 0 .../(default-locale)/{(marketing) => }/profile/[userId]/page.tsx | 0 src/app/[lang]/{(marketing) => }/daily/page.tsx | 0 src/app/[lang]/{(marketing) => }/page.tsx | 0 src/app/[lang]/{(marketing) => }/privacy/page.tsx | 0 src/app/[lang]/{(marketing) => }/profile/[userId]/page.tsx | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename src/app/(default-locale)/{(marketing) => }/daily/page.tsx (100%) rename src/app/(default-locale)/{(marketing) => }/page.tsx (100%) rename src/app/(default-locale)/{(marketing) => }/privacy/page.tsx (100%) rename src/app/(default-locale)/{(marketing) => }/profile/[userId]/page.tsx (100%) rename src/app/[lang]/{(marketing) => }/daily/page.tsx (100%) rename src/app/[lang]/{(marketing) => }/page.tsx (100%) rename src/app/[lang]/{(marketing) => }/privacy/page.tsx (100%) rename src/app/[lang]/{(marketing) => }/profile/[userId]/page.tsx (100%) diff --git a/src/app/(default-locale)/(marketing)/daily/page.tsx b/src/app/(default-locale)/daily/page.tsx similarity index 100% rename from src/app/(default-locale)/(marketing)/daily/page.tsx rename to src/app/(default-locale)/daily/page.tsx diff --git a/src/app/(default-locale)/(marketing)/page.tsx b/src/app/(default-locale)/page.tsx similarity index 100% rename from src/app/(default-locale)/(marketing)/page.tsx rename to src/app/(default-locale)/page.tsx diff --git a/src/app/(default-locale)/(marketing)/privacy/page.tsx b/src/app/(default-locale)/privacy/page.tsx similarity index 100% rename from src/app/(default-locale)/(marketing)/privacy/page.tsx rename to src/app/(default-locale)/privacy/page.tsx diff --git a/src/app/(default-locale)/(marketing)/profile/[userId]/page.tsx b/src/app/(default-locale)/profile/[userId]/page.tsx similarity index 100% rename from src/app/(default-locale)/(marketing)/profile/[userId]/page.tsx rename to src/app/(default-locale)/profile/[userId]/page.tsx diff --git a/src/app/[lang]/(marketing)/daily/page.tsx b/src/app/[lang]/daily/page.tsx similarity index 100% rename from src/app/[lang]/(marketing)/daily/page.tsx rename to src/app/[lang]/daily/page.tsx diff --git a/src/app/[lang]/(marketing)/page.tsx b/src/app/[lang]/page.tsx similarity index 100% rename from src/app/[lang]/(marketing)/page.tsx rename to src/app/[lang]/page.tsx diff --git a/src/app/[lang]/(marketing)/privacy/page.tsx b/src/app/[lang]/privacy/page.tsx similarity index 100% rename from src/app/[lang]/(marketing)/privacy/page.tsx rename to src/app/[lang]/privacy/page.tsx diff --git a/src/app/[lang]/(marketing)/profile/[userId]/page.tsx b/src/app/[lang]/profile/[userId]/page.tsx similarity index 100% rename from src/app/[lang]/(marketing)/profile/[userId]/page.tsx rename to src/app/[lang]/profile/[userId]/page.tsx