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>
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import {
|
|
islandNamesFrom,
|
|
islandPropValue,
|
|
parseIslandStrategy,
|
|
renderIslandMarker,
|
|
serializeIslandProps,
|
|
} from "../src/island-codegen.ts";
|
|
|
|
test("defaults to the client-only strategy", () => {
|
|
expect(parseIslandStrategy([])).toBe("only");
|
|
expect(parseIslandStrategy(["client:visible"])).toBe("visible");
|
|
expect(parseIslandStrategy(["client:idle"])).toBe("idle");
|
|
expect(parseIslandStrategy(["client:load"])).toBe("load");
|
|
});
|
|
|
|
test("serializes JSON-safe props", () => {
|
|
const result = serializeIslandProps("Chart", { title: "Revenue", points: [1, 2] });
|
|
expect(result).toEqual({ json: '{"title":"Revenue","points":[1,2]}' });
|
|
});
|
|
|
|
test("rejects non-serializable props with WRN-ISLAND-PROPS", () => {
|
|
const result = serializeIslandProps("Chart", { onClick: () => {} });
|
|
expect(result).toHaveProperty("diagnostic");
|
|
const { diagnostic } = result as { diagnostic: { code: string; message: string } };
|
|
expect(diagnostic.code).toBe("WRN-ISLAND-PROPS");
|
|
expect(diagnostic.message).toContain("Chart");
|
|
expect(diagnostic.message).toContain("onClick");
|
|
});
|
|
|
|
test("rejects class instances and nested offenders", () => {
|
|
class Point {
|
|
constructor(public x = 1) {}
|
|
}
|
|
expect(serializeIslandProps("Chart", { origin: new Point() })).toHaveProperty("diagnostic");
|
|
expect(serializeIslandProps("Chart", { nested: { deep: () => {} } })).toHaveProperty(
|
|
"diagnostic",
|
|
);
|
|
expect(serializeIslandProps("Chart", { list: [1, () => {}] })).toHaveProperty("diagnostic");
|
|
});
|
|
|
|
test("accepts null and nested plain data", () => {
|
|
const result = serializeIslandProps("Chart", {
|
|
empty: null,
|
|
nested: { rows: [{ id: 1 }], flag: false },
|
|
});
|
|
expect(result).toHaveProperty("json");
|
|
});
|
|
|
|
test("rejects island names that are unsafe as URL path segments", () => {
|
|
// The name is fetched as /__wrnexus/island/<name>.js, so traversal and
|
|
// separators must be refused rather than merely escaped.
|
|
expect(() =>
|
|
renderIslandMarker({ name: "../secret", strategy: "only", propsJson: "{}" }),
|
|
).toThrow(/not a safe identifier/);
|
|
expect(() => renderIslandMarker({ name: "a/b", strategy: "only", propsJson: "{}" })).toThrow(
|
|
/not a safe identifier/,
|
|
);
|
|
expect(() =>
|
|
renderIslandMarker({ name: "Chart", strategy: "only", propsJson: "{}" }),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("renders a marker with escaped props", () => {
|
|
const html = renderIslandMarker({
|
|
name: "Chart",
|
|
strategy: "visible",
|
|
propsJson: '{"title":"a<b\\"c"}',
|
|
});
|
|
expect(html).toContain('data-wrn-island="Chart"');
|
|
expect(html).toContain('data-wrn-island-strategy="visible"');
|
|
expect(html).not.toContain('title":"a<b"c');
|
|
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()" });
|
|
});
|