diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 3c71ba67..befc7ac7 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: 631914b0f75e27a95908eb3252604e0948abf27b50533ab01f0dd12b532a1020 +// WRN editor compiler source hash: fd183ab8c54df72c779d099d7625ce0068e49bea458052335c77cbf31ccf9179 // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -1385,6 +1385,10 @@ function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindin return `
${inner}
`; } function renderNestedComponentInvocation(node, ctx) { + // Islands work inside .wrn components too, not just pages. + const island = islandMarkerFor(node); + if (island) + return island; let bindIndex = 0; const attrs = node.attrs .filter((attr) => attr.name !== "data-component") diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index cc870af8..48fa082d 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: 67bc974ee8e647ebce3dfc0ceaff2a1be56384fc16ed0334b89ab32d6bd590a8 +// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 8cd4a543..464ba1cb 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -773,6 +773,10 @@ function renderNestedComponentInvocation( node: Extract, ctx: CompCtx, ): string { + // Islands work inside .wrn components too, not just pages. + const island = islandMarkerFor(node); + if (island) return island; + let bindIndex = 0; const attrs = node.attrs diff --git a/packages/compiler/test/island-emit.test.ts b/packages/compiler/test/island-emit.test.ts index 4d1fd997..2902af57 100644 --- a/packages/compiler/test/island-emit.test.ts +++ b/packages/compiler/test/island-emit.test.ts @@ -51,3 +51,12 @@ test("a runtime expression prop fails the build with WRN-ISLAND-PROPS", () => { /WRN-ISLAND-PROPS/, ); }); + +test("an island inside a .wrn component also emits a marker", () => { + const source = `component Panel { + view { } +}`; + const out = generate(parse(source), { islands: new Set(["Chart"]) }); + expect(out).toContain("data-wrn-island="); + expect(out).not.toContain('data-component="Chart"'); +}); diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 4c4223cc..dc8ef39c 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -43,6 +43,7 @@ import { loadWrnServerModule, setCompileCacheDir, setCompileImportOptions, + rebuildChangedIslands, setDevCompilerPipeline, wrnBrowserArtifactUrlAsync, } from "./pipeline.ts"; @@ -685,6 +686,18 @@ export async function startServer(opts: ServeOptions): Promise { console.log(`[wrnexus] hot update — ${files.join(", ")}`); await pluginRunner.hook("hmrUpdate", files); + // Island .tsx sources are not .wrn files, so nothing below would rebuild + // them; page modules are cached, so no compile runs on the next request. + const islandFiles = files + .map((changed) => (isAbsolute(changed) ? changed : resolve(appDir, changed))) + .filter((changed) => changed.endsWith(".tsx")); + if (islandFiles.length > 0) { + try { + await rebuildChangedIslands(islandFiles); + } catch (error) { + console.warn("[wrnexus] island rebuild failed", error); + } + } const storeUpdates: Array<{ name: string; url: string; kind: string }> = []; for (const changed of files) { const absolute = isAbsolute(changed) ? changed : resolve(appDir, changed); diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index a74fb2e0..829a2818 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -130,6 +130,9 @@ function importOptionsHash(file: string): string { /** Island bundles already built this dev session, keyed by resolved source. */ const builtIslands = new Map(); +/** Island name -> resolved .tsx source, so the watcher can rebuild on edit. */ +const islandSources = new Map(); +let islandAppRoot: string | null = null; let islandRuntimeBuilt = false; /** @@ -143,8 +146,21 @@ async function ensureIslandArtifacts( ): Promise { if (islands.length === 0) return; const outDir = join(appRoot, ".wrnexus", "island"); + islandAppRoot = appRoot; + for (const island of islands) islandSources.set(island.name, island.sourcePath); - const pending = islands.filter((island) => builtIslands.get(island.name) !== island.sourcePath); + // Keyed by source AND mtime: keying on the path alone serves a stale bundle + // forever once an island .tsx is edited. + const stamp = (island: { name: string; sourcePath: string }) => { + let mtime: number; + try { + mtime = statSync(island.sourcePath).mtimeMs; + } catch { + mtime = 0; + } + return `${island.sourcePath}:${mtime}`; + }; + const pending = islands.filter((island) => builtIslands.get(island.name) !== stamp(island)); if (pending.length === 0 && islandRuntimeBuilt) return; // The runtime is built alongside the islands so they share one React copy. @@ -157,8 +173,8 @@ async function ensureIslandArtifacts( 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); + const rebuilt = pending.find((island) => island.name === asset.name); + if (rebuilt) builtIslands.set(asset.name, stamp(rebuilt)); } for (const chunk of result.sharedChunks) { registerIslandArtifact(`/__wrnexus/island/${basename(chunk)}`, chunk); @@ -169,6 +185,23 @@ async function ensureIslandArtifacts( * Island imports in this file: names so codegen emits placeholders instead of * component mounts, and sources so the bundles can be built. */ +/** + * Rebuilds islands whose .tsx source changed. + * + * Page modules are cached after the first request, so no compile runs on a + * later request and nothing else would notice an island edit. + */ +export async function rebuildChangedIslands(changed: string[]): Promise { + if (!islandAppRoot) return false; + const touched = new Set(changed.map((file) => resolve(file))); + const affected = [...islandSources] + .filter(([, sourcePath]) => touched.has(resolve(sourcePath))) + .map(([name, sourcePath]) => ({ name, sourcePath })); + if (affected.length === 0) return false; + await ensureIslandArtifacts(affected, islandAppRoot); + return true; +} + function islandsForFile( ast: PageAst, importer: string, @@ -461,6 +494,8 @@ export interface WrnCompileArtifacts { declarations: string; contract: string; rpc: string; + /** Island inputs for this file, so a cache hit can still build islands. */ + islands: string; } export interface WrnCompileMetrics { @@ -551,11 +586,14 @@ export function compileWrnArtifactsAsync(file: string, version = 0): Promise statSync(path).isFile())) { + // The islands manifest is written only by the async compile path, so it + // is not part of the completeness check — a missing manifest means "no + // islands known for this file", not a stale cache. + const requiredArtifacts = Object.entries(artifacts) + .filter(([key]) => key !== "islands") + .map(([, path]) => path); + if (requiredArtifacts.every((path) => statSync(path).isFile())) { compileMetrics.hits++; browserArtifactPaths.set(`/__wrnexus/client/${stem}.mjs`, artifacts.browser); + // A cached .wrn still needs its island bundles: the .tsx may have changed + // since, and after a restart with a warm cache nothing else would build them. + let cachedIslands: Array<{ name: string; sourcePath: string }> = []; + try { + cachedIslands = JSON.parse(readFileSync(artifacts.islands, "utf8")) as Array<{ + name: string; + sourcePath: string; + }>; + } catch { + cachedIslands = []; + } + // This variant is synchronous, so the rebuild is kicked off rather than + // awaited. The async compile path awaits it before serving a page. + void ensureIslandArtifacts(cachedIslands, projectRootForFile(file)).catch((error) => { + console.warn("[wrnexus] island rebuild failed", error); + }); return artifacts; } } catch {