Performance follow-ups
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
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
|
||||
// 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.
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<ConditionalNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
</>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
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
|
||||
// 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.
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<ConditionalNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
</>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,10 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
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.
|
||||
export default function DevLayout({ children }: { children: ReactNode }) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
notFound();
|
||||
}
|
||||
return <main className="flex-1">{children}</main>;
|
||||
return (
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<main className="flex-1">{children}</main>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactNode } from "react";
|
||||
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
|
||||
// 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 }) {
|
||||
return (
|
||||
<>
|
||||
<MessagesProvider messages={marketingMessages}>
|
||||
<AuthModalProvider>
|
||||
<MarketingNavigation />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,22 +29,18 @@ export default function LearnPage() {
|
||||
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
|
||||
<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) => (
|
||||
<ContentThumbnailTemplate
|
||||
key={`${post.slug}-horizontal`}
|
||||
key={post.slug}
|
||||
post={post}
|
||||
variant="horizontal"
|
||||
/>
|
||||
))}
|
||||
</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"
|
||||
variant="responsive"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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. */
|
||||
export default function MarketingCaseStudyLayout({
|
||||
@@ -7,8 +10,12 @@ export default function MarketingCaseStudyLayout({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MessagesProvider messages={marketingMessages}>
|
||||
<AuthModalProvider>
|
||||
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
|
||||
{children}
|
||||
</main>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-5
@@ -23,14 +23,13 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||
}) => {
|
||||
const variant = variantProp;
|
||||
const sizing = sizingProp;
|
||||
// Get article-specific background image from frontmatter
|
||||
const getBackgroundImage = (
|
||||
post: ContentThumbnailTemplateProps["post"],
|
||||
variant: "vertical" | "horizontal",
|
||||
orientation: "vertical" | "horizontal",
|
||||
): string => {
|
||||
if (post.frontmatter?.thumbnail) {
|
||||
const imageName =
|
||||
variant === "vertical"
|
||||
orientation === "vertical"
|
||||
? post.frontmatter.thumbnail.vertical
|
||||
: post.frontmatter.thumbnail.horizontal;
|
||||
|
||||
@@ -47,12 +46,21 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||
? slug
|
||||
: contentCatalogSlugForFallback(slug);
|
||||
|
||||
return variant === "vertical"
|
||||
return orientation === "vertical"
|
||||
? contentBlogVerticalPath(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 (
|
||||
<ContentThumbnailTemplateView
|
||||
@@ -61,6 +69,7 @@ const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||
variant={variant}
|
||||
sizing={sizing}
|
||||
backgroundImage={backgroundImage}
|
||||
backgroundImageSmd={backgroundImageSmd}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { BlogPost } from "../../../../lib/content";
|
||||
|
||||
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
|
||||
export type ContentThumbnailTemplateVariantValue =
|
||||
| "vertical"
|
||||
| "horizontal"
|
||||
| "responsive";
|
||||
|
||||
export type ContentThumbnailTemplateSizingValue = "fluid" | "fixed";
|
||||
|
||||
@@ -8,7 +11,8 @@ export interface ContentThumbnailTemplateProps {
|
||||
post: BlogPost;
|
||||
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;
|
||||
/**
|
||||
@@ -21,7 +25,9 @@ export interface ContentThumbnailTemplateProps {
|
||||
export interface ContentThumbnailTemplateViewProps {
|
||||
post: BlogPost;
|
||||
className: string;
|
||||
variant: "vertical" | "horizontal";
|
||||
variant: ContentThumbnailTemplateVariantValue;
|
||||
sizing: ContentThumbnailTemplateSizingValue;
|
||||
backgroundImage: string;
|
||||
/** Wide-viewport image source for variant="responsive" (≥smd). */
|
||||
backgroundImageSmd?: string;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,41 @@ function ContentThumbnailTemplateView({
|
||||
variant,
|
||||
sizing,
|
||||
backgroundImage,
|
||||
backgroundImageSmd,
|
||||
}: 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 (sizing === "fixed") {
|
||||
return (
|
||||
|
||||
@@ -5,11 +5,20 @@
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import ContentLockup from "../../type/ContentLockup";
|
||||
import HeroDecor from "./HeroDecor";
|
||||
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 {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
@@ -50,13 +59,14 @@ const HeroBanner = memo<HeroBannerProps>(
|
||||
|
||||
{/* Hero Image Container */}
|
||||
<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 */}
|
||||
<img
|
||||
<Image
|
||||
src={getAssetPath(ASSETS.HERO_IMAGE)}
|
||||
alt={imageAlt}
|
||||
width={HERO_IMAGE_WIDTH}
|
||||
height={HERO_IMAGE_HEIGHT}
|
||||
priority
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
className="w-full h-auto"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-6
@@ -1,8 +1,6 @@
|
||||
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthModalProvider } from "./contexts/AuthModalContext";
|
||||
import { MessagesProvider } from "./contexts/MessagesContext";
|
||||
import messages from "../messages/en/index";
|
||||
import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
||||
import "./globals.css";
|
||||
@@ -11,6 +9,11 @@ import "./globals.css";
|
||||
// (the only groups that read the session via `ConditionalNavigation`). Marketing
|
||||
// renders a client-side `MarketingNavigation` so its HTML can be statically
|
||||
// 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({
|
||||
subsets: ["latin"],
|
||||
@@ -142,11 +145,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<body
|
||||
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
|
||||
>
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<div className="min-h-screen flex flex-col">{children}</div>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,9 @@ const nextConfig = {
|
||||
optimizeCss: true,
|
||||
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
|
||||
compress: true,
|
||||
// Image optimization
|
||||
|
||||
+14
-17
@@ -82,32 +82,29 @@ describe("LearnPage", () => {
|
||||
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 mobileRegion = container.querySelector(".smd\\:hidden");
|
||||
const desktopRegion = container.querySelector(".smd\\:grid");
|
||||
const grid = container.querySelector(".smd\\:grid");
|
||||
expect(grid).toBeTruthy();
|
||||
|
||||
expect(mobileRegion).toBeTruthy();
|
||||
expect(desktopRegion).toBeTruthy();
|
||||
const links = within(grid as HTMLElement).getAllByRole("link");
|
||||
expect(links).toHaveLength(mockPosts.length);
|
||||
|
||||
const mobileLinks = within(mobileRegion as HTMLElement).getAllByRole(
|
||||
"link",
|
||||
);
|
||||
const desktopLinks = within(desktopRegion as HTMLElement).getAllByRole(
|
||||
"link",
|
||||
);
|
||||
|
||||
expect(mobileLinks).toHaveLength(mockPosts.length);
|
||||
expect(desktopLinks).toHaveLength(mockPosts.length);
|
||||
|
||||
expect(mobileLinks[0]).toHaveAttribute(
|
||||
expect(links[0]).toHaveAttribute(
|
||||
"href",
|
||||
"/blog/resolving-active-conflicts",
|
||||
);
|
||||
expect(desktopLinks[1]).toHaveAttribute(
|
||||
expect(links[1]).toHaveAttribute(
|
||||
"href",
|
||||
"/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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,12 +128,24 @@ describe("Group layouts (chrome composition)", () => {
|
||||
findDescendant(main, (n) => typeof n === "string" && n.includes("marketing-child")),
|
||||
).toBeTruthy();
|
||||
|
||||
// Footer is loaded via next/dynamic — it appears as a render prop component
|
||||
// sibling to <main>. Verify the layout returns more than just <main>.
|
||||
const childrenArr = Array.isArray(tree.props.children)
|
||||
? tree.props.children
|
||||
: [tree.props.children];
|
||||
expect(childrenArr.length).toBeGreaterThan(1);
|
||||
// Footer is a next/dynamic component sibling to <main>. Find the node
|
||||
// whose children include <main>, then assert its sibling list also
|
||||
// contains a third element (the Footer dynamic component) — independent
|
||||
// of where MessagesProvider/AuthModalProvider sit in the tree.
|
||||
const mainSiblingParent = findDescendant(tree, (n) => {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user