diff --git a/packages/compiler/src/client-codegen.ts b/packages/compiler/src/client-codegen.ts index a3094fba..a838719b 100644 --- a/packages/compiler/src/client-codegen.ts +++ b/packages/compiler/src/client-codegen.ts @@ -308,21 +308,82 @@ export function generateBrowserModule(ast: PageAst): string { const selectedImports = selectedBrowserImports(ast, functions); const imports = selectedImports.map((entry) => entry.code).join("\n"); const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))]; + const sharedState = state.filter((name) => safeIdentifier(name) && !RUNTIME_BINDINGS.has(name)); + const sharedProps = ast.props + .map((entry) => entry.name) + .filter( + (name) => + safeIdentifier(name) && + !RUNTIME_BINDINGS.has(name) && + !sharedState.includes(name), + ); + const callableAliases = functionNames.filter( + (name) => + safeIdentifier(name) && + !RUNTIME_BINDINGS.has(name) && + !sharedState.includes(name) && + !sharedProps.includes(name), + ); + const sharedCommit = sharedState.map((name) => `context.state.${name} = ${name};`).join(" "); + const sharedRestore = [ + ...sharedState.map((name) => `${name} = context.state.${name};`), + ...sharedProps.map((name) => `${name} = context.props.${name};`), + ].join(" "); + const hasAuthoredCommit = callableAliases.includes("commit"); + const hasAuthoredSetTimeout = callableAliases.includes("setTimeout"); + const implementations = functions + .map((fn) => { + const parameters = fn.parameters.map((parameter) => parameter.name).join(", "); + return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(${parameters}) { + try { ${eraseFunctionTypes(fn.body)} } finally { __wrnexusCommit(); } + }`; + }) + .join(",\n"); return `// generated WRNexusJS browser module for ${ast.name} ${imports} export const __wrnexusClientFunctions = { -${functions.map((fn) => ` ${functionEntry(ast, fn, functionNames)}`).join(",\n")} +${functions + .map( + (fn) => + ` ${JSON.stringify(fn.name)}: (context, ...args) => __wrnexusBindings(context)[${JSON.stringify(fn.name)}](...args)`, + ) + .join(",\n")} }; export const __wrnexusClientState = ${JSON.stringify(state)}; export const __wrnexusOutputs = ${JSON.stringify(ast.outputs)}; export const __wrnexusImportedBindings = { ${importedBindings.join(", ")} }; -export function bindClientScope(context) { +function __wrnexusCreateClientFunctions(context) { + ${sharedState.length ? `let { ${sharedState.join(", ")} } = context.state;` : ""} + ${sharedProps.length ? `let { ${sharedProps.join(", ")} } = context.props;` : ""} + const output = context.output; + const server = context.server; + const props = context.props; + const refs = context.refs; + const __wrnexusCommit = () => { ${sharedCommit} }; + const __wrnexusRestore = () => { ${sharedRestore} }; + ${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""} + ${!hasAuthoredSetTimeout ? `const setTimeout = (callback, delay, ...args) => globalThis.setTimeout(() => { + try { return callback(...args); } finally { __wrnexusCommit(); } + }, delay);` : ""} + const implementations = { + ${implementations} + }; + ${callableAliases.map((name) => `const ${name} = (...args) => implementations[${JSON.stringify(name)}](...args);`).join("\n ")} const functions = {}; - const scopedContext = { ...context, functions }; - for (const [name, handler] of Object.entries(__wrnexusClientFunctions)) { - functions[name] = (...args) => handler(scopedContext, ...args); + for (const name of Object.keys(implementations)) { + functions[name] = (...args) => { + __wrnexusRestore(); + return implementations[name](...args); + }; } return functions; } +function __wrnexusBindings(context) { + return context.__wrnexusBoundFunctions || + (context.__wrnexusBoundFunctions = __wrnexusCreateClientFunctions(context)); +} +export function bindClientScope(context) { + return __wrnexusBindings(context); +} `; } diff --git a/packages/compiler/test/v060-targets.test.ts b/packages/compiler/test/v060-targets.test.ts index 18749762..2fc33a20 100644 --- a/packages/compiler/test/v060-targets.test.ts +++ b/packages/compiler/test/v060-targets.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { gzipSync } from "bun"; import { generateTargets, parse } from "../src/index.ts"; const ast = parse(`component ConfirmDialog { @@ -130,7 +131,7 @@ test("browser codegen binds peer client functions through the scoped function ta view { } }`), ); - expect(targets.browser).toContain("context.functions"); + expect(targets.browser).toContain('implementations["increment"]'); const executable = new Function( `${targets.browser.replace(/^export\s+/gm, "")}\nreturn { bindClientScope };`, )() as { bindClientScope: (context: Record) => Record void> }; @@ -200,3 +201,27 @@ test("the deferred commit helper does not shadow an authored commit function", ( expect(() => new Function(targets.browser.replace(/^export\s+/gm, ""))).not.toThrow(); expect(targets.browser).not.toContain("const commit = __wrnexusCommit"); }); + +test("generated client modules stay below decoded-size and duplication ratchets", () => { + const states = Array.from({ length: 12 }, (_, index) => `s${index}: number = ${index}`).join(" "); + const functions = Array.from( + { length: 20 }, + (_, index) => + `client function f${index}(): void { s${index % 12} += 1 ${Array.from( + { length: 20 }, + (_unused, peer) => (peer === index ? "" : `if (false) f${peer}()`), + ).join(" ")} }`, + ).join("\n"); + const browser = generateTargets( + parse(`component ClientSizeStress { + state { ${states} } + functions { ${functions} } + view { } + }`), + ).browser; + const decoded = new TextEncoder().encode(browser).length; + const compressed = gzipSync(new TextEncoder().encode(browser)).length; + + expect(decoded).toBeLessThanOrEqual(13_000); + expect(decoded / compressed).toBeLessThanOrEqual(10); +});