Backend / staging cleanup, performance substrate, and create-flow polish #60
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-5
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
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
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user