Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/MOBILE_SSR_RESPONSIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions e2e/api-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
6 changes: 3 additions & 3 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
5 changes: 3 additions & 2 deletions src/app/(default-locale)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand Down
5 changes: 4 additions & 1 deletion src/app/AppRootLayoutClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -30,10 +32,11 @@ export const AppRootLayoutClient: React.FC<AppRootLayoutClientProps> = ({
i18n,
session,
locale,
ssrDeviceType,
}) => {
return (
<AppRouterCacheProvider options={{ key: "css" }}>
<AppShellProviders session={session}>
<AppShellProviders session={session} ssrDeviceType={ssrDeviceType}>
<AppRouterI18nProvider locale={locale} i18n={i18n}>
<CookieConsentRoot>
<ProjectBrowserProvider>
Expand Down
4 changes: 2 additions & 2 deletions src/app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 14 additions & 2 deletions src/app/locale-app/LocaleAppLayout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<AppRootLayoutClient session={session} i18n={i18n} locale={locale}>
<AppRootLayoutClient
session={session}
i18n={i18n}
locale={locale}
ssrDeviceType={ssrDeviceType}
>
{children}
</AppRootLayoutClient>
);
Expand Down
47 changes: 39 additions & 8 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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;
}

Expand All @@ -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).
*/
Expand All @@ -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();
Expand Down
66 changes: 66 additions & 0 deletions src/shared/lib/__tests__/ssrDevice.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
});
5 changes: 4 additions & 1 deletion src/shared/lib/appRouterLocaleHeader.ts
Original file line number Diff line number Diff line change
@@ -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";
29 changes: 29 additions & 0 deletions src/shared/lib/ssrDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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",
]),
);
};
33 changes: 12 additions & 21 deletions vibe-docs/Instant-Navigations-TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 5 additions & 4 deletions vibe-docs/Locale-Migration-Design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
Loading