diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index d441f0df..c293958b 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -23,7 +23,7 @@ import { } from "node:fs"; import { basename, dirname, extname, join, relative, resolve } from "node:path"; import { buildRouter, type Route } from "@wrnexus/router"; -import { getReactiveRuntime } from "@wrnexus/csr"; +import { getComponentControllerRuntime, getReactiveRuntime } from "@wrnexus/csr"; import { analyzeRuntimeImports, analyzeRuntimeRequirements, @@ -109,6 +109,7 @@ export async function runBuild(appRoot: string): Promise { const compiledDir = join(distDir, "compiled"); const clientModulesDir = join(distDir, "client"); const reactivePath = join(distDir, "reactive.js"); + const controllersPath = join(distDir, "controllers.js"); const publicDir = join(root, "public"); const distPublicDir = join(distDir, "public"); const config = await loadAppConfig(root); @@ -477,6 +478,13 @@ export async function runBuild(appRoot: string): Promise { ); assetHash.update(reactiveCode); console.log(`✓ Runtime: ${reactivePath}`); + const controllerCode = await buildBrowserRuntime( + getComponentControllerRuntime(), + controllersPath, + join(compiledDir, "controllers.entry.js"), + ); + assetHash.update(controllerCode); + console.log(`✓ Controllers: ${controllersPath}`); // 1a) Theme tokens + client switcher (always emitted; built-in light/dark). const theme = resolveThemeConfig(config.theme); @@ -693,6 +701,7 @@ await createProductionServer( }, { reactivePath: join(import.meta.dir, "reactive.js"), + controllersPath: join(import.meta.dir, "controllers.js"), clientModulesDir: join(import.meta.dir, "client"), themePath: join(import.meta.dir, "theme.css"), themeJsPath: join(import.meta.dir, "theme.js"), diff --git a/packages/csr/src/index.ts b/packages/csr/src/index.ts index 965aad54..34a4341a 100644 --- a/packages/csr/src/index.ts +++ b/packages/csr/src/index.ts @@ -17,12 +17,14 @@ export { NAV_RUNTIME } from "./nav-runtime.ts"; export { REALTIME_RUNTIME } from "./realtime-runtime.ts"; export { ACTION_RUNTIME } from "./action-runtime.ts"; -/** The reactive runtime served at `/__wrnexus/reactive.js` (plain browser JS). */ -export function getReactiveRuntime(development = false): string { +const CONTROLLER_SECTIONS = ["PRIMARY", "UI", "PIN"] as const; + +function runtimeForMode(development: boolean): string { if (development) { - return REACTIVE_RUNTIME - .replace(/\/\*__WRNEXUS_DEV_START__\*\//g, "") - .replace(/\/\*__WRNEXUS_DEV_END__\*\//g, ""); + return REACTIVE_RUNTIME.replace(/\/\*__WRNEXUS_DEV_START__\*\//g, "").replace( + /\/\*__WRNEXUS_DEV_END__\*\//g, + "", + ); } return REACTIVE_RUNTIME.replace( /\/\*__WRNEXUS_DEV_START__\*\/[\s\S]*?\/\*__WRNEXUS_DEV_END__\*\//g, @@ -30,6 +32,118 @@ export function getReactiveRuntime(development = false): string { ); } +function controllerSection(source: string, name: (typeof CONTROLLER_SECTIONS)[number]): string { + const pattern = new RegExp( + `/\\*__WRNEXUS_CONTROLLERS_${name}_START__\\*/([\\s\\S]*?)/\\*__WRNEXUS_CONTROLLERS_${name}_END__\\*/`, + ); + const match = pattern.exec(source); + if (!match?.[1]) throw new Error(`Missing reactive runtime controller section: ${name}`); + return match[1]; +} + +function stripControllerSections(source: string): string { + for (const name of CONTROLLER_SECTIONS) { + source = source.replace( + new RegExp( + `/\\*__WRNEXUS_CONTROLLERS_${name}_START__\\*/[\\s\\S]*?/\\*__WRNEXUS_CONTROLLERS_${name}_END__\\*/`, + ), + "", + ); + } + return source; +} + +const CONTROLLER_LOADER = String.raw` + var componentControllerSelector = "[data-wrn-anchored],[data-wrn-dialog],[data-wrn-roving],[data-wrn-scrollspy],[data-wrn-splitter],[data-wrn-navbar],[data-wrn-preferences],[data-wrn-select],[data-wrn-pin-input]"; + var componentControllerPromise = null; + var componentControllerUrl = (document.currentScript && document.currentScript.src) + ? new URL("./controllers.js", document.currentScript.src).href + : "/__wrnexus/controllers.js"; + function needsComponentControllers(root) { + var host = root || document; + return !!((host.matches && host.matches(componentControllerSelector)) || + (host.querySelector && host.querySelector(componentControllerSelector))); + } + function loadComponentControllers(root) { + if (!needsComponentControllers(root)) return; + if (window.__wrnexusComponentControllers) { + window.__wrnexusComponentControllers.hydrate(root || document); + return; + } + if (!componentControllerPromise) { + componentControllerPromise = new Promise(function (resolve, reject) { + var script = document.createElement("script"); + script.src = componentControllerUrl; + script.defer = true; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); + } + componentControllerPromise.then(function () { + if (window.__wrnexusComponentControllers) window.__wrnexusComponentControllers.hydrate(root || document); + }).catch(function (error) { console.error("[wrnexus] failed to load component controllers", error); }); + } +`; + +/** The reactive runtime served at `/__wrnexus/reactive.js` (plain browser JS). */ +export function getReactiveRuntime(development = false): string { + let source = stripControllerSections(runtimeForMode(development)); + source = source.replace(" function hydrateScopes(root) {", `${CONTROLLER_LOADER}\n function hydrateScopes(root) {`); + source = source.replace( + " hydrateNavbarControllers(host);\n hydratePreferenceControllers(host);\n hydrateSelectControllers(host);\n hydratePinInputControllers(host);", + " loadComponentControllers(host);", + ); + source = source.replace(" startDocumentWatch();\n", ""); + source = source.replace( + " setupAnchoredOverlays();\n setupModalDialogs();\n setupRovingFocus();\n setupScrollspy();\n setupSplitters();", + " loadComponentControllers(document);", + ); + source = source.replace( + " window.__wrnexusRepositionAnchored = repositionAnchored;", + " window.__wrnexusRepositionAnchored = function (root) { if (window.__wrnexusComponentControllers) window.__wrnexusComponentControllers.reposition(root); };", + ); + source = source.replace( + " window.__wrnexusMountClientRoots = mountClientRoots;", + ` window.__wrnexusControllerBridge = { invokeComponentOutput: invokeComponentOutput, callServerFunction: callServerFunction, dispatchComponentEvent: dispatchComponentEvent, emitPinInputEvent: emitPinInputEvent, parseScopeDecl: parseScopeDecl, warn: ${development ? "warnOnce" : "function () {}"} };\n window.__wrnexusMountClientRoots = mountClientRoots;`, + ); + return source; +} + +/** Component-specific controllers, loaded only when their marker is present. */ +export function getComponentControllerRuntime(development = false): string { + const source = runtimeForMode(development); + const sections = CONTROLLER_SECTIONS.map((name) => controllerSection(source, name)).join("\n"); + return ` +(function () { + "use strict"; + var bridge = window.__wrnexusControllerBridge || {}; + var invokeComponentOutput = bridge.invokeComponentOutput; + var callServerFunction = bridge.callServerFunction; + var dispatchComponentEvent = bridge.dispatchComponentEvent; + var emitPinInputEvent = bridge.emitPinInputEvent; + var parseScopeDecl = bridge.parseScopeDecl; + var warnOnce = bridge.warn || function () {}; +${sections} + function hydrate(root) { + var host = root || document; + hydrateNavbarControllers(host); + hydratePreferenceControllers(host); + hydrateSelectControllers(host); + hydratePinInputControllers(host); + syncRovingGroups(host); + } + setupAnchoredOverlays(); + setupModalDialogs(); + setupRovingFocus(); + setupScrollspy(); + setupSplitters(); + window.__wrnexusComponentControllers = { hydrate: hydrate, reposition: repositionAnchored }; + hydrate(document); +})(); +`.trim(); +} + /** The client-side navigation runtime served at `/__wrnexus/nav.js`. */ export function getNavRuntime(): string { return NAV_RUNTIME; diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index b13ef6f4..c9104d87 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -2864,6 +2864,7 @@ export const REACTIVE_RUNTIME = String.raw` * cleared, so repositioning is idempotent -- re-running it on a panel that * is already on screen changes nothing and cannot drift. */ + /*__WRNEXUS_CONTROLLERS_PRIMARY_START__*/ var ANCHORED_SELECTOR = "[data-wrn-anchored]"; var ANCHOR_MARGIN = 8; var anchoredScheduled = false; @@ -3480,6 +3481,8 @@ export const REACTIVE_RUNTIME = String.raw` document.addEventListener("pointercancel", endDrag); } + /*__WRNEXUS_CONTROLLERS_PRIMARY_END__*/ + function hydrateScopes(root) { var host = root || document; @@ -3510,6 +3513,7 @@ export const REACTIVE_RUNTIME = String.raw` /*__WRNEXUS_DEV_END__*/ } + /*__WRNEXUS_CONTROLLERS_UI_START__*/ var navbarOutsideClickBound = false; var preferenceOutsideClickBound = false; @@ -4028,6 +4032,8 @@ export const REACTIVE_RUNTIME = String.raw` } } + /*__WRNEXUS_CONTROLLERS_UI_END__*/ + function invokeComponentOutput(root, name, payload) { if (!root || !name) return undefined; var registry = root.__wrnexusOutputHandlers; @@ -4135,6 +4141,7 @@ export const REACTIVE_RUNTIME = String.raw` } } + /*__WRNEXUS_CONTROLLERS_PIN_START__*/ function setupPinInputController(root) { if (!root || root.__wrnexusPinInputController) return; var cells = Array.prototype.slice.call(root.querySelectorAll("[data-pin-cell]")); @@ -4310,6 +4317,8 @@ export const REACTIVE_RUNTIME = String.raw` inputs.forEach(setupPinInputController); } + /*__WRNEXUS_CONTROLLERS_PIN_END__*/ + function parseScopeDecl(decl) { var initial = {}; splitTopLevel(decl, ",").forEach(function (part) { diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index cc32796b..9a2d089a 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -1,11 +1,11 @@ import { test, expect, beforeEach } from "bun:test"; import { Window } from "happy-dom"; import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts"; -import { getReactiveRuntime } from "../src/index.ts"; +import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts"; import { mountHtml } from "@wrnexus/test"; // Fresh DOM per test, with the runtime's globals bound. -function mount(html: string): Window { +function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window { const win = new Window() as unknown as Window & Record; win.document.body.innerHTML = `
${html}
`; (globalThis as Record).window = win; @@ -25,7 +25,8 @@ function mount(html: string): Window { (globalThis as Record).CustomEvent = ( win as unknown as { CustomEvent: unknown } ).CustomEvent; - (0, eval)(REACTIVE_RUNTIME); + (0, eval)(runtime); + if (controllers) (0, eval)(controllers); // Hydrate deterministically (auto-init waits on DOMContentLoaded, which the // test window may not fire). setupScope is idempotent, so this is safe. const w = win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }; @@ -41,6 +42,28 @@ beforeEach(() => { delete (globalThis as Record).MutationObserver; }); +test("split runtime hydrates a controller only from the controller asset", () => { + const core = getReactiveRuntime(true); + const controllers = getComponentControllerRuntime(true); + expect(core).not.toContain("function setupRovingFocus"); + expect(controllers).toContain("function setupRovingFocus"); + + const win = mount( + `
`, + core, + controllers, + ); + const buttons = win.document.querySelectorAll("button"); + (buttons[0] as unknown as HTMLButtonElement).focus(); + buttons[0]!.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(win.document.activeElement).toBe(buttons[1]); +}); + +test("a page without controller markers does not request the controller asset", () => { + const win = mount(`
{count}
`, getReactiveRuntime(true)); + expect(win.document.querySelector('script[src$="controllers.js"]')).toBeNull(); +}); + test("hydrates {expr} mustaches from data-scope", () => { const win = mount(`
{count}, {count * 2}
`); expect(win.document.querySelector("span")!.textContent).toBe("0, 0"); diff --git a/packages/dev-server/src/assets.ts b/packages/dev-server/src/assets.ts index 0b23a33a..6967e5ba 100644 --- a/packages/dev-server/src/assets.ts +++ b/packages/dev-server/src/assets.ts @@ -12,6 +12,7 @@ import { getActionRuntime, + getComponentControllerRuntime, getReactiveRuntime, getNavRuntime, getRealtimeRuntime, @@ -94,6 +95,8 @@ export function createDevAssetServer( return serveWrnBrowserArtifact(pathname) ?? new Response("Not Found", { status: 404 }); } if (pathname === "/__wrnexus/reactive.js") return jsResponse(getReactiveRuntime(true)); + if (pathname === "/__wrnexus/controllers.js") + return jsResponse(getComponentControllerRuntime(true)); if (pathname === "/__wrnexus/nav.js") return jsResponse(getNavRuntime()); if (pathname === "/__wrnexus/realtime.js") return jsResponse(getRealtimeRuntime()); if (pathname === "/__wrnexus/actions.js") return jsResponse(getActionRuntime()); diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 4f7fdd97..237598a5 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -20,6 +20,7 @@ import { } from "@wrnexus/router"; import { getActionRuntime, + getComponentControllerRuntime, getReactiveRuntime, getNavRuntime, getRealtimeRuntime, @@ -92,6 +93,8 @@ export interface ProdOptions { stylesIncludeFramework?: boolean; /** Absolute path to the pre-built reactive runtime. */ reactivePath?: string; + /** Absolute path to the on-demand component controller runtime. */ + controllersPath?: string; /** Absolute directory containing bundled per-WRN browser modules. */ clientModulesDir?: string; /** Absolute path to the pre-built theme stylesheet (`theme.css`). */ @@ -341,6 +344,10 @@ function createProdAssetServer(opts: ProdOptions): AssetServer { } return new Response(getReactiveRuntime(), { headers: JS_HEADERS }); } + if (pathname === "/__wrnexus/controllers.js") { + if (opts.controllersPath) return serveFile(opts.controllersPath, JS_HEADERS); + return new Response(getComponentControllerRuntime(), { headers: JS_HEADERS }); + } if (pathname === "/__wrnexus/nav.js") return new Response(getNavRuntime(), { headers: JS_HEADERS }); if (pathname === "/__wrnexus/realtime.js") diff --git a/scripts/lib/measure-runtime-size.ts b/scripts/lib/measure-runtime-size.ts index c5879e97..4dae9a6d 100644 --- a/scripts/lib/measure-runtime-size.ts +++ b/scripts/lib/measure-runtime-size.ts @@ -12,12 +12,14 @@ */ import { getReactiveRuntime, + getComponentControllerRuntime, getNavRuntime, getRealtimeRuntime, } from "../../packages/csr/src/index.ts"; const runtimes: Record = { "reactive-runtime.ts": getReactiveRuntime(), + "component-controllers.ts": getComponentControllerRuntime(), "nav-runtime.ts": getNavRuntime(), "realtime-runtime.ts": getRealtimeRuntime(), }; diff --git a/scripts/security-performance-audit.mjs b/scripts/security-performance-audit.mjs index bd30e38f..1a8cc729 100644 --- a/scripts/security-performance-audit.mjs +++ b/scripts/security-performance-audit.mjs @@ -169,7 +169,8 @@ addCheck( * minified transfer once. That is the number worth defending. */ const runtimeBudgets = { - "reactive-runtime.ts": 71_000, + "reactive-runtime.ts": 49_000, + "component-controllers.ts": 24_100, "nav-runtime.ts": 12_000, "realtime-runtime.ts": 8_000, }; @@ -182,7 +183,8 @@ const minifiedSizes = JSON.parse( ); for (const [file, budget] of Object.entries(runtimeBudgets)) { const bytes = minifiedSizes[file]; - const raw = statSync(join(root, "packages", "csr", "src", file)).size; + const sourceFile = file === "component-controllers.ts" ? "reactive-runtime.ts" : file; + const raw = statSync(join(root, "packages", "csr", "src", sourceFile)).size; addCheck( `PERF-RUNTIME-SIZE-${file.replace(/-runtime\.ts$/, "").toUpperCase()}`, typeof bytes === "number" && bytes <= budget,