feat(compiler): bundle islands with a shared React chunk
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<IslandBuildResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 <div className="chart">{title}</div>;
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user