Skip to content

fix: render without random numbers, so Next.js 16 can prerender emails - #65

Merged
ivoIturrieta merged 3 commits into
mainfrom
claude/wonderful-pascal-s6go99
Oct 13, 2026
Merged

ivoIturrieta merged 3 commits into
mainfrom
claude/wonderful-pascal-s6go99

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A new Next.js 16 app (create-next-app turns on Cache Components) can't build a page, GET route handler or Client Component that renders an email with 0.2.0. Next.js prerenders those at build time and fails the build when a render reads a random number. 0.2.0 asked crypto.getRandomValues() for each render's nonce (the marker that stops markup in a template's data from passing for one of the collecting render's markers). 0.1.22 builds the same pages.

The nonce now comes from a count of renders (shared by every copy of Elements in a process, as the collecting stack is) and performance's high-resolution clock, which Next.js leaves for timing. The nonce never reaches the output, so HTML, designs and plain text are unchanged.

Changes

  • packages/react/src/utils/normalize.tsx: nonce() uses the render count and performance.timeOrigin / performance.now(). It reads no random numbers or date.
  • packages/react/src/regression/render-isolation.test.tsx: a render calls no crypto.getRandomValues(), crypto.randomUUID(), Math.random() or Date.now() (fails on 0.2.0). An email renders the same where the clock stands still (fake timers) or there's no performance, and so does an email rendered inside one of its components.
  • tests/next16-integration (new, in CI): Next.js 16.4 with Cache Components, where a route handler, a Server Component and a Client Component that render an email are prerendered and checked by verify.mjs. Its build fails with the published 0.2.0.
  • .github/workflows/test.yml, CLAUDE.md, tests/nextjs-integration/verify.mjs: the new consumer step and its docs.
  • packages/react/README.md: Waku's Server Components can't render emails, and no release of Elements can there.

Test Plan

  • pnpm build passes; ESM bundle 117,988 bytes (budget 118,000)
  • pnpm test passes (react 989, shared 107, convert-core 193, from-react-email 283, migrate 178), and with coverage
  • Tested in Storybook: smoke test 138/138, visual drift 138 stories × 2 widths match the baseline
  • Added/updated tests for new behavior (unit tests above; the new test fails without the fix)
  • Typechecks, CSP gate (Node 20 and 24), browser runtime gate, browser E2E gate (27 sections), migration fidelity and differential checks
  • Consumers with the packed tarball: Next.js 15, 14 and the new 16 (next build with Turbopack and with --webpack, and next dev), Jest 30
  • Output against the published 0.2.0: byte-identical HTML, design, plain text and messages for 13 templates (the realistic examples plus edge cases: a component that throws, an async one, a nested render, markup in Columns, Page/Document roots) on React 18 and 19; designs and plain text still match 0.1.22; 5,000 generated templates (GENERATED_SEEDS=5000)
  • Fresh apps with the fixed build next to the published 0.2.0, each rendering the same email: Next.js 16, Astro 7, TanStack Start, React Router 8, Vite 8, Waku (Client Components), Cloudflare Workers (with and without nodejs_compat), Express 5, Node + TypeScript (ESM and CommonJS), Bun, Deno, and Vitest 5 in node/jsdom/happy-dom with real timers, fake timers and a frozen performance. All renders were right and matched 0.2.0.

Notes

  • The nonce's strength. It was 64 random bits; now it's the render count and the clock to a fraction of a millisecond. Data written before a render still can't know it: in Node it would mean guessing process start time and render time to the nanosecond. It's weaker where the clock is coarse or stands still (browsers round it; Workers read wall time with timeOrigin 0). A guessed nonce lets markup in a template's data rearrange or break that one render; it can't add markup beyond the raw HTML that data already puts there. Using crypto where Next.js hasn't wrapped it would mean detecting its wrapper, which could break with a Next.js update, and would go over the bundle budget.
  • Bundle budget. 12 bytes are left under the 118 KB budget.
  • Not in this PR. In Next.js Server Components and route handlers rendered on request, a component of the user's own that uses a hook is left out of the email (0.1.22 lost the whole Column). That's a separate issue.
  • Users get the fix in the next release (0.2.1). After it, move @unlayer/react-elements-previous to it, per CLAUDE.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F9N7pEyxbxtqQmAw8BgEps


Generated by Claude Code


📖 Storybook Preview: https://unlayer.github.io/elements/pr/65/

Next.js 16's Cache Components (create-next-app's default) prerender pages and
GET route handlers when the app builds, and fail the build if a render reads a
random number or the date. 0.2.0 asked crypto.getRandomValues() for each
render's nonce, so a new Next.js app couldn't build a page, route handler or
Client Component that renders an email; 0.1.22 could.

The nonce now comes from a count of the process's renders (shared by every
copy of Elements, as the collecting stack is) and performance's
high-resolution clock, which Next.js leaves for timing. Data written before a
render still can't name it, and it never reaches the output, so rendered HTML,
designs and plain text are unchanged.

- render-isolation.test.tsx: a render calls no crypto.getRandomValues(),
  crypto.randomUUID(), Math.random() or Date.now() (fails before the fix).
- tests/next16-integration: Next.js 16 with Cache Components, a route
  handler, Server Component and Client Component prerendered; added to CI.
  Its build fails with the published 0.2.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F9N7pEyxbxtqQmAw8BgEps
The nonce now comes from performance's clock and a count of renders. Where
fake timers freeze the clock, or a runtime has no performance, an email (and
one rendered inside a component of it) must render exactly as it does with a
clock. Fails if the nonce assumes performance exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F9N7pEyxbxtqQmAw8BgEps
Waku gives server code a react-dom/server that has no renderer, so importing
Elements in a Server Component fails (0.1.22 when bundling, 0.2.0 when the
page loads). Rendering in a Client Component works, as in its server render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01F9N7pEyxbxtqQmAw8BgEps
@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.55%. Comparing base (3ac5f73) to head (2f0778c).

Additional details and impacted files
@@           Coverage Diff           @@
##             main      #65   +/-   ##
=======================================
  Coverage   96.55%   96.55%           
=======================================
  Files          40       40           
  Lines        2787     2789    +2     
  Branches      795      795           
=======================================
+ Hits         2691     2693    +2     
  Misses         95       95           
  Partials        1        1           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ivoIturrieta
ivoIturrieta merged commit 97d0541 into main Oct 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants