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
+68 -1
View File
@@ -19,6 +19,8 @@ import {
compile,
generate,
generateTargets,
buildIslands,
islandNamesFrom,
resolveWrnImports,
type PageAst,
type ViewNode,
@@ -126,6 +128,69 @@ function importOptionsHash(file: string): string {
return hashPath(JSON.stringify({ ...options, aliases }));
}
/** Island bundles already built this dev session, keyed by resolved source. */
const builtIslands = new Map<string, string>();
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");
const pending = islands.filter((island) => builtIslands.get(island.name) !== island.sourcePath);
if (pending.length === 0 && islandRuntimeBuilt) return;
// The runtime is built alongside the islands so they share one React copy.
const result = await buildIslands({
islands: pending.length ? pending : islands,
outDir,
appRoot,
});
registerIslandArtifact("/__wrnexus/island/runtime.js", join(outDir, "runtime.js"));
islandRuntimeBuilt = true;
for (const asset of result.assets) {
registerIslandArtifact(`/__wrnexus/island/${asset.name}.js`, asset.path);
const source = pending.find((island) => island.name === asset.name)?.sourcePath;
if (source) builtIslands.set(asset.name, source);
}
for (const chunk of result.sharedChunks) {
registerIslandArtifact(`/__wrnexus/island/${basename(chunk)}`, chunk);
}
}
/**
* Island imports in this file: names so codegen emits placeholders instead of
* component mounts, and sources so the bundles can be built.
*/
function islandsForFile(
ast: PageAst,
importer: string,
): { names: Set<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,
@@ -490,11 +555,13 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise<Wrn
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);
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,
),
+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) ||