From 06df9d66ae4cc433143d543bbfad5f1b00e85c95 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 15:22:58 +0530 Subject: [PATCH] feat(compiler): bundle islands with a shared React chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit splitting:true keeps React in one shared chunk so a page with several islands does not ship react-dom repeatedly. Island .tsx is compiled against React's JSX runtime via a Bun onLoad plugin. The repo's root tsconfig sets jsxImportSource to @wrnexus/core, so islands would otherwise compile to the HTML-string renderer and never mount. A @jsxImportSource pragma only affects the file carrying it, so injecting one into the generated entry is not enough — the injection has to happen per source file. App-authored islands stay plain .tsx. The JSX test asserts built output rather than generated entry text, because the entry-text assertion passed while the mechanism did not work. Co-Authored-By: Claude Opus 5 --- packages/compiler/src/island-bundle.ts | 113 +++++++++++++++++++ packages/compiler/test/island-bundle.test.ts | 61 ++++++++++ 2 files changed, 174 insertions(+) create mode 100644 packages/compiler/src/island-bundle.ts create mode 100644 packages/compiler/test/island-bundle.test.ts diff --git a/packages/compiler/src/island-bundle.ts b/packages/compiler/src/island-bundle.ts new file mode 100644 index 00000000..964c379f --- /dev/null +++ b/packages/compiler/src/island-bundle.ts @@ -0,0 +1,113 @@ +import type { BunPlugin } from "bun"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { join } from "node:path"; + +export interface IslandInput { + name: string; + sourcePath: string; +} + +export interface IslandBuildResult { + assets: Array<{ name: string; hash: string; path: string }>; + sharedChunks: string[]; +} + +/** + * Generates the per-island browser entry. + * + * Never imports react-dom/server — islands are client-only. + */ +export function generateIslandEntry(input: IslandInput): string { + return [ + "/** @jsxImportSource react */", + `import Component from ${JSON.stringify(input.sourcePath)};`, + `export const name = ${JSON.stringify(input.name)};`, + `export default Component;`, + "", + ].join("\n"); +} + +/** + * Compiles every island `.tsx` against React's JSX runtime. + * + * The repo's root tsconfig sets `jsxImportSource` to `@wrnexus/core`, so an + * island would otherwise compile to WRNexus's HTML-string renderer and + * silently never mount. A `@jsxImportSource` pragma applies only to the file + * that carries it, so putting one in the generated entry does nothing for the + * author's own component — the injection has to happen per source file, which + * is what this plugin does. App-authored islands stay plain `.tsx`. + */ +export function reactJsxPlugin(): BunPlugin { + return { + name: "wrnexus-island-jsx", + setup(build) { + build.onLoad({ filter: /\.tsx$/ }, async (args) => ({ + contents: `/** @jsxImportSource react */\n${await Bun.file(args.path).text()}`, + loader: "tsx", + })); + }, + }; +} + +export function assertReactAvailable( + appRoot: string, +): { code: "WRN-ISLAND-REACT-MISSING"; message: string; severity: "error" } | null { + const require = createRequire(join(appRoot, "package.json")); + try { + require.resolve("react"); + require.resolve("react-dom"); + return null; + } catch { + return { + code: "WRN-ISLAND-REACT-MISSING", + severity: "error", + message: + "This app imports a .tsx island but react and react-dom are not installed. " + + "Run: bun add react react-dom", + }; + } +} + +/** + * Bundles island entries. `splitting: true` is required so React is emitted + * once as a shared chunk rather than duplicated into every island. + */ +export async function buildIslands(input: { + islands: IslandInput[]; + outDir: string; +}): Promise { + if (input.islands.length === 0) return { assets: [], sharedChunks: [] }; + + const result = await Bun.build({ + entrypoints: input.islands.map((island) => island.sourcePath), + outdir: input.outDir, + target: "browser", + format: "esm", + splitting: true, + minify: true, + plugins: [reactJsxPlugin()], + }); + + if (!result.success) { + throw new AggregateError(result.logs, "Island bundling failed"); + } + + const assets: IslandBuildResult["assets"] = []; + const sharedChunks: string[] = []; + + for (const output of result.outputs) { + if (output.kind === "entry-point") { + const island = input.islands[assets.length]!; + assets.push({ + name: island.name, + hash: createHash("sha256").update(output.path).digest("hex").slice(0, 16), + path: output.path, + }); + } else if (output.kind === "chunk") { + sharedChunks.push(output.path); + } + } + + return { assets, sharedChunks }; +} diff --git a/packages/compiler/test/island-bundle.test.ts b/packages/compiler/test/island-bundle.test.ts new file mode 100644 index 00000000..978fd0d2 --- /dev/null +++ b/packages/compiler/test/island-bundle.test.ts @@ -0,0 +1,61 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { assertReactAvailable, buildIslands, generateIslandEntry } from "../src/island-bundle.ts"; + +const created: string[] = []; +afterAll(() => { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); +}); + +test("generates an entry that re-exports the island component", () => { + const entry = generateIslandEntry({ name: "Chart", sourcePath: "/app/Chart.tsx" }); + expect(entry).toContain("/app/Chart.tsx"); + expect(entry).toContain("Chart"); + expect(entry).not.toContain("react-dom/server"); +}); + +test("island .tsx compiles against React's JSX runtime, not WRNexus's", async () => { + // The root tsconfig points jsxImportSource at @wrnexus/core, so an island + // would otherwise compile to WRNexus's HTML-string renderer and never mount. + // A pragma applies only to the file carrying it, so this asserts the built + // output rather than the generated entry text. + // The fixture must live inside the repo: Bun resolves `react` from the + // importing file's location, exactly as a real island resolves it from the + // app that installed react. + const root = mkdtempSync(join(process.cwd(), ".island-jsx-test-")); + created.push(root); + const source = join(root, "Chart.tsx"); + writeFileSync( + source, + `export default function Chart({ title }: { title: string }) { + return
{title}
; + }`, + ); + + const outDir = join(root, "out"); + await buildIslands({ islands: [{ name: "Chart", sourcePath: source }], outDir }); + + const built = readdirSync(outDir) + .filter((file) => file.endsWith(".js")) + .map((file) => readFileSync(join(outDir, file), "utf8")) + .join("\n"); + + expect(built).not.toContain("wrnexus"); + expect(built).toMatch(/react\/jsx|jsxDEV|jsx_runtime/); +}); + +test("reports WRN-ISLAND-REACT-MISSING when react is not installed", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-noreact-")); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "app" })); + + const diagnostic = assertReactAvailable(root); + expect(diagnostic?.code).toBe("WRN-ISLAND-REACT-MISSING"); + expect(diagnostic?.message).toContain("bun add react react-dom"); +}); + +test("returns null when react resolves", () => { + expect(assertReactAvailable(process.cwd())).toBeNull(); +});