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");
-1
View File
@@ -7,7 +7,6 @@
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*",
"@wrnexus/syntax": "workspace:*",
+68 -1
View File
@@ -22,6 +22,12 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
import { generateStoreModule } from "./store-codegen.ts";
import { optimizeAst } from "./analysis.ts";
import {
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
import { browserModuleRequired } from "./client-codegen.ts";
interface RenderBinding {
@@ -43,6 +49,48 @@ interface NamedDataBinding extends RenderBinding {
mode: DataMode;
}
/**
* Island names for the file currently being generated.
*
* Codegen is a synchronous single pass, so a module-scoped set avoids threading
* an extra parameter through every render function. Always reset in generate().
*/
let currentIslands: ReadonlySet<string> = new Set<string>();
/**
* Builds the island placeholder for a component tag that was imported from a
* .tsx file. Returns null for ordinary .wrn components.
*/
function islandMarkerFor(node: {
tag: string;
attrs: Array<{ name: string; value?: string }>;
}): string | null {
if (!currentIslands.has(node.tag)) return null;
const directives = node.attrs
.map((attr) => attr.name)
.filter((name) => name.startsWith("client:"));
const props: Record<string, unknown> = {};
for (const attr of node.attrs) {
if (attr.name.startsWith("client:")) continue;
const parsed = islandPropValue(attr.value);
if ("dynamic" in parsed) {
throw new Error(
`WRN-ISLAND-PROPS: Island '${node.tag}' received a runtime expression for prop '${attr.name}'. ` +
`Island props are serialized at build time, so they must be literal values ` +
`(for example start={3} or title="Revenue"), not ${attr.value}.`,
);
}
props[attr.name] = parsed.value;
}
const serialized = serializeIslandProps(node.tag, props);
if ("diagnostic" in serialized) throw new Error(serialized.diagnostic.message);
return renderIslandMarker({
name: node.tag,
strategy: parseIslandStrategy(directives),
propsJson: serialized.json,
});
}
function isComponentTag(tag: string): boolean {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
}
@@ -448,6 +496,8 @@ function renderLoopBody(node: ViewNode): string {
}
if (componentTag) {
const island = islandMarkerFor(node);
if (island) return escLit(island);
return (
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
@@ -704,6 +754,9 @@ function renderPageComponentInvocation(
loops: string[],
reactive: PageReactive | null,
): string {
const island = islandMarkerFor(node);
if (island) return island;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
@@ -1255,7 +1308,21 @@ function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet<s
}
}
export function generate(ast: PageAst): string {
export interface GenerateOptions {
/** Local names bound to .tsx island imports in this file. */
islands?: ReadonlySet<string>;
}
export function generate(ast: PageAst, options: GenerateOptions = {}): string {
currentIslands = options.islands ?? new Set<string>();
try {
return generateInner(ast);
} finally {
currentIslands = new Set<string>();
}
}
function generateInner(ast: PageAst): string {
ast = optimizeAst(ast).ast;
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
if (ast.kind === "component" || ast.kind === "layout") {
+12
View File
@@ -126,3 +126,15 @@ export function compile(source: string, filePath = "<inline .wrn>"): CompileResu
}
export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts";
export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts";
export {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
export type { IslandDiagnostic, IslandStrategy } from "./island-codegen.ts";
export { assertReactAvailable, buildIslands, generateIslandEntry } from "./island-bundle.ts";
export type { IslandBuildResult, IslandInput } from "./island-bundle.ts";
export { routeNeedsIslands } from "./analysis.ts";
+25 -1
View File
@@ -82,6 +82,16 @@ export function assertReactAvailable(
export async function buildIslands(input: {
islands: IslandInput[];
outDir: string;
/**
* App root used to resolve the island mount runtime. When given, the runtime
* is emitted as `runtime.js` in the SAME build as the islands.
*
* This is not a convenience: building the runtime separately gives it its own
* copy of React, and a component rendered by one copy while importing hooks
* from another fails with "Cannot read properties of null (reading
* 'useState')". One build with splitting keeps React in a single shared chunk.
*/
appRoot?: string;
}): Promise<IslandBuildResult> {
if (input.islands.length === 0) return { assets: [], sharedChunks: [] };
@@ -100,9 +110,22 @@ export async function buildIslands(input: {
writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
}
const entrypoints = input.islands.map((island) => join(entryDir, `${island.name}.tsx`));
if (input.appRoot) {
const resolveFrom = createRequire(join(input.appRoot, "package.json"));
const runtimeEntry = join(entryDir, "runtime.ts");
writeFileSync(
runtimeEntry,
`export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))};
`,
"utf8",
);
entrypoints.push(runtimeEntry);
}
try {
const result = await Bun.build({
entrypoints: input.islands.map((island) => join(entryDir, `${island.name}.tsx`)),
entrypoints,
outdir: input.outDir,
target: "browser",
format: "esm",
@@ -123,6 +146,7 @@ export async function buildIslands(input: {
// 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 (stem === "runtime") continue;
if (!islandNames.has(stem)) continue;
assets.push({
name: stem,
+60 -1
View File
@@ -1,4 +1,23 @@
import { escapeHtml, isSafeIslandName } from "@wrnexus/core";
// Implemented locally rather than imported from @wrnexus/core: compiler modules
// are bundled into the Node-only VS Code extension, which contains no other
// packages, so a runtime import of core would break the editor compiler.
const HTML_ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]!);
}
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
function isSafeIslandName(name: string): boolean {
return SAFE_ISLAND_NAME.test(name);
}
export type IslandStrategy = "only" | "load" | "visible" | "idle";
@@ -35,6 +54,28 @@ function unsupportedProp(value: unknown): boolean {
return Object.values(value as Record<string, unknown>).some(unsupportedProp);
}
/**
* Interprets an island attribute value with JSX semantics.
*
* `title="Revenue"` is a string, `start={3}` is a number, `flag` alone is
* `true`. Without this every prop arrives as a string, so `start={3}` would be
* `"3"` and arithmetic in the island silently concatenates.
*
* Returns `dynamic` for a `{…}` value that is not JSON: such expressions are
* evaluated at runtime and cannot cross the serialization boundary.
*/
export function islandPropValue(raw: string | undefined): { value: unknown } | { dynamic: string } {
if (raw === undefined || raw === "") return { value: true };
const expression = /^\{([\s\S]*)\}$/.exec(raw);
if (!expression) return { value: raw };
const inner = expression[1]!.trim();
try {
return { value: JSON.parse(inner) as unknown };
} catch {
return { dynamic: inner };
}
}
export function serializeIslandProps(
componentName: string,
props: Record<string, unknown>,
@@ -80,3 +121,21 @@ export function renderIslandMarker(input: {
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`
);
}
/**
* Local binding names introduced by island imports.
*
* Codegen sees only component tag names, so it needs the set of names that came
* from `.tsx` imports to tell an island apart from a `.wrn` component.
*/
export function islandNamesFrom(
imports: Array<{ kind?: "island"; declaration: { defaultImport?: string } }>,
): Set<string> {
const names = new Set<string>();
for (const entry of imports) {
if (entry.kind !== "island") continue;
const local = entry.declaration.defaultImport;
if (local) names.add(local);
}
return names;
}
@@ -12,9 +12,9 @@ test("a route with an island import needs client JavaScript", () => {
});
test("a route with no island imports stays zero-JS", () => {
expect(routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }])).toBe(
false,
);
expect(
routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }]),
).toBe(false);
});
test("an empty import list stays zero-JS", () => {
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test";
import {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
@@ -71,3 +73,28 @@ test("renders a marker with escaped props", () => {
expect(html).toContain("&lt;");
expect(html).toContain("&quot;");
});
test("collects local binding names from island imports only", () => {
const names = islandNamesFrom([
{ kind: "island", declaration: { defaultImport: "Chart" } },
{ declaration: { defaultImport: "Card" } },
{ kind: "island", declaration: {} },
]);
expect([...names]).toEqual(["Chart"]);
});
test("island prop values follow JSX semantics, not raw attribute strings", () => {
// Without this, start={3} arrives as the string "3" and arithmetic inside the
// island concatenates: 3 -> "31" -> "311".
expect(islandPropValue("{3}")).toEqual({ value: 3 });
expect(islandPropValue("{true}")).toEqual({ value: true });
expect(islandPropValue("{[1,2]}")).toEqual({ value: [1, 2] });
expect(islandPropValue('{"a"}')).toEqual({ value: "a" });
expect(islandPropValue("Revenue")).toEqual({ value: "Revenue" });
expect(islandPropValue(undefined)).toEqual({ value: true });
});
test("a runtime expression prop is reported as dynamic", () => {
expect(islandPropValue("{someVariable}")).toEqual({ dynamic: "someVariable" });
expect(islandPropValue("{fn()}")).toEqual({ dynamic: "fn()" });
});
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const SOURCE = `page Home {
view {
<Chart title="Revenue" client:visible />
<Card>plain</Card>
}
}`;
test("an island tag emits an island marker instead of a component mount", () => {
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
expect(out).toContain("data-wrn-island=");
expect(out).toContain("Chart");
expect(out).toContain('data-wrn-island-strategy="visible"');
// Non-island components still mount the normal way.
expect(out).toContain('data-component="Card"');
});
test("without the island set the same tag stays a normal component", () => {
const out = generate(parse(SOURCE));
expect(out).not.toContain("data-wrn-island=");
expect(out).toContain('data-component="Chart"');
});
test("island props are serialized into the marker", () => {
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
expect(out).toContain("Revenue");
});
test("numeric and boolean island props keep their types through the marker", () => {
const source = `page Home {
view { <Chart start={3} live={true} title="Revenue" /> }
}`;
const out = generate(parse(source), { islands: new Set(["Chart"]) });
expect(out).toContain("&quot;start&quot;:3");
expect(out).toContain("&quot;live&quot;:true");
expect(out).toContain("&quot;title&quot;:&quot;Revenue&quot;");
});
test("a runtime expression prop fails the build with WRN-ISLAND-PROPS", () => {
const source = `page Home {
state count = 1
view { <Chart value={count} /> }
}`;
expect(() => generate(parse(source), { islands: new Set(["Chart"]) })).toThrow(
/WRN-ISLAND-PROPS/,
);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.8.32",
"version": "0.8.33",
"type": "module",
"main": "src/index.ts",
"exports": {
+68 -1
View File
@@ -19,6 +19,8 @@ import {
compile,
generate,
generateTargets,
buildIslands,
islandNamesFrom,
resolveWrnImports,
type PageAst,
type ViewNode,
@@ -126,6 +128,69 @@ function importOptionsHash(file: string): string {
return hashPath(JSON.stringify({ ...options, aliases }));
}
/** Island bundles already built this dev session, keyed by resolved source. */
const builtIslands = new Map<string, string>();
let islandRuntimeBuilt = false;
/**
* Builds island bundles on demand in dev and registers them under
* /__wrnexus/island/. Without this the browser bootstrap 404s and no island
* ever mounts.
*/
async function ensureIslandArtifacts(
islands: Array<{ name: string; sourcePath: string }>,
appRoot: string,
): Promise<void> {
if (islands.length === 0) return;
const outDir = join(appRoot, ".wrnexus", "island");
const pending = islands.filter((island) => builtIslands.get(island.name) !== island.sourcePath);
if (pending.length === 0 && islandRuntimeBuilt) return;
// The runtime is built alongside the islands so they share one React copy.
const result = await buildIslands({
islands: pending.length ? pending : islands,
outDir,
appRoot,
});
registerIslandArtifact("/__wrnexus/island/runtime.js", join(outDir, "runtime.js"));
islandRuntimeBuilt = true;
for (const asset of result.assets) {
registerIslandArtifact(`/__wrnexus/island/${asset.name}.js`, asset.path);
const source = pending.find((island) => island.name === asset.name)?.sourcePath;
if (source) builtIslands.set(asset.name, source);
}
for (const chunk of result.sharedChunks) {
registerIslandArtifact(`/__wrnexus/island/${basename(chunk)}`, chunk);
}
}
/**
* Island imports in this file: names so codegen emits placeholders instead of
* component mounts, and sources so the bundles can be built.
*/
function islandsForFile(
ast: PageAst,
importer: string,
): { names: Set<string>; inputs: Array<{ name: string; sourcePath: string }> } {
if (!ast.structuredImports.length) return { names: new Set(), inputs: [] };
const root = projectRootForFile(importer);
const importOptions = compileImportOptions.get(resolve(root)) ?? {
mode: "compatible" as const,
aliases: { "@": "./app" },
autoImport: true,
};
const resolved = resolveWrnImports(ast.structuredImports, importer, {
appRoot: root,
mode: importOptions.mode,
aliases: importOptions.aliases,
});
const inputs = resolved
.filter((entry) => entry.kind === "island" && entry.resolved && entry.declaration.defaultImport)
.map((entry) => ({ name: entry.declaration.defaultImport!, sourcePath: entry.resolved! }));
return { names: islandNamesFrom(resolved), inputs };
}
function rewriteArtifactImports(
code: string,
ast: PageAst,
@@ -490,11 +555,13 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
const result = compile(source, file);
validateConfiguredImports(source, result.ast, file);
const ast = await devCompilerPipeline!.transformAst(result.ast, file);
const { names: islands, inputs: islandInputs } = islandsForFile(ast, file);
await ensureIslandArtifacts(islandInputs, projectRootForFile(file));
const targets = generateTargets(ast);
mkdirSync(cacheDir, { recursive: true });
const browserPath = `/__wrnexus/client/${stem}.mjs`;
const outputs = {
main: `// compiled from .wrn\n${generate(ast)}`.replaceAll(
main: `// compiled from .wrn\n${generate(ast, { islands })}`.replaceAll(
"__WRNEXUS_CLIENT_MODULE__",
browserPath,
),
+2 -2
View File
@@ -674,10 +674,10 @@ export const HMR_CLIENT_JS = `
var i18nScript = Array.prototype.find.call(
doc.querySelectorAll("script:not([src])"),
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
);
if (i18nScript) {
var i18nMatch = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(i18nScript.textContent || "").trim());
var i18nMatch = /^window[.]__wrnI18n=([^]*);[ \t\r\n]*$/.exec(String(i18nScript.textContent || "").trim());
if (i18nMatch) {
try {
var incomingI18n = JSON.parse(i18nMatch[1]);
@@ -19,6 +19,9 @@ export function collectScripts(
) {
scripts.push("/__wrnexus/reactive.js");
}
// Islands ship their own bootstrap, not the reactive runtime — a page whose
// only interactivity is an island must not pull in WRNexus's client runtime.
if (/\bdata-wrn-island=/.test(body)) scripts.push("/__wrnexus/islands.js");
if (/\bdata-wrn-action=/.test(body)) scripts.push("/__wrnexus/actions.js");
if (
/\bdata-wrn-theme-(toggle|set)\b/.test(body) ||
@@ -0,0 +1,73 @@
import { afterAll, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { compile, generate, islandNamesFrom, resolveWrnImports } from "@wrnexus/compiler";
import { collectScripts } from "../src/script-selection.ts";
const created: string[] = [];
afterAll(() => {
for (const dir of created) rmSync(dir, { recursive: true, force: true });
});
function app() {
const root = mkdtempSync(join(process.cwd(), ".island-e2e-"));
created.push(root);
mkdirSync(join(root, "app"), { recursive: true });
writeFileSync(
join(root, "app", "Chart.tsx"),
`export default function Chart({ title }: { title: string }) { return <div>{title}</div>; }`,
);
writeFileSync(join(root, "app", "Card.wrn"), `component Card { view { <div>card</div> } }`);
return root;
}
test("a .wrn importing a .tsx emits an island marker and requests the bootstrap", () => {
const root = app();
const page = join(root, "app", "page.wrn");
const source = [
'import Chart from "./Chart"',
'import Card from "./Card"',
"page Home {",
" view {",
' <Chart title="Revenue" client:visible />',
" <Card />",
" }",
"}",
].join("\n");
writeFileSync(page, source);
const ast = compile(source, page).ast;
const islands = islandNamesFrom(
resolveWrnImports(ast.structuredImports, page, { appRoot: root }),
);
// Only the .tsx import is an island; the .wrn component is not.
expect([...islands]).toEqual(["Chart"]);
const out = generate(ast, { islands });
expect(out).toContain('data-wrn-island="Chart"');
expect(out).toContain('data-wrn-island-strategy="visible"');
expect(out).toContain('data-component="Card"');
// Rendered island markup must pull in the island bootstrap.
expect(collectScripts('<div data-wrn-island="Chart"></div>')).toContain("/__wrnexus/islands.js");
});
test("a page with no .tsx imports emits no island markup and no island script", () => {
const root = app();
const page = join(root, "app", "plain.wrn");
const source = ['import Card from "./Card"', "page Plain {", " view { <Card /> }", "}"].join(
"\n",
);
writeFileSync(page, source);
const ast = compile(source, page).ast;
const islands = islandNamesFrom(
resolveWrnImports(ast.structuredImports, page, { appRoot: root }),
);
expect(islands.size).toBe(0);
const out = generate(ast, { islands });
expect(out).not.toContain("data-wrn-island");
expect(collectScripts(out)).not.toContain("/__wrnexus/islands.js");
});
@@ -0,0 +1,16 @@
import { expect, test } from "bun:test";
import { collectScripts } from "../src/script-selection.ts";
test("island markup pulls in the island bootstrap", () => {
const scripts = collectScripts(`<div data-wrn-island="Chart"></div>`);
expect(scripts).toContain("/__wrnexus/islands.js");
});
test("markup without islands ships no island bootstrap", () => {
expect(collectScripts(`<p>plain server html</p>`)).toEqual([]);
});
test("islands alone do not pull in the reactive runtime", () => {
const scripts = collectScripts(`<div data-wrn-island="Chart"></div>`);
expect(scripts).not.toContain("/__wrnexus/reactive.js");
});
@@ -22,7 +22,10 @@ function customNotFoundRuntime() {
router: buildRouter(app),
loadModule: async (file) =>
file.includes(`${join("api", "404")}.ts`)
? { GET: () => Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }) }
? {
GET: () =>
Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }),
}
: { default: () => "<main><h1>That page is gone</h1></main>" },
getMiddleware: async () => [],
assets: { serve: async () => null },
+8 -3
View File
@@ -5,15 +5,20 @@
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./runtime": "./src/runtime-source.ts"
"./runtime": "./src/runtime-source.ts",
"./browser": "./src/browser.ts"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependenciesMeta": {
"react": { "optional": true },
"react-dom": { "optional": true }
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
},
"dependencies": {
"@wrnexus/store": "workspace:*"
+8
View File
@@ -0,0 +1,8 @@
/**
* Browser entry for the island mount runtime, served at
* `/__wrnexus/island/runtime.js` and imported by the bootstrap only when a
* `data-wrn-island` marker is present.
*/
export { islandRootCount, mountIslands, remountIslands, unmountIslands } from "./island-runtime.ts";
export type { MountOptions } from "./island-runtime.ts";
export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts";
+1 -1
View File
@@ -41,4 +41,4 @@ export class IslandErrorBoundary extends Component<
</div>
);
}
}
}
+6 -1
View File
@@ -1,7 +1,12 @@
import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, createElement } from "react";
import { islandRootCount, mountIslands, remountIslands, unmountIslands } from "../src/island-runtime.ts";
import {
islandRootCount,
mountIslands,
remountIslands,
unmountIslands,
} from "../src/island-runtime.ts";
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;