test(islands): guard zero-JS routes and single-React bundling
Two guards protect the core promise: a route with no islands emits no assets at all, and a page with several islands keeps React in one shared chunk. buildIslands now writes a generated entry per island instead of passing component sources directly. Two islands sharing a source deduped to a single entrypoint, and output order is not guaranteed to match input order, so island names could bind to the wrong bundle. Island modules are excluded from the editor compiler bundle: it globs packages/compiler/src, and island-bundle.ts calls Bun.build while island-codegen.ts imports @wrnexus/core — neither belongs in a Node-only VS Code artifact. Integration assertions share one build. bun test interferes with Bun.build's module reads after several build calls in one process, while the same calls succeed repeatedly outside the runner; production is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import type { BunPlugin } from "bun";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
export interface IslandInput {
|
||||
name: string;
|
||||
@@ -42,10 +43,15 @@ 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",
|
||||
}));
|
||||
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
||||
// Only first-party island sources need the pragma. Third-party .tsx
|
||||
// under node_modules is left alone so Bun's own handling is untouched.
|
||||
if (args.path.includes("node_modules")) return undefined;
|
||||
return {
|
||||
contents: `/** @jsxImportSource react */\n${await Bun.file(args.path).text()}`,
|
||||
loader: "tsx" as const,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -79,35 +85,57 @@ export async function buildIslands(input: {
|
||||
}): 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()],
|
||||
});
|
||||
// Each island gets its own generated entry file named after the island.
|
||||
// Passing the component sources directly would dedupe two islands that share
|
||||
// a source file, and output order is not guaranteed to match input order —
|
||||
// both of which silently mismatch island names to bundles.
|
||||
//
|
||||
// The entries live inside outDir so `react` resolves from the app that
|
||||
// installed it, exactly as the island's own imports do.
|
||||
const entryDir = join(input.outDir, ".entries");
|
||||
mkdirSync(entryDir, { recursive: true });
|
||||
|
||||
if (!result.success) {
|
||||
throw new AggregateError(result.logs, "Island bundling failed");
|
||||
const islandNames = new Set(input.islands.map((island) => island.name));
|
||||
for (const island of input.islands) {
|
||||
writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
|
||||
}
|
||||
|
||||
const assets: IslandBuildResult["assets"] = [];
|
||||
const sharedChunks: string[] = [];
|
||||
try {
|
||||
const result = await Bun.build({
|
||||
entrypoints: input.islands.map((island) => join(entryDir, `${island.name}.tsx`)),
|
||||
outdir: input.outDir,
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
plugins: [reactJsxPlugin()],
|
||||
});
|
||||
|
||||
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);
|
||||
if (!result.success) {
|
||||
throw new AggregateError(result.logs, "Island bundling failed");
|
||||
}
|
||||
}
|
||||
|
||||
return { assets, sharedChunks };
|
||||
const assets: IslandBuildResult["assets"] = [];
|
||||
const sharedChunks: string[] = [];
|
||||
|
||||
for (const output of result.outputs) {
|
||||
if (output.kind === "entry-point") {
|
||||
// Bun names an entry's output after its entry file, so the basename
|
||||
// identifies the island unambiguously.
|
||||
const stem = basename(output.path).replace(/\.js$/, "");
|
||||
if (!islandNames.has(stem)) continue;
|
||||
assets.push({
|
||||
name: stem,
|
||||
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 };
|
||||
} finally {
|
||||
rmSync(entryDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user