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:
@@ -7,7 +7,6 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/csr": "workspace:*",
|
||||
"@wrnexus/store": "workspace:*",
|
||||
"@wrnexus/syntax": "workspace:*",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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("<");
|
||||
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()" });
|
||||
});
|
||||
|
||||
@@ -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(""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 { <Chart value={count} /> }
|
||||
}`;
|
||||
expect(() => generate(parse(source), { islands: new Set(["Chart"]) })).toThrow(
|
||||
/WRN-ISLAND-PROPS/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user