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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.8.32",
|
||||
"version": "0.8.33",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user