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:
@@ -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) ||
|
||||
|
||||
Reference in New Issue
Block a user