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:
+51
-10
@@ -28,6 +28,9 @@ import { getIslandRuntime } from "@wrnexus/react/runtime";
|
||||
import {
|
||||
analyzeRuntimeImports,
|
||||
analyzeRuntimeRequirements,
|
||||
assertReactAvailable,
|
||||
buildIslands,
|
||||
islandNamesFrom,
|
||||
assertValidAst,
|
||||
generate,
|
||||
generateTargets,
|
||||
@@ -185,6 +188,8 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
const clientFiles = new Map<string, string>();
|
||||
const runtimeAnalysis = new Map<string, RuntimeRequirements>();
|
||||
const partialStaticFiles = new Set<string>();
|
||||
/** Island component name -> resolved .tsx source, collected across all routes. */
|
||||
const discoveredIslands = new Map<string, string>();
|
||||
const compileWrn = async (file: string): Promise<void> => {
|
||||
if (!file.endsWith(".wrn") || compiledFiles.has(file)) return;
|
||||
const source = readFileSync(file, "utf8");
|
||||
@@ -209,7 +214,25 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
assertValidAst(ast, { file, accessibility: true });
|
||||
ast = await pluginRunner.transformAst(ast, file);
|
||||
if (ast.renderMode === "partial-static") partialStaticFiles.add(file);
|
||||
runtimeAnalysis.set(file, analyzeRuntimeRequirements(ast));
|
||||
|
||||
// Islands must be known before codegen (to emit markers instead of
|
||||
// component mounts) and before route analysis (an island route ships JS).
|
||||
const fileImports = resolveWrnImports(ast.structuredImports, file, {
|
||||
appRoot: root,
|
||||
mode: config.imports?.mode ?? "compatible",
|
||||
aliases: config.imports?.aliases,
|
||||
});
|
||||
const fileIslands = islandNamesFrom(fileImports);
|
||||
for (const imported of fileImports) {
|
||||
if (imported.kind === "island" && imported.resolved) {
|
||||
discoveredIslands.set(imported.declaration.defaultImport!, imported.resolved);
|
||||
}
|
||||
}
|
||||
|
||||
runtimeAnalysis.set(
|
||||
file,
|
||||
analyzeRuntimeRequirements(ast, { hasIslands: fileIslands.size > 0 }),
|
||||
);
|
||||
const pluginDiagnostics = await pluginRunner.diagnostics(ast, file);
|
||||
const errors = pluginDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
||||
for (const diagnostic of pluginDiagnostics.filter((item) => item.severity !== "error")) {
|
||||
@@ -223,10 +246,11 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const targets = generateTargets(ast);
|
||||
let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll(
|
||||
"__WRNEXUS_CLIENT_MODULE__",
|
||||
clientUrl,
|
||||
);
|
||||
let code =
|
||||
`// compiled from ${fwd(relative(root, file))}\n${generate(ast, { islands: fileIslands })}`.replaceAll(
|
||||
"__WRNEXUS_CLIENT_MODULE__",
|
||||
clientUrl,
|
||||
);
|
||||
let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`;
|
||||
code = await pluginRunner.transformCode(code, file);
|
||||
browserCode = await pluginRunner.transformCode(browserCode, file);
|
||||
@@ -249,11 +273,7 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedImports = resolveWrnImports(ast.structuredImports, file, {
|
||||
appRoot: root,
|
||||
mode: config.imports?.mode ?? "compatible",
|
||||
aliases: config.imports?.aliases,
|
||||
});
|
||||
const resolvedImports = fileImports;
|
||||
for (const imported of resolvedImports) {
|
||||
if (imported.diagnostic?.severity === "error") {
|
||||
throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`);
|
||||
@@ -505,6 +525,26 @@ export async function runBuild(appRoot: string): Promise<void> {
|
||||
assetHash.update(islandCode);
|
||||
console.log(`✓ Islands: ${islandsPath}`);
|
||||
|
||||
// Island bundles are emitted only when a route actually imported a .tsx, so a
|
||||
// build with no islands produces no React and no island assets at all.
|
||||
const islandsDir = join(distDir, "island");
|
||||
if (discoveredIslands.size > 0) {
|
||||
const missingReact = assertReactAvailable(root);
|
||||
if (missingReact) {
|
||||
throw new Error(`${missingReact.code}: ${missingReact.message}`);
|
||||
}
|
||||
mkdirSync(islandsDir, { recursive: true });
|
||||
const islandBuild = await buildIslands({
|
||||
islands: [...discoveredIslands].map(([name, sourcePath]) => ({ name, sourcePath })),
|
||||
outDir: islandsDir,
|
||||
appRoot: root,
|
||||
});
|
||||
for (const asset of islandBuild.assets) assetHash.update(asset.hash);
|
||||
console.log(
|
||||
`✓ Island bundles: ${islandBuild.assets.length} (${islandBuild.sharedChunks.length} shared chunks)`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1a) Theme tokens + client switcher (always emitted; built-in light/dark).
|
||||
const theme = resolveThemeConfig(config.theme, config.cookies);
|
||||
const themeCss = renderThemeCss(theme);
|
||||
@@ -747,6 +787,7 @@ await createProductionServer(
|
||||
reactivePath: join(import.meta.dir, "reactive.js"),
|
||||
controllersPath: join(import.meta.dir, "controllers.js"),
|
||||
clientModulesDir: join(import.meta.dir, "client"),
|
||||
islandsDir: join(import.meta.dir, "island"),
|
||||
themePath: join(import.meta.dir, "theme.css"),
|
||||
themeAssetsDir: join(import.meta.dir, "theme"),
|
||||
themeJsPath: join(import.meta.dir, "theme.js"),
|
||||
|
||||
Reference in New Issue
Block a user