diff --git a/bun.lock b/bun.lock index c05d184d..a7de97d9 100644 --- a/bun.lock +++ b/bun.lock @@ -301,6 +301,7 @@ "name": "@wrnexus/compiler", "version": "0.8.10", "dependencies": { + "@wrnexus/core": "workspace:*", "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", "@wrnexus/syntax": "workspace:*", diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 60047eac..ba00ff6b 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -24,6 +24,7 @@ import { import { basename, dirname, extname, join, relative, resolve } from "node:path"; import { buildRouter, type Route } from "@wrnexus/router"; import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr"; +import { getIslandRuntime } from "@wrnexus/react/runtime"; import { analyzeRuntimeImports, analyzeRuntimeRequirements, @@ -112,6 +113,7 @@ export async function runBuild(appRoot: string): Promise { const clientModulesDir = join(distDir, "client"); const reactivePath = join(distDir, "reactive.js"); const controllersPath = join(distDir, "controllers.js"); + const islandsPath = join(distDir, "islands.js"); const publicDir = join(root, "public"); const distPublicDir = join(distDir, "public"); const config = await loadAppConfig(root); @@ -493,6 +495,16 @@ export async function runBuild(appRoot: string): Promise { assetHash.update(controllerCode); console.log(`✓ Controllers: ${controllersPath}`); + // Island bootstrap: emitted unconditionally but inert without markers, so a + // build with no islands still ships no React. + const islandCode = await buildBrowserRuntime( + getIslandRuntime(), + islandsPath, + join(compiledDir, "islands.entry.js"), + ); + assetHash.update(islandCode); + console.log(`✓ Islands: ${islandsPath}`); + // 1a) Theme tokens + client switcher (always emitted; built-in light/dark). const theme = resolveThemeConfig(config.theme, config.cookies); const themeCss = renderThemeCss(theme); diff --git a/packages/dev-server/src/assets.ts b/packages/dev-server/src/assets.ts index e8dfd085..f0d94f85 100644 --- a/packages/dev-server/src/assets.ts +++ b/packages/dev-server/src/assets.ts @@ -25,6 +25,7 @@ import { type ResolvedTheme, type StylesConfig, } from "@wrnexus/styles"; +import { getIslandRuntime } from "@wrnexus/react/runtime"; import { VALIDATE_RUNTIME } from "@wrnexus/validation"; import { I18N_RUNTIME } from "@wrnexus/i18n"; import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from "@wrnexus/uploader"; @@ -32,7 +33,7 @@ import type { Mode } from "@wrnexus/core"; import type { AssetServer } from "./runtime.ts"; import { servePublicAsset } from "./public.ts"; import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts"; -import { serveWrnBrowserArtifact } from "./pipeline.ts"; +import { serveIslandArtifact, serveWrnBrowserArtifact } from "./pipeline.ts"; /** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */ export interface DevStyles { @@ -95,6 +96,10 @@ export function createDevAssetServer( if (pathname.startsWith("/__wrnexus/client/")) { return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 }); } + if (pathname.startsWith("/__wrnexus/island/")) { + return serveIslandArtifact(pathname) ?? new Response("Not Found", { status: 404 }); + } + if (pathname === "/__wrnexus/islands.js") return jsResponse(getIslandRuntime(true)); if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true)); if (pathname === "/__wrnexus/controllers.js") return jsResponse(getComponentControllerRuntime(true)); diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index 795488ef..466a20ea 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -57,6 +57,7 @@ export function runMiddleware( const moduleCache = new Map>>(); const moduleVersions = new Map(); const browserArtifactPaths = new Map(); +const islandArtifactPaths = new Map(); type ImportMode = "legacy" | "compatible" | "explicit"; interface CompileImportOptions { @@ -644,6 +645,25 @@ export function serveWrnBrowserArtifact(pathname: string): Response | null { }); } +/** Registers a built island asset for serving under `/__wrnexus/island/`. */ +export function registerIslandArtifact(pathname: string, artifact: string): void { + islandArtifactPaths.set(pathname, artifact); +} + +/** Serves a built island bundle, chunk, or the island mount runtime. */ +export function serveIslandArtifact(pathname: string): Response | null { + const artifact = islandArtifactPaths.get(pathname); + if (!artifact || !existsSync(artifact)) return null; + return new Response(readFileSync(artifact, "utf8"), { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": "no-store, max-age=0", + pragma: "no-cache", + expires: "0", + }, + }); +} + /** Forget one module and force its next dynamic import to bypass Bun's import cache. */ export function invalidateModule(file: string): void { file = resolve(file); diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index d41d4d29..d083840d 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -25,6 +25,7 @@ import { getNavRuntime, getRealtimeRuntime, } from "@wrnexus/csr"; +import { getIslandRuntime } from "@wrnexus/react/runtime"; import { loadEnv, resolveProfile, @@ -99,6 +100,7 @@ export interface ProdOptions { controllersPath?: string; /** Absolute directory containing bundled per-WRN browser modules. */ clientModulesDir?: string; + islandsDir?: string; /** Absolute path to the pre-built theme stylesheet (`theme.css`). */ themePath?: string; /** Pre-built active theme/accent stylesheets, loaded on demand. */ @@ -341,6 +343,15 @@ function createProdAssetServer(opts: ProdOptions): AssetServer { } return serveFile(join(opts.clientModulesDir, name), JS_HEADERS); } + if (pathname.startsWith("/__wrnexus/island/")) { + const name = pathname.slice("/__wrnexus/island/".length); + if (!opts.islandsDir || !/^[A-Za-z0-9._-]+\.js$/.test(name)) { + return new Response("Not Found", { status: 404 }); + } + return serveFile(join(opts.islandsDir, name), JS_HEADERS); + } + if (pathname === "/__wrnexus/islands.js") + return new Response(getIslandRuntime(), { headers: JS_HEADERS }); if (pathname === "/__wrnexus/reactive.js") { if (opts.reactivePath) { const file = Bun.file(opts.reactivePath); diff --git a/packages/react/src/error-boundary.tsx b/packages/react/src/error-boundary.tsx index 54224e0d..032e6692 100644 --- a/packages/react/src/error-boundary.tsx +++ b/packages/react/src/error-boundary.tsx @@ -4,7 +4,8 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; export interface IslandErrorBoundaryProps { name: string; development: boolean; - children: ReactNode; + /** Optional so createElement(Boundary, props, child) typechecks. */ + children?: ReactNode; } interface IslandErrorBoundaryState { diff --git a/packages/react/src/runtime-source.ts b/packages/react/src/runtime-source.ts new file mode 100644 index 00000000..ab9a50b9 --- /dev/null +++ b/packages/react/src/runtime-source.ts @@ -0,0 +1,40 @@ +/** + * The island bootstrap served at `/__wrnexus/islands.js`. + * + * Mirrors the `@wrnexus/csr` pattern: this file is only ever fetched when a + * `data-wrn-island` marker is present, so island-free pages download nothing — + * including React. + */ +export function getIslandRuntime(development = false): string { + return ` +(function () { + function loader(name) { + return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js"); + } + + function boot() { + if (!document.querySelector("[data-wrn-island]")) return; + import("/__wrnexus/island/runtime.js").then(function (runtime) { + runtime.mountIslands(document, { loader: loader, development: ${development} }); + window.__wrnexusUnmountIslands = function (root) { + runtime.unmountIslands(root || document); + }; + window.__wrnexusRemountIslands = function (root) { + return runtime.remountIslands(root || document, { + loader: loader, + development: ${development} + }); + }; + }).catch(function (error) { + console.error("[wrnexus] failed to load the island runtime", error); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); +`; +} diff --git a/packages/react/test/island-runtime.test.ts b/packages/react/test/island-runtime.test.ts index 800c83a1..0d01a7ef 100644 --- a/packages/react/test/island-runtime.test.ts +++ b/packages/react/test/island-runtime.test.ts @@ -5,6 +5,12 @@ import { islandRootCount, mountIslands, unmountIslands } from "../src/island-run (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +// happy-dom's element types do not structurally match lib.dom's ParentNode; +// this cast is a test-environment concern, not a runtime one. +function host(window: Window): ParentNode { + return window.document.body as unknown as ParentNode; +} + function domWith(html: string) { const window = new Window(); window.document.body.innerHTML = html; @@ -36,7 +42,7 @@ test("mounts an island and passes deserialized props", async () => { const window = domWith(marker('{"title":"Revenue"}')); await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); expect(window.document.body.textContent).toContain("Revenue"); @@ -47,12 +53,12 @@ test("unmounts roots and leaves no leaked roots behind", async () => { const window = domWith(marker('{"title":"A"}')); await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); expect(islandRootCount()).toBe(1); act(() => { - unmountIslands(window.document.body); + unmountIslands(host(window)); }); expect(islandRootCount()).toBe(0); }); @@ -62,10 +68,10 @@ test("repeated mount/unmount cycles do not accumulate roots", async () => { for (let i = 0; i < 5; i += 1) { await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); act(() => { - unmountIslands(window.document.body); + unmountIslands(host(window)); }); } @@ -75,7 +81,7 @@ test("repeated mount/unmount cycles do not accumulate roots", async () => { test("does nothing when no island markers are present", async () => { const window = domWith(`

plain server html

`); await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); expect(islandRootCount()).toBe(0); }); @@ -84,8 +90,8 @@ test("mounting twice does not create a second root for the same element", async const window = domWith(marker()); await act(async () => { - await mountIslands(window.document.body, { loader }); - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); + await mountIslands(host(window), { loader }); }); expect(islandRootCount()).toBe(1); @@ -97,7 +103,7 @@ test("a failing bundle load leaves the placeholder and mounts no root", async () console.error = () => {}; await act(async () => { - await mountIslands(window.document.body, { + await mountIslands(host(window), { loader: async () => { throw new Error("network down"); }, @@ -115,7 +121,7 @@ test("malformed props JSON falls back to empty props instead of throwing", async console.error = () => {}; await act(async () => { - await mountIslands(window.document.body, { loader }); + await mountIslands(host(window), { loader }); }); console.error = original; diff --git a/packages/react/test/runtime-source.test.ts b/packages/react/test/runtime-source.test.ts new file mode 100644 index 00000000..a34a5030 --- /dev/null +++ b/packages/react/test/runtime-source.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { getIslandRuntime } from "../src/runtime-source.ts"; + +test("emits a runtime that bails out when no island markers exist", () => { + const source = getIslandRuntime(false); + expect(source).toContain("data-wrn-island"); + expect(source).toContain("/__wrnexus/island/"); +}); + +test("registers a navigation hook so islands unmount on route change", () => { + expect(getIslandRuntime(false)).toContain("__wrnexusUnmountIslands"); +}); + +test("never references react-dom/server", () => { + expect(getIslandRuntime(true)).not.toContain("react-dom/server"); +}); + +test("threads the development flag into the mount options", () => { + expect(getIslandRuntime(true)).toContain("development: true"); + expect(getIslandRuntime(false)).toContain("development: false"); +}); + +test("encodes the island name before using it as a URL path segment", () => { + expect(getIslandRuntime(false)).toContain("encodeURIComponent"); +}); diff --git a/tsconfig.json b/tsconfig.json index bd5dda0a..dc398dc5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,8 @@ "@wrnexus/core": ["./packages/core/src/index.ts"], "@wrnexus/core/jsx-runtime": ["./packages/core/src/jsx-runtime.ts"], "@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"], + "@wrnexus/react": ["./packages/react/src/index.ts"], + "@wrnexus/react/runtime": ["./packages/react/src/runtime-source.ts"], "@wrnexus/reactive": ["./packages/reactive/src/index.ts"], "@wrnexus/graphql": ["./packages/graphql/src/index.ts"], "@wrnexus/graphql/*": ["./packages/graphql/src/*.ts"],