Files
community-rule/docs/perf/next16-eval.md
T
2026-05-26 08:11:57 -06:00

9.2 KiB

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. Originally written as a canary report when both flags were deferred; updated when both shipped as follow-up work (see "Outcome" sections below).

TL;DR

Flag Recommendation Status
cacheComponents (PPR successor) Ship Shipped. force-dynamic removed from (app) and (admin) layouts; <ConditionalNavigation /> (and <MarketingNavigation />) wrapped in <Suspense fallback={null}>. (app)/(admin) routes are now ◐ Partial Prerender instead of ƒ Dynamic. / static shell dropped from 45 KB → 11.7 KB gzipped.
React Compiler Ship (annotation mode) Shipped (plumbing only). babel-plugin-react-compiler + eslint-plugin-react-compiler installed. reactCompiler: { compilationMode: "annotation" } enabled in next.config.mjs. ESLint rule wired in at "warn" — found 31 latent warnings across 8 files (none introduced by this change). Migrating containers to "use memo" is a future task.

Both flags now ship in main. The findings below describe what changed in Next 16, what work each required, and the outcomes.

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) — SHIPPED

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.

Work performed

  1. Removed export const dynamic = "force-dynamic" from app/(app)/layout.tsx and app/(admin)/layout.tsx.
  2. Wrapped <ConditionalNavigation /> (server component reading getNavAuthSignedIn()cookies()) in <Suspense fallback={null}> in both layouts.
  3. Same change for <MarketingNavigation /> in app/(marketing)/layout.tsx — the marketing nav reads usePathname() (uncached per request) and would otherwise block the static shell at routes like /rules/[id].
  4. Enabled experimental.cacheComponents: true in next.config.mjs.

unstable_noStore() already sits inside getNavAuthSignedIn(); no additional cache primitives were needed.

Why fallback={null} and not a placeholder

Any non-null fallback would also need to live in the static shell. The existing ConditionalNavigationClient reads usePathname() to decide chromeless paths (/create/*, /login), which is uncached data — disallowed in the static shell under cacheComponents. A truly static placeholder is possible but would cause layout shift on routes that ultimately render no nav. Trade-off accepted: brief blank-nav while the dynamic island streams in.

Outcome (measured against npx next build)

Route group Before After
/, /about, /blog, /components-preview, /how-it-works, /learn ○ Static ○ Static
/create, /create/[screenId], /profile, /monitor, /login, /rules/[id] ƒ Dynamic ◐ Partial Prerender
/templates, /use-cases, /use-cases/[slug], /blog/[slug] ○ Static / ◐ SSG ◐ Partial Prerender

/ static shell: 45 KB gzipped → 11.7 KB gzipped (74% reduction). All 196 test files / 1251 tests pass.

3b. React Compiler — SHIPPED (annotation mode, plumbing only)

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?

Work performed

  1. npm install --save-dev babel-plugin-react-compiler eslint-plugin-react-compiler.
  2. Added top-level reactCompiler: { compilationMode: "annotation" } to next.config.mjs.
  3. Wired react-compiler/react-compiler as "warn" in eslint.config.mjs (both JS and TS plugin blocks).
  4. No "use memo" directives added in this pass — the goal is plumbing, not migration. The compiler is a no-op until components opt in.

Why annotation mode first (unchanged from original plan)

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.

ESLint audit results

npx eslint app lib after wiring the rule found 31 react-compiler warnings across 8 files:

Category Count
"Hooks may not be referenced as normal values" (passing hook references as values) 25
"Writing to a variable defined outside a component or hook" (module-level mutation) 2
"Hooks must always be called in a consistent order" (conditional hooks) 2
"React Compiler skipped optimizing" (file has React rules disabled) 2

Files flagged:

  • app/(app)/create/context/CreateFlowContext.tsx
  • app/(app)/create/hooks/useCompletedRuleShareExport.ts
  • app/(marketing)/use-cases/[slug]/page.tsx
  • app/(marketing)/use-cases/page.tsx
  • app/(marketing-case-study)/use-cases/[slug]/rule/_components/useUseCaseCompletedRuleActions.ts
  • app/(marketing-case-study)/use-cases/[slug]/rule/page.tsx
  • app/components/controls/SelectInput/SelectInput.container.tsx
  • app/components/sections/RelatedArticles/RelatedArticles.view.tsx

All warnings are latent (not introduced by this change). The compiler runs in annotation mode, so these files are not affected at runtime until they opt in. Not fixed in this commit — these are the migration targets to address before flipping to compilationMode: "all".

Verification

  • Test suite green: 196 files / 1251 tests pass.
  • Build green: npx next build clean with the new config.
  • TSC clean.
  • Bundle size delta minimal — no "use memo" annotations means no compiler runtime calls are emitted in user code yet.

Impact on Phase 4 (MessagesProvider)

Phase 4 (route-scoped MessagesProvider) shipped before this work. With cacheComponents now enabled, the marketing routes' messages dictionary lives in the static shell — cached at the CDN with no per-request cost. The route-scoping is still a win for the dynamic islands' RSC payload, but the static-shell win is now structural, not bundle-size dependent.

Follow-up work

cacheComponents and React Compiler annotation mode now ship. Remaining work (file separately when scheduled):

  1. Migrate top React Compiler bail sites. Fix the 31 latent warnings (especially the hook-reference patterns in CreateFlowContext and the conditional hooks in SelectInput.container) so those files become compiler-eligible.
  2. Annotate high-render containers with "use memo". Targets: CreateFlowProvider, AuthModalProvider, list-heavy views. Measure render counts before/after with React DevTools profiler.
  3. Flip React Compiler from annotation to all once the bail list is green and a critical mass of containers are annotated. Remove hand-written useMemo/useCallback the compiler subsumes.
  4. Audit <Suspense fallback={null}> UX on (app)/(admin) routes. If blank-nav flash becomes noticeable on slow connections, replace the null fallback with a static placeholder that doesn't read usePathname (e.g. a min-h div sized to the nav).