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,7 +1,7 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: 182fd799ca860d927879d4259c182ea61cbd89d913758cc9f690e1ae4a35d90f
|
||||
// WRN editor compiler generator hash: 2690208ba65bb00d9fea3e08cb3ab324cfda77792021cd46785814fadf41c1bc
|
||||
// WRN editor compiler source hash: 631914b0f75e27a95908eb3252604e0948abf27b50533ab01f0dd12b532a1020
|
||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
const __path = __nodeRequire("node:path");
|
||||
@@ -789,7 +789,46 @@ const types_ts_1 = require("./types.js");
|
||||
const syntax_1 = require("@wrnexus/syntax");
|
||||
const store_codegen_ts_1 = require("./store-codegen.js");
|
||||
const analysis_ts_1 = require("./analysis.js");
|
||||
const island_codegen_ts_1 = require("./island-codegen.js");
|
||||
const client_codegen_ts_1 = require("./client-codegen.js");
|
||||
/**
|
||||
* 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 = new Set();
|
||||
/**
|
||||
* Builds the island placeholder for a component tag that was imported from a
|
||||
* .tsx file. Returns null for ordinary .wrn components.
|
||||
*/
|
||||
function islandMarkerFor(node) {
|
||||
if (!currentIslands.has(node.tag))
|
||||
return null;
|
||||
const directives = node.attrs
|
||||
.map((attr) => attr.name)
|
||||
.filter((name) => name.startsWith("client:"));
|
||||
const props = {};
|
||||
for (const attr of node.attrs) {
|
||||
if (attr.name.startsWith("client:"))
|
||||
continue;
|
||||
const parsed = (0, island_codegen_ts_1.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 = (0, island_codegen_ts_1.serializeIslandProps)(node.tag, props);
|
||||
if ("diagnostic" in serialized)
|
||||
throw new Error(serialized.diagnostic.message);
|
||||
return (0, island_codegen_ts_1.renderIslandMarker)({
|
||||
name: node.tag,
|
||||
strategy: (0, island_codegen_ts_1.parseIslandStrategy)(directives),
|
||||
propsJson: serialized.json,
|
||||
});
|
||||
}
|
||||
function isComponentTag(tag) {
|
||||
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
||||
}
|
||||
@@ -1137,6 +1176,9 @@ function renderLoopBody(node) {
|
||||
escLit("</div>"));
|
||||
}
|
||||
if (componentTag) {
|
||||
const island = islandMarkerFor(node);
|
||||
if (island)
|
||||
return escLit(island);
|
||||
return (escLit(`<div data-component="${attrEscape(node.tag)}"`) +
|
||||
attrs +
|
||||
escLit(">") +
|
||||
@@ -1330,6 +1372,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
|
||||
const island = islandMarkerFor(node);
|
||||
if (island)
|
||||
return island;
|
||||
const attrs = node.attrs
|
||||
.filter((attr) => attr.name !== "data-component")
|
||||
.map((attr) => renderPageComponentAttr(attr, loops))
|
||||
@@ -1796,7 +1841,16 @@ function markServerAsyncBoundaries(nodes, serverLoads) {
|
||||
markServerAsyncBoundaries(node.children, serverLoads);
|
||||
}
|
||||
}
|
||||
function generate(ast) {
|
||||
function generate(ast, options = {}) {
|
||||
currentIslands = options.islands ?? new Set();
|
||||
try {
|
||||
return generateInner(ast);
|
||||
}
|
||||
finally {
|
||||
currentIslands = new Set();
|
||||
}
|
||||
}
|
||||
function generateInner(ast) {
|
||||
ast = (0, analysis_ts_1.optimizeAst)(ast).ast;
|
||||
if (ast.kind === "global-store" || ast.kind === "page-store")
|
||||
return (0, store_codegen_ts_1.generateStoreModule)(ast);
|
||||
@@ -3184,7 +3238,7 @@ function resolveWrnImports(declarations, importer, options) {
|
||||
* `@wrnexus/syntax` package. This package owns platform-specific codegen.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
||||
exports.routeNeedsIslands = exports.generateIslandEntry = exports.buildIslands = exports.assertReactAvailable = exports.serializeIslandProps = exports.renderIslandMarker = exports.parseIslandStrategy = exports.islandPropValue = exports.islandNamesFrom = exports.DependencyGraph = exports.createCompilationCache = exports.compilationKey = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.LexError = exports.Lexer = exports.NativeCompileError = exports.generateNative = exports.runtimeCapabilities = exports.analyzeRuntimeImports = exports.optimizeAst = exports.analyzeRuntimeRequirements = exports.analyzeOptimizations = exports.createWrnSourceMap = exports.resolveWrnImports = exports.resolveWrnImport = exports.createComponentContract = exports.generateStoreModule = exports.generateStoreBrowserModule = exports.generateDeclarations = exports.rpcManifest = exports.generateServerFunctionsModule = exports.generateBrowserModule = exports.generateTargets = exports.generate = exports.ParseError = exports.parse = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.assertValidAst = exports.formatWrn = void 0;
|
||||
exports.compileNativeWrnFile = compileNativeWrnFile;
|
||||
exports.compileWrnFile = compileWrnFile;
|
||||
exports.compile = compile;
|
||||
@@ -3280,6 +3334,281 @@ var cache_ts_1 = require("./cache.js");
|
||||
Object.defineProperty(exports, "compilationKey", { enumerable: true, get: function () { return cache_ts_1.compilationKey; } });
|
||||
Object.defineProperty(exports, "createCompilationCache", { enumerable: true, get: function () { return cache_ts_1.createCompilationCache; } });
|
||||
Object.defineProperty(exports, "DependencyGraph", { enumerable: true, get: function () { return cache_ts_1.DependencyGraph; } });
|
||||
var island_codegen_ts_1 = require("./island-codegen.js");
|
||||
Object.defineProperty(exports, "islandNamesFrom", { enumerable: true, get: function () { return island_codegen_ts_1.islandNamesFrom; } });
|
||||
Object.defineProperty(exports, "islandPropValue", { enumerable: true, get: function () { return island_codegen_ts_1.islandPropValue; } });
|
||||
Object.defineProperty(exports, "parseIslandStrategy", { enumerable: true, get: function () { return island_codegen_ts_1.parseIslandStrategy; } });
|
||||
Object.defineProperty(exports, "renderIslandMarker", { enumerable: true, get: function () { return island_codegen_ts_1.renderIslandMarker; } });
|
||||
Object.defineProperty(exports, "serializeIslandProps", { enumerable: true, get: function () { return island_codegen_ts_1.serializeIslandProps; } });
|
||||
var island_bundle_ts_1 = require("./island-bundle.js");
|
||||
Object.defineProperty(exports, "assertReactAvailable", { enumerable: true, get: function () { return island_bundle_ts_1.assertReactAvailable; } });
|
||||
Object.defineProperty(exports, "buildIslands", { enumerable: true, get: function () { return island_bundle_ts_1.buildIslands; } });
|
||||
Object.defineProperty(exports, "generateIslandEntry", { enumerable: true, get: function () { return island_bundle_ts_1.generateIslandEntry; } });
|
||||
var analysis_ts_2 = require("./analysis.js");
|
||||
Object.defineProperty(exports, "routeNeedsIslands", { enumerable: true, get: function () { return analysis_ts_2.routeNeedsIslands; } });
|
||||
|
||||
},
|
||||
"packages/compiler/src/island-bundle.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateIslandEntry = generateIslandEntry;
|
||||
exports.reactJsxPlugin = reactJsxPlugin;
|
||||
exports.assertReactAvailable = assertReactAvailable;
|
||||
exports.buildIslands = buildIslands;
|
||||
const node_crypto_1 = require("node:crypto");
|
||||
const node_fs_1 = require("node:fs");
|
||||
const node_module_1 = require("node:module");
|
||||
const node_path_1 = require("node:path");
|
||||
/**
|
||||
* Generates the per-island browser entry.
|
||||
*
|
||||
* Never imports react-dom/server — islands are client-only.
|
||||
*/
|
||||
function generateIslandEntry(input) {
|
||||
return [
|
||||
"/** @jsxImportSource react */",
|
||||
`import Component from ${JSON.stringify(input.sourcePath)};`,
|
||||
`export const name = ${JSON.stringify(input.name)};`,
|
||||
`export default Component;`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
/**
|
||||
* Compiles every island `.tsx` against React's JSX runtime.
|
||||
*
|
||||
* The repo's root tsconfig sets `jsxImportSource` to `@wrnexus/core`, so an
|
||||
* island would otherwise compile to WRNexus's HTML-string renderer and
|
||||
* silently never mount. A `@jsxImportSource` pragma applies only to the file
|
||||
* that carries it, so putting one in the generated entry does nothing for the
|
||||
* author's own component — the injection has to happen per source file, which
|
||||
* is what this plugin does. App-authored islands stay plain `.tsx`.
|
||||
*/
|
||||
function reactJsxPlugin() {
|
||||
return {
|
||||
name: "wrnexus-island-jsx",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
||||
// Only first-party island sources need the pragma. Third-party .tsx
|
||||
// under node_modules is left alone so Bun's own handling is untouched.
|
||||
if (args.path.includes("node_modules"))
|
||||
return undefined;
|
||||
return {
|
||||
contents: `/** @jsxImportSource react */\n${await Bun.file(args.path).text()}`,
|
||||
loader: "tsx",
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
function assertReactAvailable(appRoot) {
|
||||
const require = (0, node_module_1.createRequire)((0, node_path_1.join)(appRoot, "package.json"));
|
||||
try {
|
||||
require.resolve("react");
|
||||
require.resolve("react-dom");
|
||||
return null;
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
code: "WRN-ISLAND-REACT-MISSING",
|
||||
severity: "error",
|
||||
message: "This app imports a .tsx island but react and react-dom are not installed. " +
|
||||
"Run: bun add react react-dom",
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Bundles island entries. `splitting: true` is required so React is emitted
|
||||
* once as a shared chunk rather than duplicated into every island.
|
||||
*/
|
||||
async function buildIslands(input) {
|
||||
if (input.islands.length === 0)
|
||||
return { assets: [], sharedChunks: [] };
|
||||
// Each island gets its own generated entry file named after the island.
|
||||
// Passing the component sources directly would dedupe two islands that share
|
||||
// a source file, and output order is not guaranteed to match input order —
|
||||
// both of which silently mismatch island names to bundles.
|
||||
//
|
||||
// The entries live inside outDir so `react` resolves from the app that
|
||||
// installed it, exactly as the island's own imports do.
|
||||
const entryDir = (0, node_path_1.join)(input.outDir, ".entries");
|
||||
(0, node_fs_1.mkdirSync)(entryDir, { recursive: true });
|
||||
const islandNames = new Set(input.islands.map((island) => island.name));
|
||||
for (const island of input.islands) {
|
||||
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8");
|
||||
}
|
||||
const entrypoints = input.islands.map((island) => (0, node_path_1.join)(entryDir, `${island.name}.tsx`));
|
||||
if (input.appRoot) {
|
||||
const resolveFrom = (0, node_module_1.createRequire)((0, node_path_1.join)(input.appRoot, "package.json"));
|
||||
const runtimeEntry = (0, node_path_1.join)(entryDir, "runtime.ts");
|
||||
(0, node_fs_1.writeFileSync)(runtimeEntry, `export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))};
|
||||
`, "utf8");
|
||||
entrypoints.push(runtimeEntry);
|
||||
}
|
||||
try {
|
||||
const result = await Bun.build({
|
||||
entrypoints,
|
||||
outdir: input.outDir,
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
plugins: [reactJsxPlugin()],
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new AggregateError(result.logs, "Island bundling failed");
|
||||
}
|
||||
const assets = [];
|
||||
const sharedChunks = [];
|
||||
for (const output of result.outputs) {
|
||||
if (output.kind === "entry-point") {
|
||||
// Bun names an entry's output after its entry file, so the basename
|
||||
// identifies the island unambiguously.
|
||||
const stem = (0, node_path_1.basename)(output.path).replace(/\.js$/, "");
|
||||
if (stem === "runtime")
|
||||
continue;
|
||||
if (!islandNames.has(stem))
|
||||
continue;
|
||||
assets.push({
|
||||
name: stem,
|
||||
hash: (0, node_crypto_1.createHash)("sha256").update(output.path).digest("hex").slice(0, 16),
|
||||
path: output.path,
|
||||
});
|
||||
}
|
||||
else if (output.kind === "chunk") {
|
||||
sharedChunks.push(output.path);
|
||||
}
|
||||
}
|
||||
return { assets, sharedChunks };
|
||||
}
|
||||
finally {
|
||||
(0, node_fs_1.rmSync)(entryDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
"packages/compiler/src/island-codegen.ts": function (module, exports, require, __filename, __dirname) {
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.parseIslandStrategy = parseIslandStrategy;
|
||||
exports.islandPropValue = islandPropValue;
|
||||
exports.serializeIslandProps = serializeIslandProps;
|
||||
exports.renderIslandMarker = renderIslandMarker;
|
||||
exports.islandNamesFrom = islandNamesFrom;
|
||||
// Implemented locally rather than imported from @wrnexus/core: compiler modules
|
||||
// are bundled into the Node-only VS Code extension, which contains no other
|
||||
// packages, so a runtime import of core would break the editor compiler.
|
||||
const HTML_ESCAPES = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
function escapeHtml(value) {
|
||||
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
|
||||
}
|
||||
const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/;
|
||||
function isSafeIslandName(name) {
|
||||
return SAFE_ISLAND_NAME.test(name);
|
||||
}
|
||||
const STRATEGIES = {
|
||||
"client:only": "only",
|
||||
"client:load": "load",
|
||||
"client:visible": "visible",
|
||||
"client:idle": "idle",
|
||||
};
|
||||
function parseIslandStrategy(directives) {
|
||||
for (const directive of directives) {
|
||||
const match = STRATEGIES[directive];
|
||||
if (match)
|
||||
return match;
|
||||
}
|
||||
return "only";
|
||||
}
|
||||
function unsupportedProp(value) {
|
||||
const type = typeof value;
|
||||
if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") {
|
||||
return true;
|
||||
}
|
||||
if (value === null || type !== "object")
|
||||
return false;
|
||||
if (Array.isArray(value))
|
||||
return value.some(unsupportedProp);
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null)
|
||||
return true;
|
||||
return Object.values(value).some(unsupportedProp);
|
||||
}
|
||||
/**
|
||||
* Interprets an island attribute value with JSX semantics.
|
||||
*
|
||||
* `title="Revenue"` is a string, `start={3}` is a number, `flag` alone is
|
||||
* `true`. Without this every prop arrives as a string, so `start={3}` would be
|
||||
* `"3"` and arithmetic in the island silently concatenates.
|
||||
*
|
||||
* Returns `dynamic` for a `{…}` value that is not JSON: such expressions are
|
||||
* evaluated at runtime and cannot cross the serialization boundary.
|
||||
*/
|
||||
function islandPropValue(raw) {
|
||||
if (raw === undefined || raw === "")
|
||||
return { value: true };
|
||||
const expression = /^\{([\s\S]*)\}$/.exec(raw);
|
||||
if (!expression)
|
||||
return { value: raw };
|
||||
const inner = expression[1].trim();
|
||||
try {
|
||||
return { value: JSON.parse(inner) };
|
||||
}
|
||||
catch {
|
||||
return { dynamic: inner };
|
||||
}
|
||||
}
|
||||
function serializeIslandProps(componentName, props) {
|
||||
const offenders = Object.entries(props)
|
||||
.filter(([, value]) => unsupportedProp(value))
|
||||
.map(([key]) => key);
|
||||
if (offenders.length > 0) {
|
||||
return {
|
||||
diagnostic: {
|
||||
code: "WRN-ISLAND-PROPS",
|
||||
severity: "error",
|
||||
message: `Island '${componentName}' received non-serializable prop(s): ${offenders.join(", ")}. ` +
|
||||
`Island props cross a serialization boundary and must be JSON-safe ` +
|
||||
`(no functions, symbols, bigints, undefined, or class instances).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { json: JSON.stringify(props) };
|
||||
}
|
||||
function renderIslandMarker(input) {
|
||||
// The name becomes a path segment when the browser fetches
|
||||
// /__wrnexus/island/<name>.js, so reuse the framework's conservative charset
|
||||
// rather than relying on escaping alone.
|
||||
if (!isSafeIslandName(input.name)) {
|
||||
throw new Error(`Island name '${input.name}' is not a safe identifier. ` +
|
||||
`Island names may only contain letters, digits, underscores, and hyphens.`);
|
||||
}
|
||||
return (`<div data-wrn-island="${escapeHtml(input.name)}"` +
|
||||
` data-wrn-island-strategy="${input.strategy}"` +
|
||||
` data-wrn-island-props="${escapeHtml(input.propsJson)}"></div>`);
|
||||
}
|
||||
/**
|
||||
* Local binding names introduced by island imports.
|
||||
*
|
||||
* Codegen sees only component tag names, so it needs the set of names that came
|
||||
* from `.tsx` imports to tell an island apart from a `.wrn` component.
|
||||
*/
|
||||
function islandNamesFrom(imports) {
|
||||
const names = new Set();
|
||||
for (const entry of imports) {
|
||||
if (entry.kind !== "island")
|
||||
continue;
|
||||
const local = entry.declaration.defaultImport;
|
||||
if (local)
|
||||
names.add(local);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
},
|
||||
"packages/compiler/src/native-codegen.ts": function (module, exports, require, __filename, __dirname) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: 4d9518778cef65c0400da0e8be9ece65be85acbd581e3024d484b7cbd2fb8116
|
||||
// WRN editor extension source hash: 67bc974ee8e647ebce3dfc0ceaff2a1be56384fc16ed0334b89ab32d6bd590a8
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
|
||||
Reference in New Issue
Block a user