Skip to content

Unify on Preact, drop React from the server render path - #45

Open
alexpricedev wants to merge 2 commits into
mainfrom
claude/context-engineering-claude-5-yvtj37
Open

Unify on Preact, drop React from the server render path#45
alexpricedev wants to merge 2 commits into
mainfrom
claude/context-engineering-claude-5-yvtj37

Conversation

@alexpricedev

Copy link
Copy Markdown
Owner

Removes the dual-JSX-runtime setup that #44 had to document as a gotcha. Also adds the PR template we discussed (second commit — separable if you'd rather it landed on its own).

Why

The server used React purely as a JSX-to-string function. Seven files touched it, and the only APIs in play were renderToString, the JSX/ReactNode types, and dangerouslySetInnerHTML — no hooks, context, Suspense, or portals anywhere in the server tree. Preact provides all of it.

So React bought us a second runtime, a second set of types, and a /** @jsxImportSource preact */ pragma on every client island purely to opt back out of it.

What changed

  • jsxImportSourcepreact; renderToString from preact-render-to-string
  • React.ReactNodeComponentChildren; JSX type imports from preact
  • Both pragmas deleted — Preact is the default now
  • react, react-dom, @types/react, @types/react-dom removed

preact moves from devDependencies to dependencies. The server now imports its JSX runtime at runtime, so a production install without it wouldn't boot. This is the one change that could break a deploy rather than a test, so I verified it directly: bun install --production --frozen-lockfile then boot with NODE_ENV=production → HTTP 200 with asset fingerprinting working.

The bug this found

I captured the rendered HTML of every route before the migration and diffed it after. That caught a real regression:

Preact passes camelCase SVG attributes through verbatim; React rewrote them. So strokeWidth="2" reached the browser as strokeWidth, which the HTML parser doesn't recognise — the logo and the home page icons would have rendered at the default stroke width of 1. Confirmed in isolation:

camelCase : <svg ... strokeWidth="2" strokeLinecap="round">   ← ignored by the browser
kebab-case: <svg ... stroke-width="2" stroke-linecap="round"> ← correct

Fixed by converting those attributes to kebab-case, which both renderers emit identically. viewBox is left alone — it's genuinely camelCase in SVG. This is now a documented gotcha in CLAUDE.md, since nothing rewrites these for you any more.

Remaining output differences

Diffed route by route against the React baseline. All cosmetic, no visible-text or semantic changes:

Difference Assessment
charset vs charSet HTML attribute names are case-insensitive; Preact's is conventional
color-scheme:dark; trailing ; Inline style serialisation
async vs async="" Boolean attribute, both valid
Attribute ordering Semantically irrelevant
' vs &#x27; Both valid; injection-relevant chars (<, >, &, ") still escaped
<!-- --> markers gone React's hydration separators — pure noise for SSR-only output
Lottie <script async> back at end of <body> React 19 hoisted it into <head>; Preact keeps authored position. Still async, still has its SRI hash

One test asserted on We&#x27;ve — an implementation detail of React's escaper — and now asserts on the visible text.

Verification

  • Tests: 382 pass, 0 fail across 44 files — identical to the baseline I captured on main before touching anything
  • bun run check clean; clean bun install --frozen-lockfile (what CI uses) works
  • Client bundle unchanged at 2.54 KB — Preact is still --external and still loaded from the esm.sh import map
  • Production install + NODE_ENV=production boot verified
  • Browser screenshot of the home page confirms the logo renders correctly

Not verified here: the island's runtime mount in a browser. Preact resolves from esm.sh, which this environment's egress proxy blocks. The island's unit tests pass and the import-map wiring is untouched by this PR, but the CDN path itself is worth a click on a preview deploy.

Docs

CLAUDE.md's "two JSX runtimes" gotcha becomes "one runtime, two execution models" — the src/server/ vs src/client/ split is now the signal that used to come from the pragma. Skills and the README frontend section updated to match, plus the new kebab-case SVG rule.

Not included

The ProjectSearch island rewrite I flagged — it uses useEffect + getElementById to toggle rows in a table it doesn't own, so it demonstrates the wiring but not the reason. Left alone since you didn't call it either way. Same for the stale san-jose-test / APP_NAME: San Jose leftovers in ci.yml.


Generated by Claude Code

claude added 2 commits August 4, 2026 12:08
The server used React purely as a JSX-to-string function — seven files,
and the only APIs in play were `renderToString`, `JSX`/`ReactNode` types,
and `dangerouslySetInnerHTML`. No hooks, context, Suspense, or portals
anywhere in the server tree. Preact provides all of it, so carrying React
bought a second runtime, a second set of types, and a per-file
`@jsxImportSource` pragma on every client island to opt back out of it.

Now one runtime across server and client:

- `jsxImportSource` is `preact`; `renderToString` comes from
  `preact-render-to-string`
- `React.ReactNode` → `ComponentChildren`, `JSX` type imports → `preact`
- the two `/** @jsxImportSource preact */` pragmas are gone, since Preact
  is the default
- react, react-dom, @types/react, @types/react-dom removed

`preact` moves from devDependencies to dependencies: the server now
imports its JSX runtime at runtime, so a production install without it
would fail to boot. Verified by installing with --production and booting
with NODE_ENV=production.

Two behaviour differences Preact's renderer introduces, both handled:

SVG presentation attributes are passed through verbatim rather than
rewritten, so `strokeWidth` reached the browser unrecognised and strokes
would have rendered at the default width instead of 2. The logo and the
home page icons now use kebab-case (`stroke-width`), which both renderers
emit identically.

Text is escaped less aggressively — a literal `'` where React emitted
`&#x27;`. Both are valid HTML and escape the injection-relevant
characters; one test asserted on the entity and now asserts on the
visible text.

The remaining output differences are cosmetic and were diffed route by
route against the React baseline: `charset` vs `charSet` (case-insensitive
in HTML), a trailing semicolon in inline styles, boolean attributes
without `=""`, attribute ordering, React 19's resource hoisting no longer
pulling the async Lottie script into <head>, and the removal of React's
`<!-- -->` hydration markers. No visible-text or semantic changes.

Docs updated to match: the CLAUDE.md gotcha is now one runtime with two
execution models, plus the kebab-case SVG rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXCNyaHcipkSVcLzHDERmA
CI already runs lint, typecheck, build, and the full suite on every PR, so
a checklist restating those would be noise — the checks are red or green
regardless of what anyone ticks.

This one covers only the quiet failures: the cases where the suite passes
and the app is still wrong. A third-party script without its CSP entry is
blocked in the browser but green in tests; an unregistered page script
ships and never runs; a new table missing from `cleanupTestData` bleeds
between tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXCNyaHcipkSVcLzHDERmA
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