feat(islands): wire islands end to end

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 16:16:03 +05:30
co-authored by Claude Opus 5
parent 442a3106ed
commit 17aa3b98eb
36 changed files with 902 additions and 75 deletions
-1
View File
@@ -7,7 +7,6 @@
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/core": "workspace:*",
"@wrnexus/csr": "workspace:*",
"@wrnexus/store": "workspace:*",
"@wrnexus/syntax": "workspace:*",
+68 -1
View File
@@ -22,6 +22,12 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts";
import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax";
import { generateStoreModule } from "./store-codegen.ts";
import { optimizeAst } from "./analysis.ts";
import {
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
import { browserModuleRequired } from "./client-codegen.ts";
interface RenderBinding {
@@ -43,6 +49,48 @@ interface NamedDataBinding extends RenderBinding {
mode: DataMode;
}
/**
* Island names for the file currently being generated.
*
* Codegen is a synchronous single pass, so a module-scoped set avoids threading
* an extra parameter through every render function. Always reset in generate().
*/
let currentIslands: ReadonlySet<string> = new Set<string>();
/**
* Builds the island placeholder for a component tag that was imported from a
* .tsx file. Returns null for ordinary .wrn components.
*/
function islandMarkerFor(node: {
tag: string;
attrs: Array<{ name: string; value?: string }>;
}): string | null {
if (!currentIslands.has(node.tag)) return null;
const directives = node.attrs
.map((attr) => attr.name)
.filter((name) => name.startsWith("client:"));
const props: Record<string, unknown> = {};
for (const attr of node.attrs) {
if (attr.name.startsWith("client:")) continue;
const parsed = islandPropValue(attr.value);
if ("dynamic" in parsed) {
throw new Error(
`WRN-ISLAND-PROPS: Island '${node.tag}' received a runtime expression for prop '${attr.name}'. ` +
`Island props are serialized at build time, so they must be literal values ` +
`(for example start={3} or title="Revenue"), not ${attr.value}.`,
);
}
props[attr.name] = parsed.value;
}
const serialized = serializeIslandProps(node.tag, props);
if ("diagnostic" in serialized) throw new Error(serialized.diagnostic.message);
return renderIslandMarker({
name: node.tag,
strategy: parseIslandStrategy(directives),
propsJson: serialized.json,
});
}
function isComponentTag(tag: string): boolean {
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
}
@@ -448,6 +496,8 @@ function renderLoopBody(node: ViewNode): string {
}
if (componentTag) {
const island = islandMarkerFor(node);
if (island) return escLit(island);
return (
escLit(`<div data-component="${attrEscape(node.tag)}"`) +
attrs +
@@ -704,6 +754,9 @@ function renderPageComponentInvocation(
loops: string[],
reactive: PageReactive | null,
): string {
const island = islandMarkerFor(node);
if (island) return island;
const attrs = node.attrs
.filter((attr) => attr.name !== "data-component")
.map((attr) => renderPageComponentAttr(attr, loops))
@@ -1255,7 +1308,21 @@ function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet<s
}
}
export function generate(ast: PageAst): string {
export interface GenerateOptions {
/** Local names bound to .tsx island imports in this file. */
islands?: ReadonlySet<string>;
}
export function generate(ast: PageAst, options: GenerateOptions = {}): string {
currentIslands = options.islands ?? new Set<string>();
try {
return generateInner(ast);
} finally {
currentIslands = new Set<string>();
}
}
function generateInner(ast: PageAst): string {
ast = optimizeAst(ast).ast;
if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast);
if (ast.kind === "component" || ast.kind === "layout") {
+12
View File
@@ -126,3 +126,15 @@ export function compile(source: string, filePath = "<inline .wrn>"): CompileResu
}
export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts";
export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts";
export {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
} from "./island-codegen.ts";
export type { IslandDiagnostic, IslandStrategy } from "./island-codegen.ts";
export { assertReactAvailable, buildIslands, generateIslandEntry } from "./island-bundle.ts";
export type { IslandBuildResult, IslandInput } from "./island-bundle.ts";
export { routeNeedsIslands } from "./analysis.ts";
+25 -1
View File
@@ -82,6 +82,16 @@ export function assertReactAvailable(
export async function buildIslands(input: {
islands: IslandInput[];
outDir: string;
/**
* App root used to resolve the island mount runtime. When given, the runtime
* is emitted as `runtime.js` in the SAME build as the islands.
*
* This is not a convenience: building the runtime separately gives it its own
* copy of React, and a component rendered by one copy while importing hooks
* from another fails with "Cannot read properties of null (reading
* 'useState')". One build with splitting keeps React in a single shared chunk.
*/
appRoot?: string;
}): Promise<IslandBuildResult> {
if (input.islands.length === 0) return { assets: [], sharedChunks: [] };
@@ -100,9 +110,22 @@ export async function buildIslands(input: {
writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
}
const entrypoints = input.islands.map((island) => join(entryDir, `${island.name}.tsx`));
if (input.appRoot) {
const resolveFrom = createRequire(join(input.appRoot, "package.json"));
const runtimeEntry = join(entryDir, "runtime.ts");
writeFileSync(
runtimeEntry,
`export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))};
`,
"utf8",
);
entrypoints.push(runtimeEntry);
}
try {
const result = await Bun.build({
entrypoints: input.islands.map((island) => join(entryDir, `${island.name}.tsx`)),
entrypoints,
outdir: input.outDir,
target: "browser",
format: "esm",
@@ -123,6 +146,7 @@ export async function buildIslands(input: {
// Bun names an entry's output after its entry file, so the basename
// identifies the island unambiguously.
const stem = basename(output.path).replace(/\.js$/, "");
if (stem === "runtime") continue;
if (!islandNames.has(stem)) continue;
assets.push({
name: stem,
+60 -1
View File
@@ -1,4 +1,23 @@
import { escapeHtml, isSafeIslandName } from "@wrnexus/core";
// Implemented locally rather than imported from @wrnexus/core: compiler modules
// are bundled into the Node-only VS Code extension, which contains no other
// packages, so a runtime import of core would break the editor compiler.
const HTML_ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]!);
}
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
function isSafeIslandName(name: string): boolean {
return SAFE_ISLAND_NAME.test(name);
}
export type IslandStrategy = "only" | "load" | "visible" | "idle";
@@ -35,6 +54,28 @@ function unsupportedProp(value: unknown): boolean {
return Object.values(value as Record<string, unknown>).some(unsupportedProp);
}
/**
* Interprets an island attribute value with JSX semantics.
*
* `title="Revenue"` is a string, `start={3}` is a number, `flag` alone is
* `true`. Without this every prop arrives as a string, so `start={3}` would be
* `"3"` and arithmetic in the island silently concatenates.
*
* Returns `dynamic` for a `{…}` value that is not JSON: such expressions are
* evaluated at runtime and cannot cross the serialization boundary.
*/
export function islandPropValue(raw: string | undefined): { value: unknown } | { dynamic: string } {
if (raw === undefined || raw === "") return { value: true };
const expression = /^\{([\s\S]*)\}$/.exec(raw);
if (!expression) return { value: raw };
const inner = expression[1]!.trim();
try {
return { value: JSON.parse(inner) as unknown };
} catch {
return { dynamic: inner };
}
}
export function serializeIslandProps(
componentName: string,
props: Record<string, unknown>,
@@ -80,3 +121,21 @@ export function renderIslandMarker(input: {
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`
);
}
/**
* Local binding names introduced by island imports.
*
* Codegen sees only component tag names, so it needs the set of names that came
* from `.tsx` imports to tell an island apart from a `.wrn` component.
*/
export function islandNamesFrom(
imports: Array<{ kind?: "island"; declaration: { defaultImport?: string } }>,
): Set<string> {
const names = new Set<string>();
for (const entry of imports) {
if (entry.kind !== "island") continue;
const local = entry.declaration.defaultImport;
if (local) names.add(local);
}
return names;
}
@@ -12,9 +12,9 @@ test("a route with an island import needs client JavaScript", () => {
});
test("a route with no island imports stays zero-JS", () => {
expect(routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }])).toBe(
false,
);
expect(
routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }]),
).toBe(false);
});
test("an empty import list stays zero-JS", () => {
@@ -1,5 +1,7 @@
import { expect, test } from "bun:test";
import {
islandNamesFrom,
islandPropValue,
parseIslandStrategy,
renderIslandMarker,
serializeIslandProps,
@@ -71,3 +73,28 @@ test("renders a marker with escaped props", () => {
expect(html).toContain("&lt;");
expect(html).toContain("&quot;");
});
test("collects local binding names from island imports only", () => {
const names = islandNamesFrom([
{ kind: "island", declaration: { defaultImport: "Chart" } },
{ declaration: { defaultImport: "Card" } },
{ kind: "island", declaration: {} },
]);
expect([...names]).toEqual(["Chart"]);
});
test("island prop values follow JSX semantics, not raw attribute strings", () => {
// Without this, start={3} arrives as the string "3" and arithmetic inside the
// island concatenates: 3 -> "31" -> "311".
expect(islandPropValue("{3}")).toEqual({ value: 3 });
expect(islandPropValue("{true}")).toEqual({ value: true });
expect(islandPropValue("{[1,2]}")).toEqual({ value: [1, 2] });
expect(islandPropValue('{"a"}')).toEqual({ value: "a" });
expect(islandPropValue("Revenue")).toEqual({ value: "Revenue" });
expect(islandPropValue(undefined)).toEqual({ value: true });
});
test("a runtime expression prop is reported as dynamic", () => {
expect(islandPropValue("{someVariable}")).toEqual({ dynamic: "someVariable" });
expect(islandPropValue("{fn()}")).toEqual({ dynamic: "fn()" });
});
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generate } from "../src/codegen.ts";
const SOURCE = `page Home {
view {
<Chart title="Revenue" client:visible />
<Card>plain</Card>
}
}`;
test("an island tag emits an island marker instead of a component mount", () => {
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
expect(out).toContain("data-wrn-island=");
expect(out).toContain("Chart");
expect(out).toContain('data-wrn-island-strategy="visible"');
// Non-island components still mount the normal way.
expect(out).toContain('data-component="Card"');
});
test("without the island set the same tag stays a normal component", () => {
const out = generate(parse(SOURCE));
expect(out).not.toContain("data-wrn-island=");
expect(out).toContain('data-component="Chart"');
});
test("island props are serialized into the marker", () => {
const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) });
expect(out).toContain("Revenue");
});
test("numeric and boolean island props keep their types through the marker", () => {
const source = `page Home {
view { <Chart start={3} live={true} title="Revenue" /> }
}`;
const out = generate(parse(source), { islands: new Set(["Chart"]) });
expect(out).toContain("&quot;start&quot;:3");
expect(out).toContain("&quot;live&quot;:true");
expect(out).toContain("&quot;title&quot;:&quot;Revenue&quot;");
});
test("a runtime expression prop fails the build with WRN-ISLAND-PROPS", () => {
const source = `page Home {
state count = 1
view { <Chart value={count} /> }
}`;
expect(() => generate(parse(source), { islands: new Set(["Chart"]) })).toThrow(
/WRN-ISLAND-PROPS/,
);
});