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:
@@ -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