diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index b6ce1e94..77ca4cc2 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -973,6 +973,10 @@ "EffectBlock", "EventDecl", "FormatWrnOptions", + "IslandBuildResult", + "IslandDiagnostic", + "IslandInput", + "IslandStrategy", "LexError", "Lexer", "LoadBlock", @@ -999,7 +1003,9 @@ "analyzeOptimizations", "analyzeRuntimeImports", "analyzeRuntimeRequirements", + "assertReactAvailable", "assertValidAst", + "buildIslands", "compilationKey", "compile", "compileNativeWrnFile", @@ -1015,19 +1021,26 @@ "generate", "generateBrowserModule", "generateDeclarations", + "generateIslandEntry", "generateNative", "generateServerFunctionsModule", "generateStoreBrowserModule", "generateStoreModule", "generateTargets", "inferredRuntimeType", + "islandNamesFrom", + "islandPropValue", "optimizeAst", "parse", + "parseIslandStrategy", + "renderIslandMarker", "resolveWrnImport", "resolveWrnImports", + "routeNeedsIslands", "rpcManifest", "runtimeCapabilities", - "runtimeTypeOf" + "runtimeTypeOf", + "serializeIslandProps" ] }, "@wrnexus/content": { @@ -2279,6 +2292,16 @@ "useWrnActions", "useWrnStore" ], + "./browser": [ + "MountOptions", + "islandRootCount", + "mountIslands", + "remountIslands", + "setStoreResolver", + "unmountIslands", + "useWrnActions", + "useWrnStore" + ], "./runtime": [ "getIslandRuntime" ] diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index c7e34baa..3c71ba67 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -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("")); } if (componentTag) { + const island = islandMarkerFor(node); + if (island) + return escLit(island); return (escLit(`
") + @@ -1330,6 +1372,9 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}`; } 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/.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 (`
`); +} +/** + * 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) { diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 1b0d8369..cc870af8 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -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); diff --git a/examples/basic-app/app/pages/island-demo.wrn b/examples/basic-app/app/pages/island-demo.wrn new file mode 100644 index 00000000..37c08369 --- /dev/null +++ b/examples/basic-app/app/pages/island-demo.wrn @@ -0,0 +1,21 @@ +import Counter from "../islands/Counter" + +// React island demo. Route: /island-demo +// +// Counter.tsx is a plain React component: the compiler emits an island +// placeholder here instead of a server-rendered component mount, and the +// browser mounts it with createRoot. +page IslandDemo { + seo { + title = "Island demo" + description = "Mounts a React island inside a server-rendered WRNexus page." + canonical = "/island-demo" + } + + view { +
+

React island

+ +
+ } +} diff --git a/examples/basic-app/app/routes.gen.ts b/examples/basic-app/app/routes.gen.ts index e51e67d3..86898346 100644 --- a/examples/basic-app/app/routes.gen.ts +++ b/examples/basic-app/app/routes.gen.ts @@ -9,6 +9,7 @@ export interface Routes { "/client-only": Record; "/dashboard": Record; "/hello": Record; + "/island-demo": Record; "/language-tools": Record; "/layout": Record; "/login": Record; @@ -31,6 +32,7 @@ export interface RouteNames { "client.only": "/client-only"; "dashboard": "/dashboard"; "hello": "/hello"; + "island.demo": "/island-demo"; "language.tools": "/language-tools"; "layout": "/layout"; "login": "/login"; @@ -122,6 +124,7 @@ export function route( "client.only": "/client-only", "dashboard": "/dashboard", "hello": "/hello", + "island.demo": "/island-demo", "language.tools": "/language-tools", "layout": "/layout", "login": "/login", diff --git a/examples/basic-app/app/types/wrnexus.generated.d.ts b/examples/basic-app/app/types/wrnexus.generated.d.ts index 0a65aa11..537a1c99 100644 --- a/examples/basic-app/app/types/wrnexus.generated.d.ts +++ b/examples/basic-app/app/types/wrnexus.generated.d.ts @@ -13,7 +13,7 @@ declare namespace WRNexusGenerated { : never; type RealtimeMessage = T extends import("@wrnexus/core").RoomDefinition ? M : unknown; type QueuePayload = T extends import("@wrnexus/queue").JobDefinition ? I : unknown; - type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui"; + type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui"; type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment"; type RealtimeRoute = "/realtime/chat" | "/realtime/hello"; type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY"; diff --git a/packages/cli/package.json b/packages/cli/package.json index 7dbff221..dea846f8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.34", + "version": "0.8.36", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index ba00ff6b..218a14bc 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -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 { const clientFiles = new Map(); const runtimeAnalysis = new Map(); const partialStaticFiles = new Set(); + /** Island component name -> resolved .tsx source, collected across all routes. */ + const discoveredIslands = new Map(); const compileWrn = async (file: string): Promise => { if (!file.endsWith(".wrn") || compiledFiles.has(file)) return; const source = readFileSync(file, "utf8"); @@ -209,7 +214,25 @@ export async function runBuild(appRoot: string): Promise { 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 { 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 { ); } - 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 { 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"), diff --git a/packages/cli/src/create.ts b/packages/cli/src/create.ts index 58ed9a26..58b8db27 100644 --- a/packages/cli/src/create.ts +++ b/packages/cli/src/create.ts @@ -121,16 +121,16 @@ Thumbs.db }, "devDependencies": { "@wrnexus/cli": "${cliVersion}", - "@eslint/js": "^9.0.0", - "@iconify-json/lucide": "^1.2.118", + "@eslint/js": "^10.0.1", + "@iconify-json/lucide": "^1.2.123", "@iconify/tailwind4": "^1.2.3", - "@tailwindcss/cli": "^4.0.0", - "@types/bun": "latest", - "eslint": "^9.0.0", - "prettier": "latest", - "tailwindcss": "^4.0.0", - "typescript": "^5.5.0", - "typescript-eslint": "latest" + "@tailwindcss/cli": "^4.3.3", + "@types/bun": "^1.3.14", + "eslint": "^10.8.1", + "prettier": "^3.9.6", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0" } } `, diff --git a/packages/cli/src/mobile.ts b/packages/cli/src/mobile.ts index 010e3f2a..a2f39483 100644 --- a/packages/cli/src/mobile.ts +++ b/packages/cli/src/mobile.ts @@ -92,7 +92,7 @@ export async function generateMobile(appRoot: string, options: MobileOptions = { devDependencies: { "@capacitor/cli": "^8.0.0", "@capacitor/assets": "^3.0.0", - typescript: "^5.5.0", + typescript: "^6.0.3", }, }; @@ -237,7 +237,7 @@ function generateNativeMobile( "react-native-safe-area-context": "^5.6.0", "react-native-screens": "^4.23.0", }, - devDependencies: { "@types/react": "^19.2.0", typescript: "^5.9.0" }, + devDependencies: { "@types/react": "^19.2.0", typescript: "^6.0.3" }, }; const expo = `import type { ExpoConfig } from "expo/config";\nimport appConfig from "../wrnexus.config.ts";\n\nconst mobile = appConfig.mobile ?? {};\nconst config: ExpoConfig = {\n name: mobile.appName ?? ${JSON.stringify(name)},\n slug: ${JSON.stringify(slug(name))},\n scheme: mobile.scheme ?? ${JSON.stringify(scheme)},\n ios: { bundleIdentifier: mobile.appId ?? ${JSON.stringify(appId)} },\n android: { package: mobile.appId ?? ${JSON.stringify(appId)} },\n plugins: ["expo-router"],\n ...(mobile.expo ?? {}),\n};\nexport default config;\n`; const env = `/** Shared connection settings for native screens. */\nexport const API_URL = process.env.EXPO_PUBLIC_WRNEXUS_URL ?? ${JSON.stringify(apiUrl)};\nexport async function api(path: string, init?: RequestInit): Promise {\n const response = await fetch(new URL(path, API_URL), init);\n if (!response.ok) throw new Error(\`WrNexus request failed: \${response.status}\`);\n return response.json() as Promise;\n}\nexport function realtimeUrl(path: string): string {\n const url = new URL(path, API_URL);\n url.protocol = url.protocol === "https:" ? "wss:" : "ws:";\n return url.toString();\n}\n`; diff --git a/packages/cli/src/system.ts b/packages/cli/src/system.ts index 39b1f66e..9c057467 100644 --- a/packages/cli/src/system.ts +++ b/packages/cli/src/system.ts @@ -53,7 +53,7 @@ export function generateSystem(rootDir: string, input: string): string[] { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", }, - devDependencies: { "@types/bun": "latest", typescript: "^5.9.2" }, + devDependencies: { "@types/bun": "^1.3.14", typescript: "^6.0.3" }, wrnexus: { plugin: { plugin: "./src/plugin.ts", export: "default", factory: true }, }, diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts index 6d6ae998..61595225 100644 --- a/packages/cli/src/workspace.ts +++ b/packages/cli/src/workspace.ts @@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { scaffoldApp } from "./create.ts"; +import { scaffoldApp, scaffoldFrameworkRange } from "./create.ts"; import { currentCliVersion } from "./update-notifier.ts"; import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server"; @@ -88,12 +88,12 @@ export const workspaceFiles = (name: string): Record => ({ }, "devDependencies": { "@wrnexus/cli": "${frameworkVersion}", - "@eslint/js": "^9.0.0", - "@types/bun": "latest", - "eslint": "^9.0.0", - "prettier": "latest", - "typescript": "^5.5.0", - "typescript-eslint": "latest" + "@eslint/js": "^10.0.1", + "@types/bun": "^1.3.14", + "eslint": "^10.8.1", + "prettier": "^3.9.6", + "typescript": "^6.0.3", + "typescript-eslint": "^8.67.0" } } `, @@ -250,7 +250,7 @@ export default tseslint.config( "test": "bun test" }, "dependencies": { - "@wrnexus/pubsub": "${frameworkVersion}" + "@wrnexus/pubsub": "${scaffoldFrameworkRange}" } } `, diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts index 2e3d0bed..c0a680b7 100644 --- a/packages/cli/test/create.test.ts +++ b/packages/cli/test/create.test.ts @@ -147,7 +147,7 @@ test("scaffoldApp uses compatible framework packages and pins the current CLI", } expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version); expect(pkg.devDependencies["@iconify/tailwind4"]).toBe("^1.2.3"); - expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.118"); + expect(pkg.devDependencies["@iconify-json/lucide"]).toBe("^1.2.123"); const globalCss = readFileSync(join(root, "app", "styles", "global.css"), "utf8"); expect(globalCss).toContain('@plugin "@iconify/tailwind4";'); expect(globalCss).toContain('"Plus Jakarta Sans"'); diff --git a/packages/cli/test/plugin-runtime-production.test.ts b/packages/cli/test/plugin-runtime-production.test.ts index 3096f792..096d9f05 100644 --- a/packages/cli/test/plugin-runtime-production.test.ts +++ b/packages/cli/test/plugin-runtime-production.test.ts @@ -69,7 +69,9 @@ export default { } if (port === undefined) { - throw new Error(`production server failed to start: ${await new Response(proc.stderr).text()}`); + throw new Error( + `production server failed to start: ${await new Response(proc.stderr).text()}`, + ); } const page = await fetch(`http://127.0.0.1:${port}/`); diff --git a/packages/cli/test/workspace.test.ts b/packages/cli/test/workspace.test.ts index 11cdc4b7..2b9c20ce 100644 --- a/packages/cli/test/workspace.test.ts +++ b/packages/cli/test/workspace.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { currentCliVersion } from "../src/update-notifier.ts"; +import { scaffoldFrameworkRange } from "../src/create.ts"; import { addWorkspaceApp, insertWorkspaceApp, @@ -36,14 +37,14 @@ test("workspace environments preserve runtime and HMR policy", () => { expect(resolved.apps[0]?.publicOrigin).toBe("https://www.staging.example.com"); }); -test("workspace templates pin the running framework release", () => { +test("workspace templates pin the CLI and use compatible independent package ranges", () => { const files = workspaceFiles("acme"); const rootPackage = JSON.parse(files["package.json"]!); const sharedPackage = JSON.parse(files["packages/shared/package.json"]!); const version = currentCliVersion(); expect(rootPackage.devDependencies["@wrnexus/cli"]).toBe(version); - expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(version); + expect(sharedPackage.dependencies["@wrnexus/pubsub"]).toBe(scaffoldFrameworkRange); expect(files["README.md"]).toContain("http://127.0.0.1:3000"); expect(files["README.md"]).toContain("internal gateway targets"); expect(JSON.parse(files["package.json"]!).scripts.production).toBe("wrnexus production"); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index e32440a3..9c7bf622 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -7,7 +7,6 @@ ".": "./src/index.ts" }, "dependencies": { - "@wrnexus/core": "workspace:*", "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", "@wrnexus/syntax": "workspace:*", diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 296cb3a8..8cd4a543 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -22,6 +22,12 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts"; import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax"; import { generateStoreModule } from "./store-codegen.ts"; import { optimizeAst } from "./analysis.ts"; +import { + islandPropValue, + parseIslandStrategy, + renderIslandMarker, + serializeIslandProps, +} from "./island-codegen.ts"; import { browserModuleRequired } from "./client-codegen.ts"; interface RenderBinding { @@ -43,6 +49,48 @@ interface NamedDataBinding extends RenderBinding { mode: DataMode; } +/** + * 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: ReadonlySet = 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: { + tag: string; + attrs: Array<{ name: string; value?: string }>; +}): string | null { + if (!currentIslands.has(node.tag)) return null; + const directives = node.attrs + .map((attr) => attr.name) + .filter((name) => name.startsWith("client:")); + const props: Record = {}; + for (const attr of node.attrs) { + if (attr.name.startsWith("client:")) continue; + const parsed = 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 = serializeIslandProps(node.tag, props); + if ("diagnostic" in serialized) throw new Error(serialized.diagnostic.message); + return renderIslandMarker({ + name: node.tag, + strategy: parseIslandStrategy(directives), + propsJson: serialized.json, + }); +} + function isComponentTag(tag: string): boolean { return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); } @@ -448,6 +496,8 @@ function renderLoopBody(node: ViewNode): string { } if (componentTag) { + const island = islandMarkerFor(node); + if (island) return escLit(island); return ( escLit(`
attr.name !== "data-component") .map((attr) => renderPageComponentAttr(attr, loops)) @@ -1255,7 +1308,21 @@ function markServerAsyncBoundaries(nodes: ViewNode[], serverLoads: ReadonlySet; +} + +export function generate(ast: PageAst, options: GenerateOptions = {}): string { + currentIslands = options.islands ?? new Set(); + try { + return generateInner(ast); + } finally { + currentIslands = new Set(); + } +} + +function generateInner(ast: PageAst): string { ast = optimizeAst(ast).ast; if (ast.kind === "global-store" || ast.kind === "page-store") return generateStoreModule(ast); if (ast.kind === "component" || ast.kind === "layout") { diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index d35211fb..f9cad94f 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -126,3 +126,15 @@ export function compile(source: string, filePath = ""): CompileResu } export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts"; export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts"; + +export { + islandNamesFrom, + islandPropValue, + parseIslandStrategy, + renderIslandMarker, + serializeIslandProps, +} from "./island-codegen.ts"; +export type { IslandDiagnostic, IslandStrategy } from "./island-codegen.ts"; +export { assertReactAvailable, buildIslands, generateIslandEntry } from "./island-bundle.ts"; +export type { IslandBuildResult, IslandInput } from "./island-bundle.ts"; +export { routeNeedsIslands } from "./analysis.ts"; diff --git a/packages/compiler/src/island-bundle.ts b/packages/compiler/src/island-bundle.ts index c6463334..9b191d35 100644 --- a/packages/compiler/src/island-bundle.ts +++ b/packages/compiler/src/island-bundle.ts @@ -82,6 +82,16 @@ export function assertReactAvailable( export async function buildIslands(input: { islands: IslandInput[]; outDir: string; + /** + * App root used to resolve the island mount runtime. When given, the runtime + * is emitted as `runtime.js` in the SAME build as the islands. + * + * This is not a convenience: building the runtime separately gives it its own + * copy of React, and a component rendered by one copy while importing hooks + * from another fails with "Cannot read properties of null (reading + * 'useState')". One build with splitting keeps React in a single shared chunk. + */ + appRoot?: string; }): Promise { if (input.islands.length === 0) return { assets: [], sharedChunks: [] }; @@ -100,9 +110,22 @@ export async function buildIslands(input: { writeFileSync(join(entryDir, `${island.name}.tsx`), generateIslandEntry(island), "utf8"); } + const entrypoints = input.islands.map((island) => join(entryDir, `${island.name}.tsx`)); + if (input.appRoot) { + const resolveFrom = createRequire(join(input.appRoot, "package.json")); + const runtimeEntry = join(entryDir, "runtime.ts"); + writeFileSync( + runtimeEntry, + `export * from ${JSON.stringify(resolveFrom.resolve("@wrnexus/react/browser"))}; +`, + "utf8", + ); + entrypoints.push(runtimeEntry); + } + try { const result = await Bun.build({ - entrypoints: input.islands.map((island) => join(entryDir, `${island.name}.tsx`)), + entrypoints, outdir: input.outDir, target: "browser", format: "esm", @@ -123,6 +146,7 @@ export async function buildIslands(input: { // Bun names an entry's output after its entry file, so the basename // identifies the island unambiguously. const stem = basename(output.path).replace(/\.js$/, ""); + if (stem === "runtime") continue; if (!islandNames.has(stem)) continue; assets.push({ name: stem, diff --git a/packages/compiler/src/island-codegen.ts b/packages/compiler/src/island-codegen.ts index afdd4f3e..dac864be 100644 --- a/packages/compiler/src/island-codegen.ts +++ b/packages/compiler/src/island-codegen.ts @@ -1,4 +1,23 @@ -import { escapeHtml, isSafeIslandName } from "@wrnexus/core"; +// 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: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]!); +} + +const SAFE_ISLAND_NAME = /^[A-Za-z0-9_-]+$/; + +function isSafeIslandName(name: string): boolean { + return SAFE_ISLAND_NAME.test(name); +} export type IslandStrategy = "only" | "load" | "visible" | "idle"; @@ -35,6 +54,28 @@ function unsupportedProp(value: unknown): boolean { return Object.values(value as Record).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. + */ +export function islandPropValue(raw: string | undefined): { value: unknown } | { dynamic: string } { + 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) as unknown }; + } catch { + return { dynamic: inner }; + } +} + export function serializeIslandProps( componentName: string, props: Record, @@ -80,3 +121,21 @@ export function renderIslandMarker(input: { ` data-wrn-island-props="${escapeHtml(input.propsJson)}">
` ); } + +/** + * 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. + */ +export function islandNamesFrom( + imports: Array<{ kind?: "island"; declaration: { defaultImport?: string } }>, +): Set { + 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; +} diff --git a/packages/compiler/test/island-classification.test.ts b/packages/compiler/test/island-classification.test.ts index 868b4b90..8609b55a 100644 --- a/packages/compiler/test/island-classification.test.ts +++ b/packages/compiler/test/island-classification.test.ts @@ -12,9 +12,9 @@ test("a route with an island import needs client JavaScript", () => { }); test("a route with no island imports stays zero-JS", () => { - expect(routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }])).toBe( - false, - ); + expect( + routeNeedsIslands([{ declaration: { source: "./a" } as any, resolved: "/app/a.ts" }]), + ).toBe(false); }); test("an empty import list stays zero-JS", () => { diff --git a/packages/compiler/test/island-codegen.test.ts b/packages/compiler/test/island-codegen.test.ts index 8883d83a..1bfe6927 100644 --- a/packages/compiler/test/island-codegen.test.ts +++ b/packages/compiler/test/island-codegen.test.ts @@ -1,5 +1,7 @@ import { expect, test } from "bun:test"; import { + islandNamesFrom, + islandPropValue, parseIslandStrategy, renderIslandMarker, serializeIslandProps, @@ -71,3 +73,28 @@ test("renders a marker with escaped props", () => { 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()" }); +}); diff --git a/packages/compiler/test/island-emit.test.ts b/packages/compiler/test/island-emit.test.ts new file mode 100644 index 00000000..4d1fd997 --- /dev/null +++ b/packages/compiler/test/island-emit.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generate } from "../src/codegen.ts"; + +const SOURCE = `page Home { + view { + + plain + } +}`; + +test("an island tag emits an island marker instead of a component mount", () => { + const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) }); + + expect(out).toContain("data-wrn-island="); + expect(out).toContain("Chart"); + expect(out).toContain('data-wrn-island-strategy="visible"'); + // Non-island components still mount the normal way. + expect(out).toContain('data-component="Card"'); +}); + +test("without the island set the same tag stays a normal component", () => { + const out = generate(parse(SOURCE)); + + expect(out).not.toContain("data-wrn-island="); + expect(out).toContain('data-component="Chart"'); +}); + +test("island props are serialized into the marker", () => { + const out = generate(parse(SOURCE), { islands: new Set(["Chart"]) }); + expect(out).toContain("Revenue"); +}); + +test("numeric and boolean island props keep their types through the marker", () => { + const source = `page Home { + view { } +}`; + const out = generate(parse(source), { islands: new Set(["Chart"]) }); + + expect(out).toContain(""start":3"); + expect(out).toContain(""live":true"); + expect(out).toContain(""title":"Revenue""); +}); + +test("a runtime expression prop fails the build with WRN-ISLAND-PROPS", () => { + const source = `page Home { + state count = 1 + view { } +}`; + expect(() => generate(parse(source), { islands: new Set(["Chart"]) })).toThrow( + /WRN-ISLAND-PROPS/, + ); +}); diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index d89b5f6a..b43da153 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.32", + "version": "0.8.33", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index 466a20ea..a74fb2e0 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -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(); +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 { + 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; 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 { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); +}); + +function app() { + const root = mkdtempSync(join(process.cwd(), ".island-e2e-")); + created.push(root); + mkdirSync(join(root, "app"), { recursive: true }); + writeFileSync( + join(root, "app", "Chart.tsx"), + `export default function Chart({ title }: { title: string }) { return
{title}
; }`, + ); + writeFileSync(join(root, "app", "Card.wrn"), `component Card { view {
card
} }`); + return root; +} + +test("a .wrn importing a .tsx emits an island marker and requests the bootstrap", () => { + const root = app(); + const page = join(root, "app", "page.wrn"); + const source = [ + 'import Chart from "./Chart"', + 'import Card from "./Card"', + "page Home {", + " view {", + ' ', + " ", + " }", + "}", + ].join("\n"); + writeFileSync(page, source); + + const ast = compile(source, page).ast; + const islands = islandNamesFrom( + resolveWrnImports(ast.structuredImports, page, { appRoot: root }), + ); + + // Only the .tsx import is an island; the .wrn component is not. + expect([...islands]).toEqual(["Chart"]); + + const out = generate(ast, { islands }); + expect(out).toContain('data-wrn-island="Chart"'); + expect(out).toContain('data-wrn-island-strategy="visible"'); + expect(out).toContain('data-component="Card"'); + + // Rendered island markup must pull in the island bootstrap. + expect(collectScripts('
')).toContain("/__wrnexus/islands.js"); +}); + +test("a page with no .tsx imports emits no island markup and no island script", () => { + const root = app(); + const page = join(root, "app", "plain.wrn"); + const source = ['import Card from "./Card"', "page Plain {", " view { }", "}"].join( + "\n", + ); + writeFileSync(page, source); + + const ast = compile(source, page).ast; + const islands = islandNamesFrom( + resolveWrnImports(ast.structuredImports, page, { appRoot: root }), + ); + expect(islands.size).toBe(0); + + const out = generate(ast, { islands }); + expect(out).not.toContain("data-wrn-island"); + expect(collectScripts(out)).not.toContain("/__wrnexus/islands.js"); +}); diff --git a/packages/dev-server/test/island-scripts.test.ts b/packages/dev-server/test/island-scripts.test.ts new file mode 100644 index 00000000..5514a530 --- /dev/null +++ b/packages/dev-server/test/island-scripts.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; +import { collectScripts } from "../src/script-selection.ts"; + +test("island markup pulls in the island bootstrap", () => { + const scripts = collectScripts(`
`); + expect(scripts).toContain("/__wrnexus/islands.js"); +}); + +test("markup without islands ships no island bootstrap", () => { + expect(collectScripts(`

plain server html

`)).toEqual([]); +}); + +test("islands alone do not pull in the reactive runtime", () => { + const scripts = collectScripts(`
`); + expect(scripts).not.toContain("/__wrnexus/reactive.js"); +}); diff --git a/packages/dev-server/test/not-found-runtime.test.ts b/packages/dev-server/test/not-found-runtime.test.ts index 74090c6a..f9ab5226 100644 --- a/packages/dev-server/test/not-found-runtime.test.ts +++ b/packages/dev-server/test/not-found-runtime.test.ts @@ -22,7 +22,10 @@ function customNotFoundRuntime() { router: buildRouter(app), loadModule: async (file) => file.includes(`${join("api", "404")}.ts`) - ? { GET: () => Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }) } + ? { + GET: () => + Response.json({ code: "CUSTOM_NOT_FOUND" }, { headers: { "x-custom": "yes" } }), + } : { default: () => "

That page is gone

" }, getMiddleware: async () => [], assets: { serve: async () => null }, diff --git a/packages/react/package.json b/packages/react/package.json index 12243e1f..ba978633 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -5,15 +5,20 @@ "main": "src/index.ts", "exports": { ".": "./src/index.ts", - "./runtime": "./src/runtime-source.ts" + "./runtime": "./src/runtime-source.ts", + "./browser": "./src/browser.ts" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "peerDependenciesMeta": { - "react": { "optional": true }, - "react-dom": { "optional": true } + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } }, "dependencies": { "@wrnexus/store": "workspace:*" diff --git a/packages/react/src/browser.ts b/packages/react/src/browser.ts new file mode 100644 index 00000000..c4af83bf --- /dev/null +++ b/packages/react/src/browser.ts @@ -0,0 +1,8 @@ +/** + * Browser entry for the island mount runtime, served at + * `/__wrnexus/island/runtime.js` and imported by the bootstrap only when a + * `data-wrn-island` marker is present. + */ +export { islandRootCount, mountIslands, remountIslands, unmountIslands } from "./island-runtime.ts"; +export type { MountOptions } from "./island-runtime.ts"; +export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts"; diff --git a/packages/react/src/error-boundary.tsx b/packages/react/src/error-boundary.tsx index 032e6692..31800e75 100644 --- a/packages/react/src/error-boundary.tsx +++ b/packages/react/src/error-boundary.tsx @@ -41,4 +41,4 @@ export class IslandErrorBoundary extends Component<
); } -} \ No newline at end of file +} diff --git a/packages/react/test/island-hmr.test.ts b/packages/react/test/island-hmr.test.ts index 37c02bf8..56cee2b3 100644 --- a/packages/react/test/island-hmr.test.ts +++ b/packages/react/test/island-hmr.test.ts @@ -1,7 +1,12 @@ import { afterEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act, createElement } from "react"; -import { islandRootCount, mountIslands, remountIslands, unmountIslands } from "../src/island-runtime.ts"; +import { + islandRootCount, + mountIslands, + remountIslands, + unmountIslands, +} from "../src/island-runtime.ts"; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/scripts/build-editor-compiler.mjs b/scripts/build-editor-compiler.mjs index 6a28ff64..93c7b23b 100644 --- a/scripts/build-editor-compiler.mjs +++ b/scripts/build-editor-compiler.mjs @@ -34,28 +34,13 @@ function loadTypeScript() { const ts = loadTypeScript(); -// React island modules are not used by the editor: island-bundle.ts calls -// Bun.build and island-codegen.ts imports @wrnexus/core, neither of which -// exists in this Node-only bundle. They are unreachable from the editor entry, -// so excluding them keeps Bun-only code out of the extension entirely. -const EDITOR_EXCLUDED = ["island-bundle.ts", "island-codegen.ts"]; -function isEditorExcluded(path) { - return EDITOR_EXCLUDED.some((name) => path.endsWith(name)); -} - function walk(dir) { const files = []; for (const entry of readdirSync(dir)) { const path = join(dir, entry); const stat = statSync(path); if (stat.isDirectory()) files.push(...walk(path)); - else if ( - stat.isFile() && - path.endsWith(".ts") && - !path.endsWith(".test.ts") && - !isEditorExcluded(path) - ) - files.push(path); + else if (stat.isFile() && path.endsWith(".ts") && !path.endsWith(".test.ts")) files.push(path); } return files; } diff --git a/tsconfig.json b/tsconfig.json index dc398dc5..70ce51a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "@wrnexus/core/jsx-dev-runtime": ["./packages/core/src/jsx-dev-runtime.ts"], "@wrnexus/react": ["./packages/react/src/index.ts"], "@wrnexus/react/runtime": ["./packages/react/src/runtime-source.ts"], + "@wrnexus/react/browser": ["./packages/react/src/browser.ts"], "@wrnexus/reactive": ["./packages/reactive/src/index.ts"], "@wrnexus/graphql": ["./packages/graphql/src/index.ts"], "@wrnexus/graphql/*": ["./packages/graphql/src/*.ts"],