Backend / staging cleanup, performance substrate, and create-flow polish #60

Merged
an.di merged 16 commits from adilallo/Backend/StagingCleanup into main 2026-05-26 15:11:47 +00:00
16 changed files with 432 additions and 72 deletions
Showing only changes of commit eded97559d - Show all commits
+9 -4
View File
@@ -1,5 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
// Reads the session for admin chrome (matches the HttpOnly cookie on first // Reads the session for admin chrome (matches the HttpOnly cookie on first
// HTML response). Scoped here so `(marketing)` can render statically. // HTML response). Scoped here so `(marketing)` can render statically.
@@ -9,9 +12,11 @@ export const dynamic = "force-dynamic";
// public marketing footer. Auth/access is enforced upstream. // public marketing footer. Auth/access is enforced upstream.
export default function AdminLayout({ children }: { children: ReactNode }) { export default function AdminLayout({ children }: { children: ReactNode }) {
return ( return (
<> <MessagesProvider messages={messages}>
<ConditionalNavigation /> <AuthModalProvider>
<main className="flex-1">{children}</main> <ConditionalNavigation />
</> <main className="flex-1">{children}</main>
</AuthModalProvider>
</MessagesProvider>
); );
} }
+9 -4
View File
@@ -1,5 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation"; import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
// Reads `cr_session` via Server Components on every navigation so the header // Reads `cr_session` via Server Components on every navigation so the header
// matches the HttpOnly cookie on the first HTML response (no "Log in" flash // matches the HttpOnly cookie on the first HTML response (no "Log in" flash
@@ -12,9 +15,11 @@ export const dynamic = "force-dynamic";
// CreateFlow) is composed in nested layouts. // CreateFlow) is composed in nested layouts.
export default function AppLayout({ children }: { children: ReactNode }) { export default function AppLayout({ children }: { children: ReactNode }) {
return ( return (
<> <MessagesProvider messages={messages}>
<ConditionalNavigation /> <AuthModalProvider>
<main className="flex-1">{children}</main> <ConditionalNavigation />
</> <main className="flex-1">{children}</main>
</AuthModalProvider>
</MessagesProvider>
); );
} }
+10 -1
View File
@@ -1,10 +1,19 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import messages from "../../messages/en/index";
// Development-only previews (e.g. `/components-preview`) — no public chrome. // Development-only previews (e.g. `/components-preview`) — no public chrome.
export default function DevLayout({ children }: { children: ReactNode }) { export default function DevLayout({ children }: { children: ReactNode }) {
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
notFound(); notFound();
} }
return <main className="flex-1">{children}</main>; return (
<MessagesProvider messages={messages}>
<AuthModalProvider>
<main className="flex-1">{children}</main>
</AuthModalProvider>
</MessagesProvider>
);
} }
+10 -5
View File
@@ -1,6 +1,9 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import MarketingNavigation from "../components/navigation/MarketingNavigation"; import MarketingNavigation from "../components/navigation/MarketingNavigation";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import marketingMessages from "../../messages/en/marketing";
// Site footer is part of the public marketing chrome only — not rendered for // Site footer is part of the public marketing chrome only — not rendered for
// signed-in product surfaces, admin dashboards, or dev previews. See // signed-in product surfaces, admin dashboards, or dev previews. See
@@ -14,10 +17,12 @@ const Footer = dynamic(() => import("../components/navigation/Footer"), {
export default function MarketingLayout({ children }: { children: ReactNode }) { export default function MarketingLayout({ children }: { children: ReactNode }) {
return ( return (
<> <MessagesProvider messages={marketingMessages}>
<MarketingNavigation /> <AuthModalProvider>
<main className="flex-1">{children}</main> <MarketingNavigation />
<Footer /> <main className="flex-1">{children}</main>
</> <Footer />
</AuthModalProvider>
</MessagesProvider>
); );
} }
+9 -13
View File
@@ -29,22 +29,18 @@ export default function LearnPage() {
<div className="min-h-screen bg-[var(--color-surface-default-primary)]"> <div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<ContentLockup {...contentLockupData} /> <ContentLockup {...contentLockupData} />
<div className="smd:hidden sm:pt-[var(--spacing-scale-024)] sm:pb-[var(--spacing-scale-024)] sm:px-[var(--spacing-scale-020)] space-y-[var(--spacing-scale-002)] sm:space-y-[var(--spacing-scale-008)]"> {/*
* Single responsive render: ContentThumbnailTemplate variant="responsive"
* uses <picture> to swap horizontal/vertical art at smd (530px). The
* container switches from a vertical flex stack (<smd) to a grid (≥smd),
* matching the prior twin-region layout without doubling the DOM.
*/}
<div className="flex flex-col space-y-[var(--spacing-scale-002)] sm:space-y-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-024)] sm:pb-[var(--spacing-scale-024)] smd:grid smd:grid-cols-2 smd:gap-[var(--spacing-scale-008)] smd:space-y-0 smd:px-[var(--spacing-scale-020)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-016)] md:px-[var(--spacing-scale-032)] xmd:grid-cols-3 xmd:gap-[var(--spacing-scale-012)] lg:grid-cols-3 lg:gap-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-064)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg2:grid-cols-4 lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:grid-cols-5 xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] [&>*]:min-w-0">
{allPosts.map((post) => ( {allPosts.map((post) => (
<ContentThumbnailTemplate <ContentThumbnailTemplate
key={`${post.slug}-horizontal`} key={post.slug}
post={post} post={post}
variant="horizontal" variant="responsive"
/>
))}
</div>
<div className="hidden smd:grid smd:grid-cols-2 xmd:grid-cols-3 lg:grid-cols-3 lg2:grid-cols-4 xl:grid-cols-5 smd:gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-016)] xmd:gap-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-016)] lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] smd:px-[var(--spacing-scale-020)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] [&>*]:min-w-0">
{allPosts.map((post) => (
<ContentThumbnailTemplate
key={`${post.slug}-vertical`}
post={post}
variant="vertical"
/> />
))} ))}
</div> </div>
+10 -3
View File
@@ -1,4 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { MessagesProvider } from "../contexts/MessagesContext";
import { AuthModalProvider } from "../contexts/AuthModalContext";
import marketingMessages from "../../messages/en/marketing";
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */ /** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
export default function MarketingCaseStudyLayout({ export default function MarketingCaseStudyLayout({
@@ -7,8 +10,12 @@ export default function MarketingCaseStudyLayout({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<main className="flex h-dvh min-h-0 flex-col overflow-hidden"> <MessagesProvider messages={marketingMessages}>
{children} <AuthModalProvider>
</main> <main className="flex h-dvh min-h-0 flex-col overflow-hidden">
{children}
</main>
</AuthModalProvider>
</MessagesProvider>
); );
} }
@@ -23,14 +23,13 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
}) => { }) => {
const variant = variantProp; const variant = variantProp;
const sizing = sizingProp; const sizing = sizingProp;
// Get article-specific background image from frontmatter
const getBackgroundImage = ( const getBackgroundImage = (
post: ContentThumbnailTemplateProps["post"], post: ContentThumbnailTemplateProps["post"],
variant: "vertical" | "horizontal", orientation: "vertical" | "horizontal",
): string => { ): string => {
if (post.frontmatter?.thumbnail) { if (post.frontmatter?.thumbnail) {
const imageName = const imageName =
variant === "vertical" orientation === "vertical"
? post.frontmatter.thumbnail.vertical ? post.frontmatter.thumbnail.vertical
: post.frontmatter.thumbnail.horizontal; : post.frontmatter.thumbnail.horizontal;
@@ -47,12 +46,21 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
? slug ? slug
: contentCatalogSlugForFallback(slug); : contentCatalogSlugForFallback(slug);
return variant === "vertical" return orientation === "vertical"
? contentBlogVerticalPath(resolvedSlug) ? contentBlogVerticalPath(resolvedSlug)
: contentBlogHorizontalPath(resolvedSlug); : contentBlogHorizontalPath(resolvedSlug);
}; };
const backgroundImage = getBackgroundImage(post, variant); // For "responsive", emit both orientations so the <picture> source can
// swap at smd without a second card in the DOM.
const backgroundImage =
variant === "responsive"
? getBackgroundImage(post, "horizontal")
: getBackgroundImage(post, variant);
const backgroundImageSmd =
variant === "responsive"
? getBackgroundImage(post, "vertical")
: undefined;
return ( return (
<ContentThumbnailTemplateView <ContentThumbnailTemplateView
@@ -61,6 +69,7 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
variant={variant} variant={variant}
sizing={sizing} sizing={sizing}
backgroundImage={backgroundImage} backgroundImage={backgroundImage}
backgroundImageSmd={backgroundImageSmd}
/> />
); );
}, },
@@ -1,6 +1,9 @@
import type { BlogPost } from "../../../../lib/content"; import type { BlogPost } from "../../../../lib/content";
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal"; export type ContentThumbnailTemplateVariantValue =
| "vertical"
| "horizontal"
| "responsive";
export type ContentThumbnailTemplateSizingValue = "fluid" | "fixed"; export type ContentThumbnailTemplateSizingValue = "fluid" | "fixed";
@@ -8,7 +11,8 @@ export interface ContentThumbnailTemplateProps {
post: BlogPost; post: BlogPost;
className?: string; className?: string;
/** /**
* Content thumbnail variant. * vertical | horizontal — single layout. responsive — horizontal at <smd,
* vertical at ≥smd (Learn grid); single card, viewport-swapped via <picture>.
*/ */
variant?: ContentThumbnailTemplateVariantValue; variant?: ContentThumbnailTemplateVariantValue;
/** /**
@@ -21,7 +25,9 @@ export interface ContentThumbnailTemplateProps {
export interface ContentThumbnailTemplateViewProps { export interface ContentThumbnailTemplateViewProps {
post: BlogPost; post: BlogPost;
className: string; className: string;
variant: "vertical" | "horizontal"; variant: ContentThumbnailTemplateVariantValue;
sizing: ContentThumbnailTemplateSizingValue; sizing: ContentThumbnailTemplateSizingValue;
backgroundImage: string; backgroundImage: string;
/** Wide-viewport image source for variant="responsive" (≥smd). */
backgroundImageSmd?: string;
} }
@@ -9,7 +9,41 @@ function ContentThumbnailTemplateView({
variant, variant,
sizing, sizing,
backgroundImage, backgroundImage,
backgroundImageSmd,
}: ContentThumbnailTemplateViewProps) { }: ContentThumbnailTemplateViewProps) {
if (variant === "responsive") {
// Single card; <picture> swaps the orientation-specific image at smd
// (530px), aspect-ratio and content positioning switch via Tailwind.
return (
<Link
href={`/blog/${post.slug}`}
className={`group block w-full transition-transform duration-200 hover:scale-[1.02] ${className}`}
>
<div className="relative aspect-[320/225.5] w-full overflow-hidden smd:aspect-[260/390]">
<div className="absolute inset-0 z-0">
<picture>
{backgroundImageSmd ? (
<source
media="(min-width: 530px)"
srcSet={backgroundImageSmd}
/>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={backgroundImage}
alt=""
className="pointer-events-none size-full object-cover"
/>
</picture>
</div>
<div className="absolute left-[4.375%] top-[6.099%] z-20 w-[71.875%] smd:left-[6.923%] smd:top-[4.615%] smd:w-[76.923%]">
<ContentContainer post={post} size="xs" />
</div>
</div>
</Link>
);
}
if (variant === "vertical") { if (variant === "vertical") {
if (sizing === "fixed") { if (sizing === "fixed") {
return ( return (
@@ -5,11 +5,20 @@
*/ */
import { memo } from "react"; import { memo } from "react";
import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext"; import { useTranslation } from "../../../contexts/MessagesContext";
import ContentLockup from "../../type/ContentLockup"; import ContentLockup from "../../type/ContentLockup";
import HeroDecor from "./HeroDecor"; import HeroDecor from "./HeroDecor";
import { ASSETS, getAssetPath } from "../../../../lib/assetUtils"; import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
/**
* Intrinsic dimensions of `public/assets/marketing/hero-image.png` (2560×1600,
* 16:10). Passed to `next/image` to reserve aspect ratio + drive responsive
* srcset generation. Actual rendered size is governed by `sizes`.
*/
const HERO_IMAGE_WIDTH = 2560;
const HERO_IMAGE_HEIGHT = 1600;
interface HeroBannerProps { interface HeroBannerProps {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
@@ -50,13 +59,14 @@ const HeroBanner = memo<HeroBannerProps>(
{/* Hero Image Container */} {/* Hero Image Container */}
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center"> <div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element -- dynamic path from getAssetPath */} <Image
<img
src={getAssetPath(ASSETS.HERO_IMAGE)} src={getAssetPath(ASSETS.HERO_IMAGE)}
alt={imageAlt} alt={imageAlt}
width={HERO_IMAGE_WIDTH}
height={HERO_IMAGE_HEIGHT}
priority
sizes="(min-width: 768px) 50vw, 100vw"
className="w-full h-auto" className="w-full h-auto"
loading="eager"
fetchPriority="high"
/> />
</div> </div>
</div> </div>
+6 -7
View File
@@ -1,8 +1,6 @@
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google"; import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AuthModalProvider } from "./contexts/AuthModalContext";
import { MessagesProvider } from "./contexts/MessagesContext";
import messages from "../messages/en/index"; import messages from "../messages/en/index";
import { ASSETS, getAssetPath } from "../lib/assetUtils"; import { ASSETS, getAssetPath } from "../lib/assetUtils";
import "./globals.css"; import "./globals.css";
@@ -11,6 +9,11 @@ import "./globals.css";
// (the only groups that read the session via `ConditionalNavigation`). Marketing // (the only groups that read the session via `ConditionalNavigation`). Marketing
// renders a client-side `MarketingNavigation` so its HTML can be statically // renders a client-side `MarketingNavigation` so its HTML can be statically
// optimized — TTFB drops to CDN speed for guests. // optimized — TTFB drops to CDN speed for guests.
//
// MessagesProvider + AuthModalProvider are mounted per route group (Phase 4b):
// `(marketing)` gets a trimmed slice without `create.*` (~41 KB gzipped saved
// per static page); `(app)`/`(admin)`/`(dev)` get the full tree. See
// `messages/en/marketing.ts` and `docs/perf/next16-eval.md`.
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@@ -142,11 +145,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body <body
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`} className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
> >
<MessagesProvider messages={messages}> <div className="min-h-screen flex flex-col">{children}</div>
<AuthModalProvider>
<div className="min-h-screen flex flex-col">{children}</div>
</AuthModalProvider>
</MessagesProvider>
</body> </body>
</html> </html>
); );
+160
View File
@@ -0,0 +1,160 @@
# Next 16 substrate evaluation (Phase 3)
Evaluation of `experimental.cacheComponents` (formerly `experimental.ppr`)
and React Compiler against this repo on Next.js 16.2.6. Performed as a
canary build pass without committing either flag to `main`.
## TL;DR
| Flag | Recommendation | Why |
| --- | --- | --- |
| `cacheComponents` (PPR successor) | **Defer** — requires a follow-up refactor before it can ship | Renamed from `ppr` in Next 16; now a boolean global toggle, no per-route `experimental_ppr` opt-in. Requires removing `force-dynamic` from `(app)` and `(admin)` layouts and re-expressing session-aware dynamism via Suspense + cache primitives. |
| React Compiler | **Defer** — config surface moved + missing dep | Moved out of `experimental` to the top-level `reactCompiler` key in Next 16. Requires installing `babel-plugin-react-compiler`. No blocking codebase incompatibilities found in the canary surface, but the install + eslint plugin setup is its own follow-up task. |
Neither flag was shippable as a pure config flip in this audit. The findings
below describe what changed in Next 16 and the work each would require.
## Repo baseline (Next 16.2.6, Turbopack, no experimental flags)
- Build status: clean (`npx next build`)
- Static routes: `/`, `/_not-found`, `/about`, `/blog`, `/components-preview`,
`/how-it-works`, `/learn`, `/templates`, `/use-cases`
- SSG routes: `/blog/[slug]`, `/use-cases/[slug]`, `/use-cases/[slug]/rule`
- Dynamic routes: all `/api/*`, `/create`, `/create/[screenId]`,
`/create/review-template/[slug]`, `/login`, `/monitor`, `/profile`,
`/rules/[id]`
- `.next/static` total: **3.6 MB** (uncompressed)
Note: Next 16 with Turbopack no longer prints per-route first-load JS sizes
in the build summary. Bundle analyzer (`ANALYZE=true`) is the canonical
source for size data — see Phase 4a.
## 3a. `cacheComponents` (PPR) — DEFER
### What changed in Next 16
`experimental.ppr` has been merged into `experimental.cacheComponents`:
```
Error: experimental.ppr has been merged into cacheComponents. The Partial
Prerendering feature is still available, but is now enabled via cacheComponents.
```
Crucially, the per-route incremental opt-in is gone:
```
cacheComponents: invalid type: string "incremental", expected a boolean
```
So `cacheComponents: true` flips PPR semantics on globally for every route.
### Blocker
With `cacheComponents: true`, the build fails:
```
./app/(admin)/layout.tsx:6:14
Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`.
Please remove it.
./app/(app)/layout.tsx:8:14
Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`.
Please remove it.
```
Both layouts use `export const dynamic = "force-dynamic"` to render
session-aware chrome (set in Phase 4b of the prior plan). `cacheComponents`
requires expressing that dynamism via `<Suspense>` boundaries plus
`unstable_noStore()`/`unstable_cache()` instead of route-segment `dynamic`.
### Estimated work to ship
1. Refactor [app/(app)/layout.tsx](../../app/(app)/layout.tsx) and
[app/(admin)/layout.tsx](../../app/(admin)/layout.tsx) so the
`ConditionalNavigation` session fetch sits inside a `<Suspense>` boundary
with a fallback that matches the generic chrome.
2. Mark the session-reading components with `unstable_noStore()` (or the
stable equivalent in Next 16) so they opt out of the static cache.
3. Verify the existing static routes (`/`, `/about`, `/blog`, etc.) still
prerender; add `<Suspense>` boundaries around any future dynamic islands.
4. Confirm `(marketing)` routes still serve from CDN with the static shell
while the personalized nav island streams.
This is the natural next step after Phase 4b made marketing static, but
it's not a config-only change. Ticket separately.
### Verification (when shipping)
- `(marketing)` routes still appear as `○ Static` in build output.
- `(app)`/`(admin)` routes' static shell prerenders; the personalized nav
streams (visible in `curl` of the HTML — partial shell first, then nav).
- TTFB on `(marketing)` unchanged or improved.
## 3b. React Compiler — DEFER
### What changed in Next 16
`experimental.reactCompiler` moved to the top-level `reactCompiler` key:
```
⚠ `experimental.reactCompiler` has been moved to `reactCompiler`. Please
update your next.config.mjs file accordingly.
```
And requires the babel plugin to be installed:
```
Failed to resolve package babel-plugin-react-compiler while attempting to
resolve React Compiler. We attempted to resolve React Compiler relative
to the next package. Is babel-plugin-react-compiler installed in your
node_modules directory?
```
### Estimated work to ship
1. `npm install --save-dev babel-plugin-react-compiler eslint-plugin-react-compiler`.
2. Add `reactCompiler: { compilationMode: "annotation" }` to `next.config.mjs`
(top-level, not under `experimental`).
3. Enable `eslint-plugin-react-compiler` and run it against the repo to
surface components that would bail (refs mutated during render, reads
of non-reactive globals inline, etc.).
4. Incrementally add `"use memo"` directives to high-render-frequency
containers (`CreateFlowProvider`, `AuthModalProvider`, list-heavy views).
5. Once stable, flip `compilationMode: "all"` and remove hand-written
`useMemo`/`useCallback` where the compiler subsumes them.
### Why annotation mode first
We have many hand-rolled memoized containers. The risk of `compilationMode: "all"`
on day one is that the compiler bails on a critical component in a way that
changes render counts. Annotation mode lets us migrate one component at a
time with eslint enforcement.
### Verification (when shipping)
- Bundle size before/after `next build` with the runtime added.
- Test suite green (`npx vitest run` — 196 files / 1251 tests today).
- Component render counts unchanged or reduced on key surfaces (use the
React DevTools profiler on `/create/informational` and `/`).
## Impact on Phase 4 (MessagesProvider)
If we later ship `cacheComponents`, the MessagesProvider refactor's win
shrinks meaningfully: the messages dictionary lives in the static shell of
every route, and only the dynamic island re-fetches. The static prerender
output is already cacheable at the CDN. So Phase 4 should be re-evaluated
**after** the `cacheComponents` work lands, not before.
If we don't ship `cacheComponents`, Phase 4's bundle-size measurement
(Phase 4a) is still the right gate — measure first, refactor only if the
data justifies it.
## What to do now
- Skip both flags for this performance follow-ups plan.
- File two follow-up tickets:
1. "Enable `cacheComponents`: refactor `(app)`/`(admin)` layouts to
Suspense + cache primitives, remove `force-dynamic` from route segments."
2. "Adopt React Compiler in annotation mode: install plugin, enable
eslint rule, migrate top containers."
- Proceed with Phase 4a (measure) and let the data drive Phase 4b.
+103
View File
@@ -0,0 +1,103 @@
/**
* Marketing-scoped message bundle: every namespace from `./index` EXCEPT the
* `create.*` subtree. The `create` namespace is ~41 KB gzipped — the largest
* single contributor to per-route HTML size — and is only used inside
* `(app)/create/*`. Excluding it from the `(marketing)` group's
* `MessagesProvider` removes the embed from every marketing HTML response.
*
* The type stays compatible with `typeof import("./index").default` because
* we satisfy the same key shape (modulo `create`); marketing client
* components only read keys that exist here. If a future change reaches into
* `messages.create.*` from a marketing surface, `getTranslation` will return
* the dotted key as the fallback — visible immediately at runtime.
*
* Keep this in sync with new entries added to `./index` (excluding `create/`).
* See `docs/perf/next16-eval.md` for measurement context.
*/
import common from "./common.json";
import heroBanner from "./components/heroBanner.json";
import cardSteps from "./components/cardSteps.json";
import askOrganizer from "./components/askOrganizer.json";
import featureGrid from "./components/featureGrid.json";
import footer from "./components/footer.json";
import header from "./components/header.json";
import homeHeader from "./components/homeHeader.json";
import languageSwitcher from "./components/languageSwitcher.json";
import menu from "./components/menu.json";
import quoteBlock from "./components/quoteBlock.json";
import ruleCard from "./components/ruleCard.json";
import ruleStack from "./components/ruleStack.json";
import webVitalsDashboard from "./components/webVitalsDashboard.json";
import controlsChrome from "./components/controlsChrome.json";
import logoWall from "./components/logoWall.json";
import topNav from "./components/topNav.json";
import home from "./pages/home.json";
import templates from "./pages/templates.json";
import learn from "./pages/learn.json";
import about from "./pages/about.json";
import useCases from "./pages/useCases.json";
import useCasesDetail from "./pages/useCasesDetail.json";
import useCasesCompletedRules from "./pages/useCasesCompletedRules.json";
import useCasesCompletedRule from "./pages/useCasesCompletedRule.json";
import howItWorks from "./pages/howItWorks.json";
import monitor from "./pages/monitor.json";
import login from "./pages/login.json";
import profile from "./pages/profile.json";
import notFoundPage from "./pages/notFoundPage.json";
import ruleDetail from "./pages/ruleDetail.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import modalsShare from "./modals/share.json";
import modalsPopoverExport from "./modals/popoverExport.json";
import modalsAskOrganizerInquiry from "./modals/askOrganizerInquiry.json";
import type messages from "./index";
const marketingMessages = {
common,
heroBanner,
cardSteps,
askOrganizer,
featureGrid,
footer,
header,
homeHeader,
languageSwitcher,
menu,
quoteBlock,
ruleCard,
ruleStack,
webVitalsDashboard,
controlsChrome,
logoWall,
topNav,
pages: {
home,
templates,
learn,
about,
useCases,
useCasesDetail,
useCasesCompletedRules,
useCasesCompletedRule,
howItWorks,
monitor,
login,
profile,
notFoundPage,
ruleDetail,
},
navigation,
metadata,
modals: {
share: modalsShare,
popoverExport: modalsPopoverExport,
askOrganizerInquiry: modalsAskOrganizerInquiry,
},
};
// Cast to the full shape so it satisfies `typeof import("./index").default`
// at the MessagesProvider boundary. Reads of `messages.create.*` from a
// marketing surface are a code smell and will return the dotted key (the
// runtime `getTranslation` fallback) — visible immediately.
export default marketingMessages as typeof messages;
+3
View File
@@ -24,6 +24,9 @@ const nextConfig = {
optimizeCss: true, optimizeCss: true,
optimizePackageImports: ["react", "react-dom"], optimizePackageImports: ["react", "react-dom"],
}, },
// Phase 3 canary stub (not enabled): React Compiler probe surfaces a missing
// `babel-plugin-react-compiler` dep — Next 16 also moved this top-level out
// of `experimental`. See `docs/perf/next16-eval.md` for evaluation results.
// Compression // Compression
compress: true, compress: true,
// Image optimization // Image optimization
+14 -17
View File
@@ -82,32 +82,29 @@ describe("LearnPage", () => {
expect(screen.getByText("Still have questions?")).toBeInTheDocument(); expect(screen.getByText("Still have questions?")).toBeInTheDocument();
}); });
it("renders one card per post in each layout region without duplication", () => { it("renders one card per post (single responsive grid, no duplication)", () => {
const { container } = render(<LearnPage />); const { container } = render(<LearnPage />);
const mobileRegion = container.querySelector(".smd\\:hidden"); const grid = container.querySelector(".smd\\:grid");
const desktopRegion = container.querySelector(".smd\\:grid"); expect(grid).toBeTruthy();
expect(mobileRegion).toBeTruthy(); const links = within(grid as HTMLElement).getAllByRole("link");
expect(desktopRegion).toBeTruthy(); expect(links).toHaveLength(mockPosts.length);
const mobileLinks = within(mobileRegion as HTMLElement).getAllByRole( expect(links[0]).toHaveAttribute(
"link",
);
const desktopLinks = within(desktopRegion as HTMLElement).getAllByRole(
"link",
);
expect(mobileLinks).toHaveLength(mockPosts.length);
expect(desktopLinks).toHaveLength(mockPosts.length);
expect(mobileLinks[0]).toHaveAttribute(
"href", "href",
"/blog/resolving-active-conflicts", "/blog/resolving-active-conflicts",
); );
expect(desktopLinks[1]).toHaveAttribute( expect(links[1]).toHaveAttribute(
"href", "href",
"/blog/operational-security-mutual-aid", "/blog/operational-security-mutual-aid",
); );
// <picture> with a smd source provides the orientation swap without a
// duplicate card per breakpoint.
const sources = grid?.querySelectorAll(
"picture source[media='(min-width: 530px)']",
);
expect(sources?.length).toBe(mockPosts.length);
}); });
}); });
+18 -6
View File
@@ -128,12 +128,24 @@ describe("Group layouts (chrome composition)", () => {
findDescendant(main, (n) => typeof n === "string" && n.includes("marketing-child")), findDescendant(main, (n) => typeof n === "string" && n.includes("marketing-child")),
).toBeTruthy(); ).toBeTruthy();
// Footer is loaded via next/dynamic — it appears as a render prop component // Footer is a next/dynamic component sibling to <main>. Find the node
// sibling to <main>. Verify the layout returns more than just <main>. // whose children include <main>, then assert its sibling list also
const childrenArr = Array.isArray(tree.props.children) // contains a third element (the Footer dynamic component) — independent
? tree.props.children // of where MessagesProvider/AuthModalProvider sit in the tree.
: [tree.props.children]; const mainSiblingParent = findDescendant(tree, (n) => {
expect(childrenArr.length).toBeGreaterThan(1); const ch = Array.isArray(n?.props?.children)
? n.props.children
: [n?.props?.children].filter(Boolean);
return ch.some(
(c) =>
c?.type === "main" && c.props?.className?.includes("flex-1"),
);
});
expect(mainSiblingParent).toBeTruthy();
const siblings = Array.isArray(mainSiblingParent.props.children)
? mainSiblingParent.props.children
: [mainSiblingParent.props.children];
expect(siblings.length).toBeGreaterThan(1);
}); });
test("AppLayout wraps children in <main flex-1> with no footer", () => { test("AppLayout wraps children in <main flex-1> with no footer", () => {