React SPA bridge
Port a React SPA (or islands) to zigapagos import-by-import instead of a big-bang rewrite. An opt-in npm package keeps its import { ... } from "react" lines and bundles against the shared Preact via @z/runtime/compat, so a half-ported app runs on one Preact instance.
There is no de-React codemod and no separate tool to run: you port in place, one package at a time.
The allowlist config — z-runtime.config.json
Islands/SPAs may import only @z/runtime (+ subpaths) and relative paths by default. Add a z-runtime.config.json at your website root to widen that:
{
"islandImports": {
"firstParty": ["@myapp/shared"], // your own workspace scopes (allowed as-is)
"npmCompat": ["react-router-dom", "some-react-lib"] // opt-in npm, run under preact/compat
},
"resolve": { // optional: module-resolution overrides
"@legacy/store": "@z/runtime/compat" // every resolution of the key lands on the value
}
}
firstParty— extra first-party scopes the import lint allows verbatim (exact string or a regex fragment). No scope is allowed by default; declare your own here.npmCompat— npm packages you’ve vetted to run underpreact/compat. Listing a package here lets the import lint accept it; the package bundles normally, but itsreact/react-domimports resolve to the shared runtime (see below).resolve— module-resolution overrides (see next section).
The lint (runtime/scripts/lint-island-imports.ts) reads this file (auto-discovered upward from the linted file, or --config <path>). A bare-CLI lint walks up to the filesystem root to find it, so avoid a stray z-runtime.config.json in an unrelated ancestor dir.
Module-resolution overrides — resolve
Both the SSR sidecar and the island/SPA bundler apply the resolve map as a Bun resolution override: every resolution of a mapped specifier — from any file, at any package depth (firstParty workspace packages, npmCompat deps) — lands on the mapped module. This enforces the ONE-Preact invariant as one config line: no shim packages, no file: redirects, no per-machine symlinks into each workspace package’s node_modules.
Defaults: whenever firstParty or npmCompat is non-empty, the framework default-maps
{
"react": "@z/runtime/compat",
"react-dom": "@z/runtime/compat",
"react-dom/client": "@z/runtime/compat/client",
"react/jsx-runtime": "@z/runtime/jsx-runtime",
"react/jsx-dev-runtime": "@z/runtime/jsx-dev-runtime"
}
so a compat-bridge site needs no resolve block at all for react. An explicit resolve entry for the same key overrides its default.
Semantics:
- Keys match the exact specifier only (no subpaths — map
react/jsx-runtimeseparately fromreact). Mapped keys are automatically importable (exact match) under the lint. - Relative targets (
"./vendor/x.ts") resolve from the config file’s directory. - Client bundle: a mapped specifier whose target is on the
@z/runtimesurface stays external (the page import-map resolves it to the shared runtime — never a second inlined Preact); custom keys are rewritten through a tiny virtual re-export shim since a plain external is never renamed. Any other target is resolved and bundled. - SSR sidecar: overrides are registered as process-global Bun module mocks before any island or SPA module is imported, so they also apply to imports made inside
node_modulespackages.
Optional: tsconfig paths for TYPES
Runtime resolution does not need tsconfig paths — the resolve defaults above cover SSR. To point tsc at the compat types for bare react imports, keep (or add) the mapping in your website tsconfig.json:
{
"compilerOptions": {
"paths": {
"react": ["./node_modules/@z/runtime/src/compat/index.ts"],
"react-dom": ["./node_modules/@z/runtime/src/compat/index.ts"],
"react-dom/client": ["./node_modules/@z/runtime/src/compat/client.ts"],
"react/jsx-runtime": ["./node_modules/@z/runtime/src/jsx-runtime.ts"],
"react/jsx-dev-runtime": ["./node_modules/@z/runtime/src/jsx-runtime.ts"]
}
}
}
(@z/runtime/compat re-exports the full preact/compat React surface.)
Workflow
- Move a component (or a whole npm dep) into the island/SPA tree; keep its
reactimports. - Add the package to
npmCompat(and any workspace scope tofirstParty). - Lint (
zigapagosruns it inssr.sh/spa.sh) — an un-allowlisted npm import fails loudly, so you port deliberately, one dependency at a time. - Build:
reactstays external → one Preact; SSR resolves via theresolvedefaults above.
Caveats
- No module-scope browser side effects. A component that touches
window/documentat module top level breaks build-time SSR — mark the islandclient:onlyor guard the access. React.lazyis not supported through the bridge. The sharedlazyis zigapagos’s route-levellazy(). CallingReact.lazy(...)in an npm component and rendering it throws a loud error (rather than silently rendering nothing) — split at the route level withlazy()instead (seedocs/spa.md→ Code Splitting).- One Preact is enforced by keeping
reactexternal — never let a build inlinepreact/compatinto an island bundle.reactis externalized unconditionally, andresolvetargets on the@z/runtimesurface are likewise always kept external, so an override can’t accidentally inline a second Preact into the client.