feat(islands): wire islands end to end

The island pieces existed but nothing connected .wrn compilation to island
emission. Now:

- codegen emits a data-wrn-island placeholder for component tags bound to
  .tsx imports, keeping .wrn components on the normal mount path
- the dev pipeline and static build resolve island imports, thread the
  names into codegen, and build the bundles
- collectScripts adds /__wrnexus/islands.js only when island markup is
  present, so island-free pages still ship nothing
- island routes classify as static-interactive via hasIslands

Three bugs found by driving a real page in the browser:

1. The mount runtime was never built anywhere, so the bootstrap 404'd and
   no island mounted.
2. Building the runtime separately from the islands gave each its own copy
   of React: "Cannot read properties of null (reading 'useState')". The
   runtime is now an entrypoint of the same build so React stays in one
   shared chunk. The existing single-React test only compared bundles
   within one build and could not see across build boundaries.
3. Island props arrived as attribute strings, so start={3} was "3" and
   incrementing produced "31" then "311". Props now follow JSX semantics:
   {…} parses as JSON, quoted values stay strings, and a runtime
   expression is a WRN-ISLAND-PROPS build error rather than a silent
   wrong value.

island-codegen.ts no longer imports @wrnexus/core. Compiler modules are
bundled into the Node-only VS Code extension, which contains no other
packages, so a runtime import of core broke the editor compiler; the two
helpers are implemented locally and the core dependency is dropped again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 16:16:03 +05:30
co-authored by Claude Opus 5
parent 442a3106ed
commit 17aa3b98eb
36 changed files with 902 additions and 75 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.34",
"version": "0.8.36",
"type": "module",
"main": "src/index.ts",
"exports": {
+51 -10
View File
@@ -28,6 +28,9 @@ import { getIslandRuntime } from "@wrnexus/react/runtime";
import {
analyzeRuntimeImports,
analyzeRuntimeRequirements,
assertReactAvailable,
buildIslands,
islandNamesFrom,
assertValidAst,
generate,
generateTargets,
@@ -185,6 +188,8 @@ export async function runBuild(appRoot: string): Promise<void> {
const clientFiles = new Map<string, string>();
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
const partialStaticFiles = new Set<string>();
/** Island component name -> resolved .tsx source, collected across all routes. */
const discoveredIslands = new Map<string, string>();
const compileWrn = async (file: string): Promise<void> => {
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
const source = readFileSync(file, "utf8");
@@ -209,7 +214,25 @@ export async function runBuild(appRoot: string): Promise<void> {
assertValidAst(ast, { file, accessibility: true });
ast = await pluginRunner.transformAst(ast, file);
if (ast.renderMode === "partial-static") partialStaticFiles.add(file);
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
// Islands must be known before codegen (to emit markers instead of
// component mounts) and before route analysis (an island route ships JS).
const fileImports = resolveWrnImports(ast.structuredImports, file, {
appRoot: root,
mode: config.imports?.mode ?? "compatible",
aliases: config.imports?.aliases,
});
const fileIslands = islandNamesFrom(fileImports);
for (const imported of fileImports) {
if (imported.kind === "island" && imported.resolved) {
discoveredIslands.set(imported.declaration.defaultImport!, imported.resolved);
}
}
runtimeAnalysis.set(
file,
analyzeRuntimeRequirements(ast, { hasIslands: fileIslands.size > 0 }),
);
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
@@ -223,10 +246,11 @@ export async function runBuild(appRoot: string): Promise<void> {
try {
const targets = generateTargets(ast);
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
clientUrl,
);
let code =
`// compiled from ${fwd(relative(root, file))}\n${generate(ast, { islands: fileIslands })}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
clientUrl,
);
let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`;
code = await pluginRunner.transformCode(code, file);
browserCode = await pluginRunner.transformCode(browserCode, file);
@@ -249,11 +273,7 @@ export async function runBuild(appRoot: string): Promise<void> {
);
}
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
appRoot: root,
mode: config.imports?.mode ?? "compatible",
aliases: config.imports?.aliases,
});
const resolvedImports = fileImports;
for (const imported of resolvedImports) {
if (imported.diagnostic?.severity === "error") {
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
@@ -505,6 +525,26 @@ export async function runBuild(appRoot: string): Promise<void> {
assetHash.update(islandCode);
console.log(`✓ Islands: ${islandsPath}`);
// Island bundles are emitted only when a route actually imported a .tsx, so a
// build with no islands produces no React and no island assets at all.
const islandsDir = join(distDir, "island");
if (discoveredIslands.size > 0) {
const missingReact = assertReactAvailable(root);
if (missingReact) {
throw new Error(`${missingReact.code}: ${missingReact.message}`);
}
mkdirSync(islandsDir, { recursive: true });
const islandBuild = await buildIslands({
islands: [...discoveredIslands].map(([name, sourcePath]) => ({ name, sourcePath })),
outDir: islandsDir,
appRoot: root,
});
for (const asset of islandBuild.assets) assetHash.update(asset.hash);
console.log(
`✓ Island bundles: ${islandBuild.assets.length} (${islandBuild.sharedChunks.length} shared chunks)`,
);
}
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
const theme = resolveThemeConfig(config.theme, config.cookies);
const themeCss = renderThemeCss(theme);
@@ -747,6 +787,7 @@ await createProductionServer(
reactivePath: join(import.meta.dir, "reactive.js"),
controllersPath: join(import.meta.dir, "controllers.js"),
clientModulesDir: join(import.meta.dir, "client"),
islandsDir: join(import.meta.dir, "island"),
themePath: join(import.meta.dir, "theme.css"),
themeAssetsDir: join(import.meta.dir, "theme"),
themeJsPath: join(import.meta.dir, "theme.js"),
+9 -9
View File
@@ -121,16 +121,16 @@ Thumbs.db
},
"devDependencies": {
"@wrnexus/cli": "${cliVersion}",
"@eslint/js": "^9.0.0",
"@iconify-json/lucide": "^1.2.118",
"@eslint/js": "^10.0.1",
"@iconify-json/lucide": "^1.2.123",
"@iconify/tailwind4": "^1.2.3",
"@tailwindcss/cli": "^4.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
"@tailwindcss/cli": "^4.3.3",
"@types/bun": "^1.3.14",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
`,
+2 -2
View File
@@ -92,7 +92,7 @@ export async function generateMobile(appRoot: string, options: MobileOptions = {
devDependencies: {
"@capacitor/cli": "^8.0.0",
"@capacitor/assets": "^3.0.0",
typescript: "^5.5.0",
typescript: "^6.0.3",
},
};
@@ -237,7 +237,7 @@ function generateNativeMobile(
"react-native-safe-area-context": "^5.6.0",
"react-native-screens": "^4.23.0",
},
devDependencies: { "@types/react": "^19.2.0", typescript: "^5.9.0" },
devDependencies: { "@types/react": "^19.2.0", typescript: "^6.0.3" },
};
const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`;
const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api<T>(path: string, init?: RequestInit): Promise<T> {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise<T>;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`;
+1 -1
View File
@@ -53,7 +53,7 @@ export function generateSystem(rootDir: string, input: string): string[] {
"@wrnexus/core": "workspace:*",
"@wrnexus/plugin": "workspace:*",
},
devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" },
devDependencies: { "@types/bun": "^1.3.14", typescript: "^6.0.3" },
wrnexus: {
plugin: { plugin: "./src/plugin.ts", export: "default", factory: true },
},
+8 -8
View File
@@ -13,7 +13,7 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { scaffoldApp } from "./create.ts";
import { scaffoldApp, scaffoldFrameworkRange } from "./create.ts";
import { currentCliVersion } from "./update-notifier.ts";
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";
@@ -88,12 +88,12 @@ export const workspaceFiles = (name: string): Record<string, string> => ({
},
"devDependencies": {
"@wrnexus/cli": "${frameworkVersion}",
"@eslint/js": "^9.0.0",
"@types/bun": "latest",
"eslint": "^9.0.0",
"prettier": "latest",
"typescript": "^5.5.0",
"typescript-eslint": "latest"
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.14",
"eslint": "^10.8.1",
"prettier": "^3.9.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0"
}
}
`,
@@ -250,7 +250,7 @@ export default tseslint.config(
"test": "bun test"
},
"dependencies": {
"@wrnexus/pubsub": "${frameworkVersion}"
"@wrnexus/pubsub": "${scaffoldFrameworkRange}"
}
}
`,
+1 -1
View File
@@ -147,7 +147,7 @@ test("scaffoldApp uses compatible framework packages and pins the current CLI",
}
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3");
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118");
expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.123");
const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8");
expect(globalCss).toContain('@plugin "@iconify/tailwind4";');
expect(globalCss).toContain('"Plus Jakarta Sans"');
@@ -69,7 +69,9 @@ export default {
}
if (port === undefined) {
throw new Error(`production server failed to start: ${await new Response(proc.stderr).text()}`);
throw new Error(
`production server failed to start: ${await new Response(proc.stderr).text()}`,
);
}
const page = await fetch(`http://127.0.0.1:${port}/`);
+3 -2
View File
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join } from "node:path";
import { currentCliVersion } from "../src/update-notifier.ts";
import { scaffoldFrameworkRange } from "../src/create.ts";
import {
addWorkspaceApp,
insertWorkspaceApp,
@@ -36,14 +37,14 @@ test("workspace environments preserve runtime and HMR policy", () => {
expect(resolved.apps[0]?.publicOrigin).toBe("https://www.staging.example.com");
});
test("workspace templates pin the running framework release", () => {
test("workspace templates pin the CLI and use compatible independent package ranges", () => {
const files = workspaceFiles("acme");
const rootPackage = JSON.parse(files["package.json"]!);
const sharedPackage = JSON.parse(files["packages/shared/package.json"]!);
const version = currentCliVersion();
expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version);
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(version);
expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(scaffoldFrameworkRange);
expect(files["README.md"]).toContain("http://127.0.0.1:3000");
expect(files["README.md"]).toContain("internal gateway targets");
expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production");