Repository navigation
JSX Pack: render JSX components from PHP (v0.6.0) - #23
Merged
Merged
Conversation
Plans JSX support and native React 19.3 server rendering in pure PHP, plus a general JSX renderer for server-only HTML. Records the sign-off decisions and the verified facts behind them: React's server output compared byte for byte, hydration tested in jsdom, and rough local timings for real React, Preact and the components alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Imports have to work with no build step, so the PRD gains a module resolution layer: missing extensions and index files, jsconfig and tsconfig aliases, node_modules with package.json exports, a CommonJS loader with bundler interop, style and asset imports, import-map stubs, and barrel pruning for packages that declare sideEffects false. Also adds component guidelines for presentational components, React 19.3's browser() client-only switch, precompile rules that keep inspectable elements real, and the optional server-bundle path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
contains/attach/detach print deprecation notices on PHP 8.5, which break the compat-report worker protocol. The ArrayAccess forms behave the same on every supported PHP version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lexer recognizes JSX in modules that opt in (JsxOptions::$extensions, .jsx by default) with a tag/children/expression frame stack, so JSX text never reaches the regex or string scanners. Template literals and JSX nest in both directions, and JSX after a closing brace starts an element as in Babel and esbuild. The parser builds JSX nodes (ESTree JSX via EstreeSerializer) and lowers them at parse time, byte-compatible with esbuild: automatic and classic runtimes, pragmas, key and spread ordering, the createElement fallback, and esbuild's whitespace and entity rules. The interpreter, VM and JsToPhp emitter never see a JSX node. Preserve mode keeps the JSX AST for tooling. Oracles: Babel parser (parse, 42 fixtures) and esbuild with a recording runtime (lowering, 19 fixtures), pinned in tests/Jsx/oracle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Module resolution follows bundler semantics so components import what the client bundle imports, with no build step: import maps (null stubs), built-in modules, package.json imports, jsconfig/tsconfig paths, extension and index probing, and node_modules with exports conditions, module and main fields. Style imports become empty modules; asset and CSS-module imports go through host resolvers and fail without one. CommonJS modules run in Node's wrapper with require, module.exports, require cycles, JSON and require() of ES modules, and bundler __esModule interop (Node's default-export rule for .mjs importers). process.env .NODE_ENV is a parse-time define in ES modules and a minimal process object in CommonJS. Side-effect-free barrels are pruned so one icon import loads only that icon's module. Engine gains setJsxOptions, setModuleOptions, setImportMap, setAssetResolver, setCssModuleResolver and getExport; execFile routes .mjs and JSX files through the module loader. Oracles: a fixture app bundled by esbuild and run in Node (resolution) and 10 package smoke tests rendered in Node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A call inside a try Block is never in tail position, but strict code
turned `try { return f(); } catch (e) { ... }` into a tail call, so the
exception skipped the catch. React's server renderer relies on exactly
this shape (renderNode), and lost content after a suspension. The Catch
block keeps proper tail calls when there is no Finally, as test262's tco
tests require.
Reflect.construct skipped class fields: a base class got none, and a
derived class with an implicit constructor missed its own. It now
initializes them where `new` does.
test262: language/statements/try, language/expressions/call and the other
tail-call areas, built-ins/Reflect, built-ins/Function and both class
categories all pass at 100%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
react, react/jsx-runtime, react-dom, react-dom/server and react-dom/static are built-in modules implemented in PHP. The server renderer is a port of React's MIT-licensed Fizz sources (ReactFizzServer, ReactFizzHooks, ReactFizzConfigDOM, legacy config for renderToString) and keeps React's copyright notices; React's inline runtime scripts are copied byte for byte. Only the application's components run as JavaScript. - Elements are React 19's shape (JsElement, Symbol.for keys); jsx, jsxs, createElement, cloneElement, Children, memo, forwardRef, lazy, context in all three forms, Component and PureComponent. - Hooks follow Fizz: render-phase updates, useId tree ids (_R_..._), use() for context and promises, browser() client-only boundaries. - renderToString, renderToStaticMarkup and prerender (waiting for async components and use(promise), outlining large boundaries with $RC). - Resource hints and hoisting: preloads, stylesheet precedence, async scripts, title/meta/link, document preamble. - Engine::render() and renderSource() render a component module with PHP props (prerender semantics); getExport(); the CLI runs .mjs/.jsx through the module loader and gains --render. - React packages also default-export module.exports, as bundlers do. Oracles: 51 React fixtures byte-identical to react-dom@19.3.0 in Node (renderToString, renderToStaticMarkup, prerender) and through Engine::render(); real React 19.3 (vendored edge build) inside Phasis agrees on every fixture (three-way, which is also the exact-React fallback); React's client hydrates every hydratable fixture in jsdom with production and development builds (76/76 runs, zero recoverable errors, every server node reused). The Goals fixture project renders through Engine::render() byte-identical to its esbuild bundle in Node, and the five React smoke packages (lucide-react, react-icons, @heroicons/react, react-markdown, use-sync-external-store) match Node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Server-only templates (emails, feeds, static pages) render with the shared element model and hooks, serialized the way preact-render-to-string 6.8.0 does: HTML attribute spellings, class and for, prop:value; styles with px, bare boolean attributes, <br/> void elements, select/textarea values, no hydration markers and no URL filtering. renderToStringAsync waits for components that suspend on use(), with prts's <!--$s--> markers. Phasis extensions outside the byte-compat oracle: raw() inserts trusted HTML verbatim, and Engine::render() with the HTML profile awaits async components. The profile follows the module's JSX import source (/** @jsxImportSource phasis */) or Engine::render()'s RenderProfile. Oracle: 12 HTML fixtures byte-identical to preact-render-to-string 6.8.0 in Node, where `phasis` maps to a Preact-based shim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JsxMode::Precompile (opt-in, for the built-in react and phasis runtimes)
serializes the static HTML inside an element's children at parse time,
as Deno's jsx: "precompile" does:
<div className="card"><h2>{title}</h2><p>Static text</p></div>
-> const $$tpl_1 = ['<h2>', '</h2><p>Static text</p>', meta];
jsx("div", { className: "card", children: jsxTemplate($$tpl_1, title) });
Static parts use the target profile's own serializers (React's
pushAttribute and escaping, or preact-render-to-string's), so the bytes
match. The outermost element of each JSX expression, elements passed to
components, components and spread elements stay real elements; dynamic
attributes become jsxAttr() holes and inline on* handlers are dropped.
For the React profile each part records whether it starts or ends with
text and each hole its tree position (array forks and enclosing tags),
so <!-- --> markers, useId and format contexts are what React computes.
The helpers come from the internal phasis/jsx-precompile module, keeping
react/jsx-runtime's export list exact.
Oracle: the whole React corpus (52 fixtures) and HTML corpus (13)
render byte-identical with and without precompile, including a fixture
built for template shapes (useId in holes, text next to holes, a
suspending hole); it also hydrates cleanly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bench/ssr.jsx is the PRD's page (header, nav, 100 post cards, a context consumer, about 1,300 elements) importing classnames (CommonJS) and lucide-react icons through the barrel. bench/ssr.php renders it five ways: native React, native with precompile, real React 19.3 in Phasis, the HTML profile and the components-only floor, and checks the ratio targets. bench/node_modules holds trimmed copies of classnames 2.5.1 and lucide-react 1.56.0 (with their licenses) so the bench needs no npm. Local medians of three runs (PHP 8.5, tracing JIT, Xdebug off): native renders 12.6x faster than real React in Phasis (target 10x) and within 1.11x of the components-only floor (target 1.5x); precompile is 1.04x faster on this component-heavy page. preload.php loads every Phasis class for opcache.preload (429 classes), so no React code is compiled per request. ModuleOptions::$parseCacheDir caches parsed module ASTs on disk across engines and requests (PHP-FPM), keyed by source and parse options. bench.yml runs the SSR bench with preload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs/jsx.md, docs/react.md and docs/modules.md in a new JSX section, registered in docs/meta.json (docs-content now expects 28 docs). - docs/api.md: render(), renderSource(), getExport(), the JSX, module, import map and resolver setters, and the corrected execFile() rule. - docs/parser.md: JsxOptions and the JSX AST nodes; docs/cli.md: module files, --render and --ast for JSX; benchmarks.md: the SSR bench. - README feature list and doc links, CHANGELOG entry. - jsx.yml: the jsx PHPUnit suite plus the hydration check, with the pinned Node toolchain installed. - prds/JSX-PACK.md: "Deviations in the implementation". - preload.php: document the installed vendor path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Full local ./bin/compat-report run on feat/jsx-pack (PHP 8.5, tracing JIT, Xdebug off, 8 jobs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the final tree precompile renders bench/ssr.jsx within noise of the plain build (0.98x, 1.03x, 1.00x), so the docs no longer promise a 4% gain, and the PRD records the Phase 8 gate as a deviation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The compression WPT fixtures build SharedArrayBuffers the way the Web
Platform hands them out, through new WebAssembly.Memory({ shared: true }).
Phasis can't compile WebAssembly, so the namespace carries only what needs
no wasm engine: Memory (descriptor validation, buffer, grow, V8's error
messages) and the CompileError, LinkError and RuntimeError constructors.
- A non-shared memory's ArrayBuffer has a detach key, so transfer() and
structuredClone transfers reject it and only grow() detaches it.
- A shared memory's SharedArrayBuffers are frozen and alias one backing
store across grow().
- CompressionStream/DecompressionStream treated shared buffers as
BufferSource; BufferSource has no [AllowShared], so writes now reject.
With ext-brotli loaded (as the WPT workflow does), ./bin/wpt passes
15,577 / 15,577. The SharedArrayBuffer subtests leave the PHPUnit
expected-failure list; a Node-oracle scenario pins Memory's behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ModuleOptions::$deferSideEffectFreeModules: an ES module imported from a package that declares "sideEffects": false loads when one of its imported bindings is first read, instead of before the importer runs. The path still resolves at link time; a missing export throws its SyntaxError on the first read; namespace and side-effect imports, application files, CommonJS and possible top-level await always load with the importer. With it, importing one lucide-react icon loads 4 modules instead of 13 (PRD acceptance criterion 4) and rendering it loads 11. It is off by default because it changes when module code runs: the resolution oracle's barrel-pkg declares "sideEffects": false but logs as it loads, and with deferral it logs at first use rather than at startup as in the bundle. Signed off as opt-in. - The side-effect lookup moves from BarrelPruner to ModuleResolver. - requireModule's mid-evaluation linking is shared with the lazy path. - The package smoke tests run every package in both modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Attribute holes allocated a jsxAttr() marker object on every render. The attribute's name is static, so it now goes in the template's metadata and the hole is the bare value. jsxAttr stays exported so templates already in a parse cache still render. On bench/ssr.jsx component JavaScript dominates (the 125 lucide icons take about two thirds of a render), so precompile's gain is a few percent, smaller than the drift between variants timed one after the other (that way it read 0.98x to 1.04x). bench/ssr.php now times the pair back to back in alternating order, reports the median per-round ratio and requires precompile to beat native: 1.01x, 1.04x and 1.05x in three local runs. On the same cards without icons or tag components precompile is about 1.12x faster. Templating outermost elements too was measured and gave nothing, so they stay real elements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Full local ./bin/compat-report run on 5ccb8b8 (PHP 8.5, tracing JIT, Xdebug off, 8 jobs), after WebAssembly.Memory, deferred loading and the precompile attribute change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: - Engine::reset() runs main's initializeGlobalObject() and this branch's configureModuleLoader(), in the constructor's order. - docs/api.md and docs/cli.md take main's corrected text, plus this branch's additions: execFile() and bin/phasis load .mjs and .jsx files as modules, render(), getExport(), the module and JSX setters, and --render. - docs/compatibility/limitations.md, README.md and the docs-content check describe the bundler-style module loader and the 28 docs. - The CHANGELOG's Unreleased section becomes 0.6.0, above main's 0.5.0. - COMPAT.md and compat.json are regenerated after the merge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Full local ./bin/compat-report run on the merge with main (458e63e). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Implements
prds/JSX-PACK.md(Phases 1-10): render JSX components to HTML from PHP with no build step. Releases as v0.6.0 (CHANGELOG updated).What ships
.jsxmodules, lowered at parse time exactly like esbuild (automatic and classic runtimes, pragmas);JsxMode::Preservefor tooling with ESTree JSX export.Engine::render()/renderSource(): render a component module with PHP props.react,react-dom,react-dom/server,react-dom/staticas built-in modules. HTML is byte-identical toreact-dom@19.3.0and hydrates with React 19.3's client.phasis,phasis/jsx-runtime), byte-identical to preact-render-to-string 6.8.0, withraw().indexprobing,jsconfigaliases,node_moduleswithexports, CommonJS packages, style and asset imports, barrel pruning; opt-in deferred loading of side-effect-free package modules.JsxMode::Precompile,preload.phpforopcache.preload, an on-disk parse cache,bin/phasis --render.WebAssembly.Memory(no module compilation) and aCompressionStreamfix, which take WPT to 100%.try { return f() } catchtail calls, andReflect.constructclass fields.Deviations from the PRD are recorded in its "Deviations in the implementation" section.
Verified locally on the merge with main (v0.5.0)
./bin/verify-all./bin/compat-report)./bin/wpt(with ext-brotli)tests/Popularbench/run.php(median of 3)composer auditjsx.yml(JSX suites + hydration with the pinned Node toolchain) is new and runs for the first time on this PR.🤖 Generated with Claude Code