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:
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user