Compare commits

...
2 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 843db2815f fix(islands): rebuild on .tsx edits and support islands inside components
Three bugs found by driving the dev server rather than reading code:

1. An island used inside a .wrn component still emitted a component mount
   — only the page and nested-page render paths were covered.

2. Editing an island .tsx never rebuilt in dev. The bundle cache was keyed
   on source path alone, and page modules are cached after the first
   request so no compile runs to notice the change. The cache key now
   includes mtime, and the file watcher rebuilds islands whose .tsx
   changed.

3. A .wrn cache hit skipped island building entirely, so after a restart
   with a warm cache no island bundle was ever produced. Island inputs are
   now persisted beside the other artifacts and rebuilt on a cache hit.

The islands manifest is deliberately excluded from the artifact
completeness check: only the async compile path writes it, so requiring it
made the sync path miss the cache on every call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:26:58 +05:30
ClintchizandClaude Opus 5 17aa3b98eb 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>
2026-08-18 16:16:03 +05:30
37 changed files with 994 additions and 76 deletions
+24 -1
View File
@@ -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"
]
+337 -4
View File
@@ -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: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179
// 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("</div>"));
}
if (componentTag) {
const island = islandMarkerFor(node);
if (island)
return escLit(island);
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
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))
@@ -1340,6 +1385,10 @@ function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindin
return `<div data-component="${attrEscape(node.tag)}"` + `${attrs}>${inner}</div>`;
}
function renderNestedComponentInvocation(node, ctx) {
// Islands work inside .wrn components too, not just pages.
const island = islandMarkerFor(node);
if (island)
return island;
let bindIndex = 0;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
@@ -1796,7 +1845,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 +3242,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 +3338,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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
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/<name>.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 (`<div data-wrn-island="${escapeHtml(input.name)}"` +
` data-wrn-island-strategy="${input.strategy}"` +
` 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.
*/
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) {
+1 -1
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: 4d9518778cef65c0400da0e8be9ece65be85acbd581e3024d484b7cbd2fb8116
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
@@ -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 {
<main>
<h1>React island</h1>
<Counter start={3} client:visible />
</main>
}
}
+3
View File
@@ -9,6 +9,7 @@ export interface Routes {
"/client-only": Record<string, never>;
"/dashboard": Record<string, never>;
"/hello": Record<string, never>;
"/island-demo": Record<string, never>;
"/language-tools": Record<string, never>;
"/layout": Record<string, never>;
"/login": Record<string, never>;
@@ -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<N extends RouteName>(
"client.only": "/client-only",
"dashboard": "/dashboard",
"hello": "/hello",
"island.demo": "/island-demo",
"language.tools": "/language-tools",
"layout": "/layout",
"login": "/login",
+1 -1
View File
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
: never;
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? 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";
+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:*",
+72 -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))
@@ -720,6 +773,10 @@ function renderNestedComponentInvocation(
node: Extract<ViewNode, { type: "element" }>,
ctx: CompCtx,
): string {
// Islands work inside .wrn components too, not just pages.
const island = islandMarkerFor(node);
if (island) return island;
let bindIndex = 0;
const attrs = node.attrs
@@ -1255,7 +1312,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,62 @@
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/,
);
});
test("an island inside a .wrn component also emits a marker", () => {
const source = `component Panel {
view { <Chart start={1} /> }
}`;
const out = generate(parse(source), { islands: new Set(["Chart"]) });
expect(out).toContain("data-wrn-island=");
expect(out).not.toContain('data-component="Chart"');
});
+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": {
+13
View File
@@ -43,6 +43,7 @@ import {
loadWrnServerModule,
setCompileCacheDir,
setCompileImportOptions,
rebuildChangedIslands,
setDevCompilerPipeline,
wrnBrowserArtifactUrlAsync,
} from "./pipeline.ts";
@@ -685,6 +686,18 @@ export async function startServer(opts: ServeOptions): Promise<RunningServer> {
console.log(`[wrnexus] hot update — ${files.join(", ")}`);
await pluginRunner.hook("hmrUpdate", files);
// Island .tsx sources are not .wrn files, so nothing below would rebuild
// them; page modules are cached, so no compile runs on the next request.
const islandFiles = files
.map((changed) => (isAbsolute(changed) ? changed : resolve(appDir, changed)))
.filter((changed) => changed.endsWith(".tsx"));
if (islandFiles.length > 0) {
try {
await rebuildChangedIslands(islandFiles);
} catch (error) {
console.warn("[wrnexus] island rebuild failed", error);
}
}
const storeUpdates: Array<{ name: string; url: string; kind: string }> = [];
for (const changed of files) {
const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed);
+130 -2
View File
@@ -19,6 +19,8 @@ import {
compile,
generate,
generateTargets,
buildIslands,
islandNamesFrom,
resolveWrnImports,
type PageAst,
type ViewNode,
@@ -126,6 +128,102 @@ 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>();
/** Island name -> resolved .tsx source, so the watcher can rebuild on edit. */
const islandSources = new Map<string, string>();
let islandAppRoot: string | null = null;
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");
islandAppRoot = appRoot;
for (const island of islands) islandSources.set(island.name, island.sourcePath);
// Keyed by source AND mtime: keying on the path alone serves a stale bundle
// forever once an island .tsx is edited.
const stamp = (island: { name: string; sourcePath: string }) => {
let mtime: number;
try {
mtime = statSync(island.sourcePath).mtimeMs;
} catch {
mtime = 0;
}
return `${island.sourcePath}:${mtime}`;
};
const pending = islands.filter((island) => builtIslands.get(island.name) !== stamp(island));
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 rebuilt = pending.find((island) => island.name === asset.name);
if (rebuilt) builtIslands.set(asset.name, stamp(rebuilt));
}
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.
*/
/**
* Rebuilds islands whose .tsx source changed.
*
* Page modules are cached after the first request, so no compile runs on a
* later request and nothing else would notice an island edit.
*/
export async function rebuildChangedIslands(changed: string[]): Promise<boolean> {
if (!islandAppRoot) return false;
const touched = new Set(changed.map((file) => resolve(file)));
const affected = [...islandSources]
.filter(([, sourcePath]) => touched.has(resolve(sourcePath)))
.map(([name, sourcePath]) => ({ name, sourcePath }));
if (affected.length === 0) return false;
await ensureIslandArtifacts(affected, islandAppRoot);
return true;
}
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,
@@ -396,6 +494,8 @@ export interface WrnCompileArtifacts {
declarations: string;
contract: string;
rpc: string;
/** Island inputs for this file, so a cache hit can still build islands. */
islands: string;
}
export interface WrnCompileMetrics {
@@ -486,15 +586,20 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
declarations: join(cacheDir, `${stem}.d.ts`),
contract: join(cacheDir, `${stem}.contract.json`),
rpc: join(cacheDir, `${stem}.rpc.json`),
islands: join(cacheDir, `${stem}.islands.json`),
};
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);
mkdirSync(cacheDir, { recursive: true });
writeFileSync(artifacts.islands, JSON.stringify(islandInputs), "utf8");
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,
),
@@ -555,13 +660,36 @@ export function compileWrnArtifacts(file: string, version = 0): WrnCompileArtifa
declarations: join(cacheDir, `${stem}.d.ts`),
contract: join(cacheDir, `${stem}.contract.json`),
rpc: join(cacheDir, `${stem}.rpc.json`),
islands: join(cacheDir, `${stem}.islands.json`),
};
compileInProgress.set(file, artifacts);
try {
try {
if (Object.values(artifacts).every((path) => statSync(path).isFile())) {
// The islands manifest is written only by the async compile path, so it
// is not part of the completeness check — a missing manifest means "no
// islands known for this file", not a stale cache.
const requiredArtifacts = Object.entries(artifacts)
.filter(([key]) => key !== "islands")
.map(([, path]) => path);
if (requiredArtifacts.every((path) => statSync(path).isFile())) {
compileMetrics.hits++;
browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser);
// A cached .wrn still needs its island bundles: the .tsx may have changed
// since, and after a restart with a warm cache nothing else would build them.
let cachedIslands: Array<{ name: string; sourcePath: string }> = [];
try {
cachedIslands = JSON.parse(readFileSync(artifacts.islands, "utf8")) as Array<{
name: string;
sourcePath: string;
}>;
} catch {
cachedIslands = [];
}
// This variant is synchronous, so the rebuild is kicked off rather than
// awaited. The async compile path awaits it before serving a page.
void ensureIslandArtifacts(cachedIslands, projectRootForFile(file)).catch((error) => {
console.warn("[wrnexus] island rebuild failed", error);
});
return artifacts;
}
} catch {
+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;
+1 -16
View File
@@ -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;
}
+1
View File
@@ -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"],