diff --git a/.chronus/changes/babel-memo-naming-2026-2-13.md b/.chronus/changes/babel-memo-naming-2026-2-13.md new file mode 100644 index 000000000..d1e4132b2 --- /dev/null +++ b/.chronus/changes/babel-memo-naming-2026-2-13.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@alloy-js/babel-plugin-jsx-dom-expressions" +--- + +Add human-readable expression names to auto-memoized JSX expressions in dev builds to improve debug trace readability. diff --git a/packages/babel-plugin-alloy/test/memo-naming.test.ts b/packages/babel-plugin-alloy/test/memo-naming.test.ts new file mode 100644 index 000000000..845044873 --- /dev/null +++ b/packages/babel-plugin-alloy/test/memo-naming.test.ts @@ -0,0 +1,64 @@ +import { transformSync } from "@babel/core"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const pluginPath = join( + import.meta.dirname, + "../../babel-plugin-jsx-dom-expressions/index.js", +); + +function transform(code: string, addSourceInfo: boolean) { + const result = transformSync(code, { + plugins: [ + [ + pluginPath, + { + moduleName: "r-custom", + builtIns: ["For", "Show"], + generate: "universal", + addSourceInfo, + }, + ], + ], + filename: "test.js", + }); + return result?.code ?? ""; +} + +describe("memo naming (addSourceInfo)", () => { + it("adds name for simple member expression", () => { + const out = transform("const x = <>{signal.value};", true); + expect(out).toContain('false, "signal.value"'); + }); + + it("adds name for chained member expression", () => { + const out = transform("const x = <>{a.b.c};", true); + expect(out).toContain('false, "a.b.c"'); + }); + + it("adds name for call expression", () => { + const out = transform("const x = <>{getData()};", true); + expect(out).toContain('false, "getData"'); + }); + + it("adds name for numeric computed property", () => { + const out = transform("const x = <>{arr[0]};", true); + expect(out).toContain('false, "arr[0]"'); + }); + + it("adds name for string computed property", () => { + const out = transform('const x = <>{obj["key"]};', true); + expect(out).toContain('false, "obj[key]"'); + }); + + it("adds name for optional chaining", () => { + const out = transform("const x = <>{a?.b};", true); + expect(out).toContain('false, "a.b"'); + }); + + it("does not add name when addSourceInfo is false", () => { + const out = transform("const x = <>{signal.value};", false); + expect(out).toContain("_$memo"); + expect(out).not.toContain('"signal.value"'); + }); +}); diff --git a/packages/babel-plugin-jsx-dom-expressions/index.js b/packages/babel-plugin-jsx-dom-expressions/index.js index 8d474698e..bd3e74a87 100644 --- a/packages/babel-plugin-jsx-dom-expressions/index.js +++ b/packages/babel-plugin-jsx-dom-expressions/index.js @@ -299,6 +299,51 @@ function toPropertyName(name) { return name.toLowerCase().replace(/-([a-z])/g, (_, w) => w.toUpperCase()); } +const MAX_EXPR_NAME_LEN = 50; + +/** + * Generate a short human-readable description from an AST expression node, + * used to name memoized expressions for debug tracing. + */ +function describeExpression(node, depth = 0) { + if (depth > 4) return "…"; + if (t__namespace.isIdentifier(node)) return node.name; + if (t__namespace.isMemberExpression(node) || t__namespace.isOptionalMemberExpression(node)) { + const obj = describeExpression(node.object, depth + 1); + if (!obj) return null; + if (node.computed) { + const prop = t__namespace.isIdentifier(node.property) ? node.property.name : + t__namespace.isStringLiteral(node.property) ? node.property.value : + t__namespace.isNumericLiteral(node.property) ? String(node.property.value) : "…"; + return truncName(`${obj}[${prop}]`); + } + return truncName(`${obj}.${node.property.name || "?"}`); + } + if (t__namespace.isCallExpression(node) || t__namespace.isOptionalCallExpression(node)) { + const callee = describeExpression(node.callee, depth + 1); + return callee ? truncName(`${callee}(…)`) : null; + } + if (t__namespace.isConditionalExpression(node)) { + const test = describeExpression(node.test, depth + 1); + return test ? truncName(`${test} ? …`) : null; + } + if (t__namespace.isLogicalExpression(node)) { + const left = describeExpression(node.left, depth + 1); + return left ? truncName(`${left} ${node.operator} …`) : null; + } + if (t__namespace.isTemplateLiteral(node)) return "template"; + if (t__namespace.isArrowFunctionExpression(node) || t__namespace.isFunctionExpression(node)) { + // Try to describe the body + const body = t__namespace.isBlockStatement(node.body) ? null : describeExpression(node.body, depth + 1); + return body ? truncName(`() => ${body}`) : null; + } + return null; +} + +function truncName(s) { + return s != null && s.length > MAX_EXPR_NAME_LEN ? s.slice(0, MAX_EXPR_NAME_LEN - 1) + "…" : s; +} + function transformCondition(path, inline, deep) { const config = getConfig(path); const expr = path.node; @@ -865,7 +910,14 @@ function createTemplate(path, result, wrap) { } } if (wrap && result.dynamic && config.memoWrapper) { - return t__namespace.callExpression(registerImportMethod(path, config.memoWrapper), [result.exprs[0]]); + const args = [result.exprs[0]]; + if (config.addSourceInfo) { + const name = describeExpression( + t__namespace.isArrowFunctionExpression(result.exprs[0]) ? result.exprs[0].body : result.exprs[0] + ); + if (name) args.push(t__namespace.booleanLiteral(false), t__namespace.stringLiteral(name)); + } + return t__namespace.callExpression(registerImportMethod(path, config.memoWrapper), args); } return result.exprs[0]; } diff --git a/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js b/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js index e57578391..4d012b3d2 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js @@ -257,6 +257,51 @@ export function wrappedByText(list, startIndex) { return false; } +const MAX_EXPR_NAME_LEN = 50; + +/** + * Generate a short human-readable description from an AST expression node, + * used to name memoized expressions for debug tracing. + */ +export function describeExpression(node, depth = 0) { + if (depth > 4) return "…"; + if (t.isIdentifier(node)) return node.name; + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + const obj = describeExpression(node.object, depth + 1); + if (!obj) return null; + if (node.computed) { + const prop = t.isIdentifier(node.property) ? node.property.name : + t.isStringLiteral(node.property) ? node.property.value : + t.isNumericLiteral(node.property) ? String(node.property.value) : "…"; + return truncName(`${obj}[${prop}]`); + } + return truncName(`${obj}.${node.property.name || "?"}`); + } + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { + const callee = describeExpression(node.callee, depth + 1); + return callee ? truncName(`${callee}(…)`) : null; + } + if (t.isConditionalExpression(node)) { + const test = describeExpression(node.test, depth + 1); + return test ? truncName(`${test} ? …`) : null; + } + if (t.isLogicalExpression(node)) { + const left = describeExpression(node.left, depth + 1); + return left ? truncName(`${left} ${node.operator} …`) : null; + } + if (t.isTemplateLiteral(node)) return "template"; + if (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) { + // Try to describe the body + const body = t.isBlockStatement(node.body) ? null : describeExpression(node.body, depth + 1); + return body ? truncName(`() => ${body}`) : null; + } + return null; +} + +function truncName(s) { + return s != null && s.length > MAX_EXPR_NAME_LEN ? s.slice(0, MAX_EXPR_NAME_LEN - 1) + "…" : s; +} + export function transformCondition(path, inline, deep) { const config = getConfig(path); const expr = path.node; diff --git a/packages/babel-plugin-jsx-dom-expressions/src/universal/template.js b/packages/babel-plugin-jsx-dom-expressions/src/universal/template.js index e37a59e4f..1d54864f9 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/universal/template.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/universal/template.js @@ -1,5 +1,5 @@ import * as t from "@babel/types"; -import { getConfig, getNumberedId, registerImportMethod } from "../shared/utils"; +import { describeExpression, getConfig, getNumberedId, registerImportMethod } from "../shared/utils"; import { setAttr } from "./element"; export function createTemplate(path, result, wrap) { @@ -29,7 +29,14 @@ export function createTemplate(path, result, wrap) { } } if (wrap && result.dynamic && config.memoWrapper) { - return t.callExpression(registerImportMethod(path, config.memoWrapper), [result.exprs[0]]); + const args = [result.exprs[0]]; + if (config.addSourceInfo) { + const name = describeExpression( + t.isArrowFunctionExpression(result.exprs[0]) ? result.exprs[0].body : result.exprs[0] + ); + if (name) args.push(t.booleanLiteral(false), t.stringLiteral(name)); + } + return t.callExpression(registerImportMethod(path, config.memoWrapper), args); } return result.exprs[0]; }