Unify on Preact, drop React from the server render path - #45
Open
alexpricedev wants to merge 2 commits into
Open
Unify on Preact, drop React from the server render path#45alexpricedev wants to merge 2 commits into
alexpricedev wants to merge 2 commits into
Conversation
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 `'`. 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, theJSX/ReactNodetypes, anddangerouslySetInnerHTML— 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
jsxImportSource→preact;renderToStringfrompreact-render-to-stringReact.ReactNode→ComponentChildren;JSXtype imports frompreactreact,react-dom,@types/react,@types/react-domremovedpreactmoves 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-lockfilethen boot withNODE_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 asstrokeWidth, 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:Fixed by converting those attributes to kebab-case, which both renderers emit identically.
viewBoxis left alone — it's genuinely camelCase in SVG. This is now a documented gotcha inCLAUDE.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:
charsetvscharSetcolor-scheme:dark;trailing;asyncvsasync=""'vs'<,>,&,") still escaped<!-- -->markers gone<script async>back at end of<body><head>; Preact keeps authored position. Stillasync, still has its SRI hashOne test asserted on
We've— an implementation detail of React's escaper — and now asserts on the visible text.Verification
mainbefore touching anythingbun run checkclean; cleanbun install --frozen-lockfile(what CI uses) works--externaland still loaded from the esm.sh import mapNODE_ENV=productionboot verifiedNot 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" — thesrc/server/vssrc/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
ProjectSearchisland rewrite I flagged — it usesuseEffect+getElementByIdto 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 stalesan-jose-test/APP_NAME: San Joseleftovers inci.yml.Generated by Claude Code