diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json
index b6ce1e94..77ca4cc2 100644
--- a/docs/public-api-0.8.json
+++ b/docs/public-api-0.8.json
@@ -973,6 +973,10 @@
"EffectBlock",
"EventDecl",
"FormatWrnOptions",
+ "IslandBuildResult",
+ "IslandDiagnostic",
+ "IslandInput",
+ "IslandStrategy",
"LexError",
"Lexer",
"LoadBlock",
@@ -999,7 +1003,9 @@
"analyzeOptimizations",
"analyzeRuntimeImports",
"analyzeRuntimeRequirements",
+ "assertReactAvailable",
"assertValidAst",
+ "buildIslands",
"compilationKey",
"compile",
"compileNativeWrnFile",
@@ -1015,19 +1021,26 @@
"generate",
"generateBrowserModule",
"generateDeclarations",
+ "generateIslandEntry",
"generateNative",
"generateServerFunctionsModule",
"generateStoreBrowserModule",
"generateStoreModule",
"generateTargets",
"inferredRuntimeType",
+ "islandNamesFrom",
+ "islandPropValue",
"optimizeAst",
"parse",
+ "parseIslandStrategy",
+ "renderIslandMarker",
"resolveWrnImport",
"resolveWrnImports",
+ "routeNeedsIslands",
"rpcManifest",
"runtimeCapabilities",
- "runtimeTypeOf"
+ "runtimeTypeOf",
+ "serializeIslandProps"
]
},
"@wrnexus/content": {
@@ -2279,6 +2292,16 @@
"useWrnActions",
"useWrnStore"
],
+ "./browser": [
+ "MountOptions",
+ "islandRootCount",
+ "mountIslands",
+ "remountIslands",
+ "setStoreResolver",
+ "unmountIslands",
+ "useWrnActions",
+ "useWrnStore"
+ ],
"./runtime": [
"getIslandRuntime"
]
diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs
index c7e34baa..3c71ba67 100644
--- a/editors/vscode/src/compiler.cjs
+++ b/editors/vscode/src/compiler.cjs
@@ -1,7 +1,7 @@
"use strict";
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
-// WRN editor compiler source hash: 182fd799ca860d927879d4259c182ea61cbd89d913758cc9f690e1ae4a35d90f
-// WRN editor compiler generator hash: 2690208ba65bb00d9fea3e08cb3ab324cfda77792021cd46785814fadf41c1bc
+// WRN editor compiler source hash: 631914b0f75e27a95908eb3252604e0948abf27b50533ab01f0dd12b532a1020
+// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
const __path = __nodeRequire("node:path");
@@ -789,7 +789,46 @@ const types_ts_1 = require("./types.js");
const syntax_1 = require("@wrnexus/syntax");
const store_codegen_ts_1 = require("./store-codegen.js");
const analysis_ts_1 = require("./analysis.js");
+const island_codegen_ts_1 = require("./island-codegen.js");
const client_codegen_ts_1 = require("./client-codegen.js");
+/**
+ * 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 = new Set();
+/**
+ * Builds the island placeholder for a component tag that was imported from a
+ * .tsx file. Returns null for ordinary .wrn components.
+ */
+function islandMarkerFor(node) {
+ if (!currentIslands.has(node.tag))
+ return null;
+ const directives = node.attrs
+ .map((attr) => attr.name)
+ .filter((name) => name.startsWith("client:"));
+ const props = {};
+ for (const attr of node.attrs) {
+ if (attr.name.startsWith("client:"))
+ continue;
+ const parsed = (0, island_codegen_ts_1.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 = (0, island_codegen_ts_1.serializeIslandProps)(node.tag, props);
+ if ("diagnostic" in serialized)
+ throw new Error(serialized.diagnostic.message);
+ return (0, island_codegen_ts_1.renderIslandMarker)({
+ name: node.tag,
+ strategy: (0, island_codegen_ts_1.parseIslandStrategy)(directives),
+ propsJson: serialized.json,
+ });
+}
function isComponentTag(tag) {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
}
@@ -1137,6 +1176,9 @@ function renderLoopBody(node) {
escLit(""));
}
if (componentTag) {
+ const island = islandMarkerFor(node);
+ if (island)
+ return escLit(island);
return (escLit(`
") +
@@ -1330,6 +1372,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}${node.tag}>`;
}
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
+ const island = islandMarkerFor(node);
+ if (island)
+ return island;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
@@ -1796,7 +1841,16 @@ function markServerAsyncBoundaries(nodes, serverLoads) {
markServerAsyncBoundaries(node.children, serverLoads);
}
}
-function generate(ast) {
+function generate(ast, options = {}) {
+ currentIslands = options.islands ?? new Set();
+ try {
+ return generateInner(ast);
+ }
+ finally {
+ currentIslands = new Set();
+ }
+}
+function generateInner(ast) {
ast = (0, analysis_ts_1.optimizeAst)(ast).ast;
if (ast.kind === "global-store" || ast.kind === "page-store")
return (0, store_codegen_ts_1.generateStoreModule)(ast);
@@ -3184,7 +3238,7 @@ function resolveWrnImports(declarations, importer, options) {
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
*/
Object.defineProperty(exports, "__esModule", { value: true });
-exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
+exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
exports.compileNativeWrnFile = compileNativeWrnFile;
exports.compileWrnFile = compileWrnFile;
exports.compile = compile;
@@ -3280,6 +3334,281 @@ var cache_ts_1 = require("./cache.js");
Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } });
Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } });
Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } });
+var island_codegen_ts_1 = require("./island-codegen.js");
+Object.defineProperty(exports, "islandNamesFrom", { enumerable: true, get: function () { return island_codegen_ts_1.islandNamesFrom; } });
+Object.defineProperty(exports, "islandPropValue", { enumerable: true, get: function () { return island_codegen_ts_1.islandPropValue; } });
+Object.defineProperty(exports, "parseIslandStrategy", { enumerable: true, get: function () { return island_codegen_ts_1.parseIslandStrategy; } });
+Object.defineProperty(exports, "renderIslandMarker", { enumerable: true, get: function () { return island_codegen_ts_1.renderIslandMarker; } });
+Object.defineProperty(exports, "serializeIslandProps", { enumerable: true, get: function () { return island_codegen_ts_1.serializeIslandProps; } });
+var island_bundle_ts_1 = require("./island-bundle.js");
+Object.defineProperty(exports, "assertReactAvailable", { enumerable: true, get: function () { return island_bundle_ts_1.assertReactAvailable; } });
+Object.defineProperty(exports, "buildIslands", { enumerable: true, get: function () { return island_bundle_ts_1.buildIslands; } });
+Object.defineProperty(exports, "generateIslandEntry", { enumerable: true, get: function () { return island_bundle_ts_1.generateIslandEntry; } });
+var analysis_ts_2 = require("./analysis.js");
+Object.defineProperty(exports, "routeNeedsIslands", { enumerable: true, get: function () { return analysis_ts_2.routeNeedsIslands; } });
+
+},
+"packages/compiler/src/island-bundle.ts": function (module, exports, require, __filename, __dirname) {
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.generateIslandEntry = generateIslandEntry;
+exports.reactJsxPlugin = reactJsxPlugin;
+exports.assertReactAvailable = assertReactAvailable;
+exports.buildIslands = buildIslands;
+const node_crypto_1 = require("node:crypto");
+const node_fs_1 = require("node:fs");
+const node_module_1 = require("node:module");
+const node_path_1 = require("node:path");
+/**
+ * Generates the per-island browser entry.
+ *
+ * Never imports react-dom/server — islands are client-only.
+ */
+function generateIslandEntry(input) {
+ return [
+ "/** @jsxImportSource react */",
+ `import Component from ${JSON.stringify(input.sourcePath)};`,
+ `export const name = ${JSON.stringify(input.name)};`,
+ `export default Component;`,
+ "",
+ ].join("\n");
+}
+/**
+ * Compiles every island `.tsx` against React's JSX runtime.
+ *
+ * The repo's root tsconfig sets `jsxImportSource` to `@wrnexus/core`, so an
+ * island would otherwise compile to WRNexus's HTML-string renderer and
+ * silently never mount. A `@jsxImportSource` pragma applies only to the file
+ * that carries it, so putting one in the generated entry does nothing for the
+ * author's own component — the injection has to happen per source file, which
+ * is what this plugin does. App-authored islands stay plain `.tsx`.
+ */
+function reactJsxPlugin() {
+ return {
+ name: "wrnexus-island-jsx",
+ setup(build) {
+ 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",
+ };
+ });
+ },
+ };
+}
+function assertReactAvailable(appRoot) {
+ const require = (0, node_module_1.createRequire)((0, node_path_1.join)(appRoot, "package.json"));
+ try {
+ require.resolve("react");
+ require.resolve("react-dom");
+ return null;
+ }
+ catch {
+ return {
+ code: "WRN-ISLAND-REACT-MISSING",
+ severity: "error",
+ message: "This app imports a .tsx island but react and react-dom are not installed. " +
+ "Run: bun add react react-dom",
+ };
+ }
+}
+/**
+ * Bundles island entries. `splitting: true` is required so React is emitted
+ * once as a shared chunk rather than duplicated into every island.
+ */
+async function buildIslands(input) {
+ 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 = (0, node_path_1.join)(input.outDir, ".entries");
+ (0, node_fs_1.mkdirSync)(entryDir, { recursive: true });
+ const islandNames = new Set(input.islands.map((island) => island.name));
+ for (const island of input.islands) {
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
+ }
+ const entrypoints = input.islands.map((island) => (0, node_path_1.join)(entryDir, `${island.name}.tsx`));
+ if (input.appRoot) {
+ const resolveFrom = (0, node_module_1.createRequire)((0, node_path_1.join)(input.appRoot, "package.json"));
+ const runtimeEntry = (0, node_path_1.join)(entryDir, "runtime.ts");
+ (0, node_fs_1.writeFileSync)(runtimeEntry, `export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))};
+`, "utf8");
+ entrypoints.push(runtimeEntry);
+ }
+ try {
+ const result = await Bun.build({
+ entrypoints,
+ outdir: input.outDir,
+ target: "browser",
+ format: "esm",
+ splitting: true,
+ minify: true,
+ plugins: [reactJsxPlugin()],
+ });
+ if (!result.success) {
+ throw new AggregateError(result.logs, "Island bundling failed");
+ }
+ const assets = [];
+ const sharedChunks = [];
+ 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 = (0, node_path_1.basename)(output.path).replace(/\.js$/, "");
+ if (stem === "runtime")
+ continue;
+ if (!islandNames.has(stem))
+ continue;
+ assets.push({
+ name: stem,
+ hash: (0, node_crypto_1.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 {
+ (0, node_fs_1.rmSync)(entryDir, { recursive: true, force: true });
+ }
+}
+
+},
+"packages/compiler/src/island-codegen.ts": function (module, exports, require, __filename, __dirname) {
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.parseIslandStrategy = parseIslandStrategy;
+exports.islandPropValue = islandPropValue;
+exports.serializeIslandProps = serializeIslandProps;
+exports.renderIslandMarker = renderIslandMarker;
+exports.islandNamesFrom = islandNamesFrom;
+// 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 = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+};
+function escapeHtml(value) {
+ return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
+}
+const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
+function isSafeIslandName(name) {
+ return SAFE_ISLAND_NAME.test(name);
+}
+const STRATEGIES = {
+ "client:only": "only",
+ "client:load": "load",
+ "client:visible": "visible",
+ "client:idle": "idle",
+};
+function parseIslandStrategy(directives) {
+ for (const directive of directives) {
+ const match = STRATEGIES[directive];
+ if (match)
+ return match;
+ }
+ return "only";
+}
+function unsupportedProp(value) {
+ const type = typeof value;
+ if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") {
+ return true;
+ }
+ if (value === null || type !== "object")
+ return false;
+ if (Array.isArray(value))
+ return value.some(unsupportedProp);
+ const proto = Object.getPrototypeOf(value);
+ if (proto !== Object.prototype && proto !== null)
+ return true;
+ return Object.values(value).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.
+ */
+function islandPropValue(raw) {
+ 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) };
+ }
+ catch {
+ return { dynamic: inner };
+ }
+}
+function serializeIslandProps(componentName, props) {
+ const offenders = Object.entries(props)
+ .filter(([, value]) => unsupportedProp(value))
+ .map(([key]) => key);
+ if (offenders.length > 0) {
+ return {
+ diagnostic: {
+ code: "WRN-ISLAND-PROPS",
+ severity: "error",
+ message: `Island '${componentName}' received non-serializable prop(s): ${offenders.join(", ")}. ` +
+ `Island props cross a serialization boundary and must be JSON-safe ` +
+ `(no functions, symbols, bigints, undefined, or class instances).`,
+ },
+ };
+ }
+ return { json: JSON.stringify(props) };
+}
+function renderIslandMarker(input) {
+ // The name becomes a path segment when the browser fetches
+ // /__wrnexus/island/
.js, so reuse the framework's conservative charset
+ // rather than relying on escaping alone.
+ if (!isSafeIslandName(input.name)) {
+ throw new Error(`Island name '${input.name}' is not a safe identifier. ` +
+ `Island names may only contain letters, digits, underscores, and hyphens.`);
+ }
+ return (``);
+}
+/**
+ * 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.
+ */
+function islandNamesFrom(imports) {
+ const names = new Set();
+ for (const entry of imports) {
+ if (entry.kind !== "island")
+ continue;
+ const local = entry.declaration.defaultImport;
+ if (local)
+ names.add(local);
+ }
+ return names;
+}
},
"packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) {
diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs
index 1b0d8369..cc870af8 100644
--- a/editors/vscode/src/extension.bundle.cjs
+++ b/editors/vscode/src/extension.bundle.cjs
@@ -1,4 +1,4 @@
-// WRN editor extension source hash: 4d9518778cef65c0400da0e8be9ece65be85acbd581e3024d484b7cbd2fb8116
+// WRN editor extension source hash: 67bc974ee8e647ebce3dfc0ceaff2a1be56384fc16ed0334b89ab32d6bd590a8
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
diff --git a/examples/basic-app/app/pages/island-demo.wrn b/examples/basic-app/app/pages/island-demo.wrn
new file mode 100644
index 00000000..37c08369
--- /dev/null
+++ b/examples/basic-app/app/pages/island-demo.wrn
@@ -0,0 +1,21 @@
+import Counter from "../islands/Counter"
+
+// React island demo. Route: /island-demo
+//
+// Counter.tsx is a plain React component: the compiler emits an island
+// placeholder here instead of a server-rendered component mount, and the
+// browser mounts it with createRoot.
+page IslandDemo {
+ seo {
+ title = "Island demo"
+ description = "Mounts a React island inside a server-rendered WRNexus page."
+ canonical = "/island-demo"
+ }
+
+ view {
+
+ React island
+
+
+ }
+}
diff --git a/examples/basic-app/app/routes.gen.ts b/examples/basic-app/app/routes.gen.ts
index e51e67d3..86898346 100644
--- a/examples/basic-app/app/routes.gen.ts
+++ b/examples/basic-app/app/routes.gen.ts
@@ -9,6 +9,7 @@ export interface Routes {
"/client-only": Record;
"/dashboard": Record;
"/hello": Record;
+ "/island-demo": Record;
"/language-tools": Record;
"/layout": Record;
"/login": Record;
@@ -31,6 +32,7 @@ export interface RouteNames {
"client.only": "/client-only";
"dashboard": "/dashboard";
"hello": "/hello";
+ "island.demo": "/island-demo";
"language.tools": "/language-tools";
"layout": "/layout";
"login": "/login";
@@ -122,6 +124,7 @@ export function route(
"client.only": "/client-only",
"dashboard": "/dashboard",
"hello": "/hello",
+ "island.demo": "/island-demo",
"language.tools": "/language-tools",
"layout": "/layout",
"login": "/login",
diff --git a/examples/basic-app/app/types/wrnexus.generated.d.ts b/examples/basic-app/app/types/wrnexus.generated.d.ts
index 0a65aa11..537a1c99 100644
--- a/examples/basic-app/app/types/wrnexus.generated.d.ts
+++ b/examples/basic-app/app/types/wrnexus.generated.d.ts
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
: never;
type RealtimeMessage = T extends import("@wrnexus/core").RoomDefinition ? M : unknown;
type QueuePayload = T extends import("@wrnexus/queue").JobDefinition ? I : unknown;
- type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
+ type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 7dbff221..dea846f8 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
- "version": "0.8.34",
+ "version": "0.8.36",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts
index ba00ff6b..218a14bc 100644
--- a/packages/cli/src/build.ts
+++ b/packages/cli/src/build.ts
@@ -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 {
const clientFiles = new Map();
const runtimeAnalysis = new Map();
const partialStaticFiles = new Set();
+ /** Island component name -> resolved .tsx source, collected across all routes. */
+ const discoveredIslands = new Map();
const compileWrn = async (file: string): Promise => {
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
const source = readFileSync(file, "utf8");
@@ -209,7 +214,25 @@ export async function runBuild(appRoot: string): Promise {
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 {
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 {
);
}
- 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 {
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"),
diff --git a/packages/cli/src/create.ts b/packages/cli/src/create.ts
index 58ed9a26..58b8db27 100644
--- a/packages/cli/src/create.ts
+++ b/packages/cli/src/create.ts
@@ -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"
}
}
`,
diff --git a/packages/cli/src/mobile.ts b/packages/cli/src/mobile.ts
index 010e3f2a..a2f39483 100644
--- a/packages/cli/src/mobile.ts
+++ b/packages/cli/src/mobile.ts
@@ -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(path: string, init?: RequestInit): Promise {\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;\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`;
diff --git a/packages/cli/src/system.ts b/packages/cli/src/system.ts
index 39b1f66e..9c057467 100644
--- a/packages/cli/src/system.ts
+++ b/packages/cli/src/system.ts
@@ -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 },
},
diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts
index 6d6ae998..61595225 100644
--- a/packages/cli/src/workspace.ts
+++ b/packages/cli/src/workspace.ts
@@ -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 => ({
},
"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}"
}
}
`,
diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts
index 2e3d0bed..c0a680b7 100644
--- a/packages/cli/test/create.test.ts
+++ b/packages/cli/test/create.test.ts
@@ -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"');
diff --git a/packages/cli/test/plugin-runtime-production.test.ts b/packages/cli/test/plugin-runtime-production.test.ts
index 3096f792..096d9f05 100644
--- a/packages/cli/test/plugin-runtime-production.test.ts
+++ b/packages/cli/test/plugin-runtime-production.test.ts
@@ -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}/`);
diff --git a/packages/cli/test/workspace.test.ts b/packages/cli/test/workspace.test.ts
index 11cdc4b7..2b9c20ce 100644
--- a/packages/cli/test/workspace.test.ts
+++ b/packages/cli/test/workspace.test.ts
@@ -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");
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index e32440a3..9c7bf622 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -7,7 +7,6 @@
".": "./src/index.ts"
},
"dependencies": {
- "@wrnexus/core": "workspace:*",
"@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*",
"@wrnexus/syntax": "workspace:*",
diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts
index 296cb3a8..8cd4a543 100644
--- a/packages/compiler/src/codegen.ts
+++ b/packages/compiler/src/codegen.ts
@@ -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 = new Set();
+
+/**
+ * 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 = {};
+ 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(` attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
@@ -1255,7 +1308,21 @@ function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet;
+}
+
+export function generate(ast: PageAst, options: GenerateOptions = {}): string {
+ currentIslands = options.islands ?? new Set();
+ try {
+ return generateInner(ast);
+ } finally {
+ currentIslands = new Set();
+ }
+}
+
+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") {
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index d35211fb..f9cad94f 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -126,3 +126,15 @@ export function compile(source: string, filePath = ""): 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";
diff --git a/packages/compiler/src/island-bundle.ts b/packages/compiler/src/island-bundle.ts
index c6463334..9b191d35 100644
--- a/packages/compiler/src/island-bundle.ts
+++ b/packages/compiler/src/island-bundle.ts
@@ -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 {
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,
diff --git a/packages/compiler/src/island-codegen.ts b/packages/compiler/src/island-codegen.ts
index afdd4f3e..dac864be 100644
--- a/packages/compiler/src/island-codegen.ts
+++ b/packages/compiler/src/island-codegen.ts
@@ -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 = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+};
+
+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).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,
@@ -80,3 +121,21 @@ export function renderIslandMarker(input: {
` data-wrn-island-props="${escapeHtml(input.propsJson)}">
`
);
}
+
+/**
+ * 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 {
+ const names = new Set();
+ for (const entry of imports) {
+ if (entry.kind !== "island") continue;
+ const local = entry.declaration.defaultImport;
+ if (local) names.add(local);
+ }
+ return names;
+}
diff --git a/packages/compiler/test/island-classification.test.ts b/packages/compiler/test/island-classification.test.ts
index 868b4b90..8609b55a 100644
--- a/packages/compiler/test/island-classification.test.ts
+++ b/packages/compiler/test/island-classification.test.ts
@@ -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", () => {
diff --git a/packages/compiler/test/island-codegen.test.ts b/packages/compiler/test/island-codegen.test.ts
index 8883d83a..1bfe6927 100644
--- a/packages/compiler/test/island-codegen.test.ts
+++ b/packages/compiler/test/island-codegen.test.ts
@@ -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("<");
expect(html).toContain(""");
});
+
+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()" });
+});
diff --git a/packages/compiler/test/island-emit.test.ts b/packages/compiler/test/island-emit.test.ts
new file mode 100644
index 00000000..4d1fd997
--- /dev/null
+++ b/packages/compiler/test/island-emit.test.ts
@@ -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 {
+
+ plain
+ }
+}`;
+
+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 { }
+}`;
+ const out = generate(parse(source), { islands: new Set(["Chart"]) });
+
+ expect(out).toContain(""start":3");
+ expect(out).toContain(""live":true");
+ expect(out).toContain(""title":"Revenue"");
+});
+
+test("a runtime expression prop fails the build with WRN-ISLAND-PROPS", () => {
+ const source = `page Home {
+ state count = 1
+ view { }
+}`;
+ expect(() => generate(parse(source), { islands: new Set(["Chart"]) })).toThrow(
+ /WRN-ISLAND-PROPS/,
+ );
+});
diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json
index d89b5f6a..b43da153 100644
--- a/packages/dev-server/package.json
+++ b/packages/dev-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
- "version": "0.8.32",
+ "version": "0.8.33",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts
index 466a20ea..a74fb2e0 100644
--- a/packages/dev-server/src/pipeline.ts
+++ b/packages/dev-server/src/pipeline.ts
@@ -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();
+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 {
+ 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; 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 {
+ 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 {title}
; }`,
+ );
+ writeFileSync(join(root, "app", "Card.wrn"), `component Card { view { card
} }`);
+ 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 {",
+ ' ',
+ " ",
+ " }",
+ "}",
+ ].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('')).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 { }", "}"].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");
+});
diff --git a/packages/dev-server/test/island-scripts.test.ts b/packages/dev-server/test/island-scripts.test.ts
new file mode 100644
index 00000000..5514a530
--- /dev/null
+++ b/packages/dev-server/test/island-scripts.test.ts
@@ -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(``);
+ expect(scripts).toContain("/__wrnexus/islands.js");
+});
+
+test("markup without islands ships no island bootstrap", () => {
+ expect(collectScripts(`plain server html
`)).toEqual([]);
+});
+
+test("islands alone do not pull in the reactive runtime", () => {
+ const scripts = collectScripts(``);
+ expect(scripts).not.toContain("/__wrnexus/reactive.js");
+});
diff --git a/packages/dev-server/test/not-found-runtime.test.ts b/packages/dev-server/test/not-found-runtime.test.ts
index 74090c6a..f9ab5226 100644
--- a/packages/dev-server/test/not-found-runtime.test.ts
+++ b/packages/dev-server/test/not-found-runtime.test.ts
@@ -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: () => "That page is gone
" },
getMiddleware: async () => [],
assets: { serve: async () => null },
diff --git a/packages/react/package.json b/packages/react/package.json
index 12243e1f..ba978633 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -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:*"
diff --git a/packages/react/src/browser.ts b/packages/react/src/browser.ts
new file mode 100644
index 00000000..c4af83bf
--- /dev/null
+++ b/packages/react/src/browser.ts
@@ -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";
diff --git a/packages/react/src/error-boundary.tsx b/packages/react/src/error-boundary.tsx
index 032e6692..31800e75 100644
--- a/packages/react/src/error-boundary.tsx
+++ b/packages/react/src/error-boundary.tsx
@@ -41,4 +41,4 @@ export class IslandErrorBoundary extends Component<
);
}
-}
\ No newline at end of file
+}
diff --git a/packages/react/test/island-hmr.test.ts b/packages/react/test/island-hmr.test.ts
index 37c02bf8..56cee2b3 100644
--- a/packages/react/test/island-hmr.test.ts
+++ b/packages/react/test/island-hmr.test.ts
@@ -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;
diff --git a/scripts/build-editor-compiler.mjs b/scripts/build-editor-compiler.mjs
index 6a28ff64..93c7b23b 100644
--- a/scripts/build-editor-compiler.mjs
+++ b/scripts/build-editor-compiler.mjs
@@ -34,28 +34,13 @@ 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") &&
- !isEditorExcluded(path)
- )
- files.push(path);
+ else if (stat.isFile() && path.endsWith(".ts") && !path.endsWith(".test.ts")) files.push(path);
}
return files;
}
diff --git a/tsconfig.json b/tsconfig.json
index dc398dc5..70ce51a9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,6 +20,7 @@
"@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"],
"@wrnexus/react": ["./packages/react/src/index.ts"],
"@wrnexus/react/runtime": ["./packages/react/src/runtime-source.ts"],
+ "@wrnexus/react/browser": ["./packages/react/src/browser.ts"],
"@wrnexus/reactive": ["./packages/reactive/src/index.ts"],
"@wrnexus/graphql": ["./packages/graphql/src/index.ts"],
"@wrnexus/graphql/*": ["./packages/graphql/src/*.ts"],