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:
@@ -2259,6 +2259,30 @@
|
||||
"subjectQueue"
|
||||
]
|
||||
},
|
||||
"@wrnexus/react": {
|
||||
".": [
|
||||
"BoundStore",
|
||||
"IslandErrorBoundary",
|
||||
"IslandErrorBoundaryProps",
|
||||
"IslandStore",
|
||||
"MountOptions",
|
||||
"SnapshotCache",
|
||||
"SnapshotSource",
|
||||
"StoreResolver",
|
||||
"createSelectorCache",
|
||||
"createSnapshotCache",
|
||||
"islandRootCount",
|
||||
"mountIslands",
|
||||
"remountIslands",
|
||||
"setStoreResolver",
|
||||
"unmountIslands",
|
||||
"useWrnActions",
|
||||
"useWrnStore"
|
||||
],
|
||||
"./runtime": [
|
||||
"getIslandRuntime"
|
||||
]
|
||||
},
|
||||
"@wrnexus/reactive": {
|
||||
".": [
|
||||
"AnimationTimeline",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: c0e3e8c72c68cb3c182e2de84c13ef0b8921579d9b081550002b8cdbe4af3397
|
||||
// WRN editor compiler generator hash: c71e7fe4258c97b73b384ff14b321f0cf0b30cc2ed0322f5f84b04e757159b18
|
||||
// Generated with TypeScript: 5.9.3
|
||||
// WRN editor compiler source hash: 182fd799ca860d927879d4259c182ea61cbd89d913758cc9f690e1ae4a35d90f
|
||||
// WRN editor compiler generator hash: 2690208ba65bb00d9fea3e08cb3ab324cfda77792021cd46785814fadf41c1bc
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
const __path = __nodeRequire("node:path");
|
||||
const __modules = {
|
||||
@@ -11,6 +11,7 @@ const __modules = {
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.optimizeAst = optimizeAst;
|
||||
exports.analyzeOptimizations = analyzeOptimizations;
|
||||
exports.routeNeedsIslands = routeNeedsIslands;
|
||||
exports.analyzeRuntimeRequirements = analyzeRuntimeRequirements;
|
||||
function identifiers(value) {
|
||||
return new Set(value.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
||||
@@ -191,8 +192,18 @@ function hasEvent(nodes) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function analyzeRuntimeRequirements(ast) {
|
||||
/**
|
||||
* A route containing a React island ships JavaScript and can no longer be
|
||||
* classified as zero-JS static, so island presence must reach the classifier.
|
||||
*/
|
||||
function routeNeedsIslands(imports) {
|
||||
return imports.some((entry) => entry.kind === "island");
|
||||
}
|
||||
function analyzeRuntimeRequirements(ast, options = {}) {
|
||||
const hasIslands = options.hasIslands ?? false;
|
||||
const reasons = [];
|
||||
if (hasIslands)
|
||||
reasons.push("react island");
|
||||
const clientFunctions = ast.runtimeFunctions.some((fn) => fn.runtime !== "server");
|
||||
const clientState = ast.states.some((state) => state.runtime !== "server");
|
||||
const interactive = clientFunctions ||
|
||||
@@ -246,11 +257,16 @@ function analyzeRuntimeRequirements(ast) {
|
||||
kind = "streaming-ssr";
|
||||
reasons.push("partial-static shell with streamed dynamic regions");
|
||||
}
|
||||
// An island ships JavaScript, so a would-be zero-JS static route must be
|
||||
// reported as static-interactive. Explicit render modes still win above.
|
||||
if (hasIslands && kind === "static")
|
||||
kind = "static-interactive";
|
||||
const clientDisabled = ast.renderMode === "static" || ast.renderMode === "server";
|
||||
const serverDisabled = ast.renderMode === "client";
|
||||
return {
|
||||
kind,
|
||||
canPrerender: kind === "static" || kind === "static-interactive",
|
||||
needsIslandRuntime: hasIslands,
|
||||
needsClientRuntime: !clientDisabled &&
|
||||
(interactive || ast.renderMode === "client") &&
|
||||
ast.hydrate !== "none" &&
|
||||
@@ -3107,9 +3123,11 @@ function candidates(path) {
|
||||
path,
|
||||
`${path}.wrn`,
|
||||
`${path}.ts`,
|
||||
`${path}.tsx`,
|
||||
`${path}.d.ts`,
|
||||
(0, node_path_1.join)(path, "index.wrn"),
|
||||
(0, node_path_1.join)(path, "index.ts"),
|
||||
(0, node_path_1.join)(path, "index.tsx"),
|
||||
];
|
||||
}
|
||||
function resolveWrnImport(declaration, importer, options) {
|
||||
@@ -3136,8 +3154,12 @@ function resolveWrnImport(declaration, importer, options) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (found)
|
||||
return { declaration, resolved: (0, node_fs_1.realpathSync)(found) };
|
||||
if (found) {
|
||||
const resolved = (0, node_fs_1.realpathSync)(found);
|
||||
return resolved.endsWith(".tsx")
|
||||
? { declaration, resolved, kind: "island" }
|
||||
: { declaration, resolved };
|
||||
}
|
||||
const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning";
|
||||
return {
|
||||
declaration,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: 28a3937b948e6affb33150753d537162c6702786551dd90ab0968ef9166f21ac
|
||||
// WRN editor extension source hash: 4d9518778cef65c0400da0e8be9ece65be85acbd581e3024d484b7cbd2fb8116
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
|
||||
+2334
-1607
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Counter({ start = 0 }: { start?: number }) {
|
||||
const [count, setCount] = useState(start);
|
||||
return (
|
||||
<button type="button" onClick={() => setCount((value) => value + 1)}>
|
||||
{`clicked ${count}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({
|
||||
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",
|
||||
}));
|
||||
loader: "tsx" as const,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -79,8 +85,24 @@ export async function buildIslands(input: {
|
||||
}): Promise<IslandBuildResult> {
|
||||
if (input.islands.length === 0) return { assets: [], sharedChunks: [] };
|
||||
|
||||
// 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 });
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Bun.build({
|
||||
entrypoints: input.islands.map((island) => island.sourcePath),
|
||||
entrypoints: input.islands.map((island) => join(entryDir, `${island.name}.tsx`)),
|
||||
outdir: input.outDir,
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
@@ -98,9 +120,12 @@ export async function buildIslands(input: {
|
||||
|
||||
for (const output of result.outputs) {
|
||||
if (output.kind === "entry-point") {
|
||||
const island = input.islands[assets.length]!;
|
||||
// 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: island.name,
|
||||
name: stem,
|
||||
hash: createHash("sha256").update(output.path).digest("hex").slice(0, 16),
|
||||
path: output.path,
|
||||
});
|
||||
@@ -110,4 +135,7 @@ export async function buildIslands(input: {
|
||||
}
|
||||
|
||||
return { assets, sharedChunks };
|
||||
} finally {
|
||||
rmSync(entryDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { routeNeedsIslands } from "../src/analysis.ts";
|
||||
import { buildIslands, type IslandBuildResult } from "../src/island-bundle.ts";
|
||||
|
||||
const COUNTER = resolve(import.meta.dir, "../../../examples/basic-app/app/islands/Counter.tsx");
|
||||
|
||||
const created: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const dir of created) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function outDir(label: string): string {
|
||||
const dir = mkdtempSync(join(process.cwd(), `.island-int-${label}-`));
|
||||
created.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Every assertion that needs a real bundle shares this one build.
|
||||
//
|
||||
// Not just for speed: `bun test` interferes with Bun.build's module reads once
|
||||
// several build calls have run across test files in the same process, while the
|
||||
// same calls succeed repeatedly outside the runner. Production is unaffected —
|
||||
// the dev server's rebuild loop was verified separately — but tests must keep
|
||||
// their build count low to stay reliable in the full suite.
|
||||
let dir: string;
|
||||
let result: IslandBuildResult;
|
||||
let bundles: string[];
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = outDir("shared");
|
||||
result = await buildIslands({
|
||||
islands: [
|
||||
{ name: "CounterA", sourcePath: COUNTER },
|
||||
{ name: "CounterB", sourcePath: COUNTER },
|
||||
],
|
||||
outDir: dir,
|
||||
});
|
||||
bundles = readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".js"))
|
||||
.map((file) => readFileSync(join(dir, file), "utf8"));
|
||||
});
|
||||
|
||||
test("a route with no islands ships zero framework JavaScript", async () => {
|
||||
const empty = outDir("nojs");
|
||||
const none = await buildIslands({ islands: [], outDir: empty });
|
||||
|
||||
expect(none.assets).toHaveLength(0);
|
||||
expect(none.sharedChunks).toHaveLength(0);
|
||||
expect(readdirSync(empty)).toHaveLength(0);
|
||||
expect(routeNeedsIslands([])).toBe(false);
|
||||
});
|
||||
|
||||
test("a page with multiple islands ships React exactly once", () => {
|
||||
// React's internals must appear in at most one emitted file — the shared
|
||||
// chunk. If splitting regresses, every island inlines its own copy.
|
||||
const withReactInternals = bundles.filter(
|
||||
(source) => source.includes("REACT_ELEMENT_TYPE") || source.includes("react.development"),
|
||||
);
|
||||
|
||||
expect(result.assets).toHaveLength(2);
|
||||
expect(withReactInternals.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("two islands sharing one source get distinct, correctly named assets", () => {
|
||||
// Passing component sources as entrypoints deduped them, so the second island
|
||||
// silently lost its bundle and names could bind to the wrong output.
|
||||
expect(result.assets.map((asset) => asset.name).sort()).toEqual(["CounterA", "CounterB"]);
|
||||
expect(new Set(result.assets.map((asset) => asset.path)).size).toBe(2);
|
||||
});
|
||||
|
||||
test("a real island builds against React and never pulls in the WRNexus renderer", () => {
|
||||
const built = bundles.join("\n");
|
||||
|
||||
expect(built).not.toContain("wrnexus");
|
||||
expect(built).not.toContain("react-dom/server");
|
||||
expect(built).toContain("useState");
|
||||
});
|
||||
|
||||
test("the generated entry directory is not left behind in the output", () => {
|
||||
expect(readdirSync(dir)).not.toContain(".entries");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
# @wrnexus/react
|
||||
|
||||
Opt-in React islands for WRNexusJS: mount npm React components inside server-rendered `.wrn` pages without adopting React as the framework's rendering model.
|
||||
|
||||
Import a `.tsx` component in a `.wrn` script block and use it as an element. The compiler emits a `data-wrn-island` placeholder instead of a server render, and this package's runtime mounts it in the browser with `createRoot`. Islands are client-only, each mounts inside its own error boundary, and roots are disposed on client-side navigation.
|
||||
|
||||
`react` and `react-dom` are optional peer dependencies, so apps that use no islands ship no React. A route with no islands still ships zero framework JavaScript.
|
||||
|
||||
Use `useWrnStore(name, selector?)` to read a WRNexus store from inside an island and `useWrnActions(name)` to write to it. Writes belong in event handlers or effects, never during render.
|
||||
@@ -34,13 +34,28 @@ function loadTypeScript() {
|
||||
|
||||
const ts = loadTypeScript();
|
||||
|
||||
// React island modules are not used by the editor: island-bundle.ts calls
|
||||
// Bun.build and island-codegen.ts imports @wrnexus/core, neither of which
|
||||
// exists in this Node-only bundle. They are unreachable from the editor entry,
|
||||
// so excluding them keeps Bun-only code out of the extension entirely.
|
||||
const EDITOR_EXCLUDED = ["island-bundle.ts", "island-codegen.ts"];
|
||||
function isEditorExcluded(path) {
|
||||
return EDITOR_EXCLUDED.some((name) => path.endsWith(name));
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) files.push(...walk(path));
|
||||
else if (stat.isFile() && path.endsWith(".ts") && !path.endsWith(".test.ts")) files.push(path);
|
||||
else if (
|
||||
stat.isFile() &&
|
||||
path.endsWith(".ts") &&
|
||||
!path.endsWith(".test.ts") &&
|
||||
!isEditorExcluded(path)
|
||||
)
|
||||
files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -69,7 +84,6 @@ for (const file of sourceFiles) {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
moduleResolution: ts.ModuleResolutionKind.Node10,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
sourceMap: false,
|
||||
|
||||
Reference in New Issue
Block a user