Skip to content

feat(run/serve): zero-config JSX with an in-binary Preact runtime#35194

Open
bartlomieju wants to merge 2 commits into
mainfrom
feat/jsx-auto-install
Open

feat(run/serve): zero-config JSX with an in-binary Preact runtime#35194
bartlomieju wants to merge 2 commits into
mainfrom
feat/jsx-auto-install

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Jun 13, 2026

Copy link
Copy Markdown
Member

JSX does not work out of the box in Deno today. Running a bare .tsx file
with no configuration falls back to the classic transform and fails with
React is not defined, and even a project that configures the recommended
automatic runtime in deno.json still has to manually deno install the JSX
library before anything renders. On top of that, the ergonomic
new Response(<App />) form returns [object Object] because a raw vnode has
no HTML representation. This PR makes JSX work with zero configuration and zero
manual install by baking a small Preact-based JSX runtime into the binary.

Zero-config default via an in-binary bridge

The default JSX import source now resolves to a reserved deno-jsx:preact
sentinel rather than react. The base emit/check compiler options default to
the automatic runtime ("jsx": "react-jsx", "jsxImportSource": "deno-jsx:preact"), and jsx_import_source_config treats the no-config case
(no jsx set anywhere) as the automatic runtime pointing at that sentinel. An
explicit jsxImportSource (e.g. react) always takes precedence, and an
explicit classic "jsx": "react" still emits React.createElement unchanged.

The deno-jsx: scheme is reserved (not npm:) so it never triggers an npm
registry version lookup for a package that does not exist. The graph resolver
passes deno-jsx: specifiers through verbatim, and a JsxBridgeLoader wrapped
around the module-graph loader intercepts that scheme and returns an embedded
module instead of going to the network. The embedded bridge (cli/jsx_bridge.js
and its dev variant) wraps Preact's jsx-runtime and attaches a non-enumerable
toString that renders the vnode with preact-render-to-string. That is what
makes new Response(<App />) return real HTML rather than [object Object].
The bridge's own imports are ordinary npm:preact /
npm:preact-render-to-string specifiers, so they resolve and download through
the normal npm path on first run; no deno.json is created and no
node_modules is required.

End-to-end, with no deno.json, no node_modules, and nothing installed:

// server.tsx
function Page() {
  return <main><h1>Hello from Preact</h1></main>;
}
Deno.serve((_req) =>
  new Response(<Page />, { headers: { "content-type": "text/html" } })
);

$ deno run --allow-net server.tsx
$ curl localhost:8000
<main><h1>Hello from Preact</h1></main>

deno serve works the same way for a module that exports
default { fetch }. Both paths were verified against the built binary.

Why Preact

Preact is Fresh-aligned, complete, and React-compatible through
preact/compat. It is exposed only through the in-binary bridge, not as a
separate npm/jsr package. Crucially, preact-render-to-string does not read
process.env.NODE_ENV, so unlike React it does not require --allow-env to
render. React hits a separate NODE_ENV / --allow-env papercut (see below),
and using Preact for the zero-config default sidesteps it entirely.

Explicit-config auto-install (the original v1 behavior)

When a project explicitly configures jsxImportSource: "react" (or preact)
but has not declared the library, maybe_jsx_auto_install still installs it
via the existing deno add machinery before the graph is built (React also
pulls in react-dom). This now runs for both deno run and deno serve. It
is a no-op when the package is already declared and when the import source is
the zero-config sentinel (which needs no install).

Limitations

  • toString rendering is synchronous (preact-render-to-string's render).
    Async Suspense would require an explicit await renderAsync(...).
  • Type checking a bare .tsx under the automatic runtime has no .d.ts for the
    in-binary bridge, so the JSX runtime is treated as untyped; deno run is
    unaffected since it does not type check by default.

When a project sets jsxImportSource to react (or preact) but the library is
not declared, deno run now installs it automatically (react plus react-dom,
or preact) using the existing deno add machinery, before building the module
graph. This makes JSX work without a manual deno install. Skips when the
package is already declared in a deno.json, and only handles known JSX
libraries.
Default the JSX import source to a reserved deno-jsx:preact sentinel that the
graph loader serves from an embedded Preact bridge, so `deno run foo.tsx` and
`deno serve foo.tsx` work with no deno.json and no manual install, and
`new Response(<App />)` renders to HTML.
@bartlomieju bartlomieju changed the title feat(run): auto-install JSX dependencies when configured but missing feat(run/serve): zero-config JSX with an in-binary Preact runtime Jun 13, 2026
@bartlomieju bartlomieju added this to the 2.9.0 milestone Jun 13, 2026
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.

1 participant