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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.8.34",
|
||||
"version": "0.8.36",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+51
-10
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
Reference in New Issue
Block a user