From d596f18ee90fe33be73f8908e26e9176411b8424 Mon Sep 17 00:00:00 2001 From: Brian Terlson Date: Fri, 13 Feb 2026 02:52:49 -0800 Subject: [PATCH 1/2] feat(babel-plugin): infer human-readable names for auto-memoized JSX expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New describeExpression() function generates names from AST expression nodes (e.g., 'props.name', 'items.filter(…)', 'condition ? …'). The name is passed as the third argument to memo() when config.addSourceInfo is enabled. These names appear in debug traces and devtools, making it easier to identify which reactive computations are running. Names are truncated at 50 characters. Changes in both src/ and pre-built index.js to stay in sync. --- .../babel-plugin-jsx-dom-expressions/index.js | 51 ++++++++++++++++++- .../src/shared/utils.js | 44 ++++++++++++++++ .../src/universal/template.js | 9 +++- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/babel-plugin-jsx-dom-expressions/index.js b/packages/babel-plugin-jsx-dom-expressions/index.js index 8d474698e..2a5ed6bbf 100644 --- a/packages/babel-plugin-jsx-dom-expressions/index.js +++ b/packages/babel-plugin-jsx-dom-expressions/index.js @@ -299,6 +299,50 @@ 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 : "…"; + 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 && 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 +909,12 @@ 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]]; + const name = describeExpression( + t__namespace.isArrowFunctionExpression(result.exprs[0]) ? result.exprs[0].body : result.exprs[0] + ); + if (config.addSourceInfo && 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..01a678601 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,50 @@ 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 : "…"; + 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 && 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..22865b6ef 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,12 @@ 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]]; + const name = describeExpression( + t.isArrowFunctionExpression(result.exprs[0]) ? result.exprs[0].body : result.exprs[0] + ); + if (config.addSourceInfo && name) args.push(t.booleanLiteral(false), t.stringLiteral(name)); + return t.callExpression(registerImportMethod(path, config.memoWrapper), args); } return result.exprs[0]; } From 1ca15e5e1ddb95747701a75a7e2a4d5aeccf995f Mon Sep 17 00:00:00 2001 From: Brian Terlson Date: Fri, 13 Feb 2026 11:21:31 -0800 Subject: [PATCH 2/2] fix: handle NumericLiteral in computed properties, fix truncName edge case, add memo name tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - describeExpression: add NumericLiteral check so arr[0] renders as 'arr[0]' instead of 'arr[…]' - truncName: use 's != null' instead of 's &&' to correctly handle empty string input - Add memo name parameter tests (3 tests): getter .name is set when provided, not set when omitted, value correctness with name --- .../changes/babel-memo-naming-2026-2-13.md | 7 ++ .../test/memo-naming.test.ts | 64 +++++++++++++++++++ .../babel-plugin-jsx-dom-expressions/index.js | 15 +++-- .../src/shared/utils.js | 5 +- .../src/universal/template.js | 10 +-- 5 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 .chronus/changes/babel-memo-naming-2026-2-13.md create mode 100644 packages/babel-plugin-alloy/test/memo-naming.test.ts 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 2a5ed6bbf..bd3e74a87 100644 --- a/packages/babel-plugin-jsx-dom-expressions/index.js +++ b/packages/babel-plugin-jsx-dom-expressions/index.js @@ -313,7 +313,8 @@ function describeExpression(node, depth = 0) { 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.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 || "?"}`); @@ -340,7 +341,7 @@ function describeExpression(node, depth = 0) { } function truncName(s) { - return s && s.length > MAX_EXPR_NAME_LEN ? s.slice(0, MAX_EXPR_NAME_LEN - 1) + "…" : s; + return s != null && s.length > MAX_EXPR_NAME_LEN ? s.slice(0, MAX_EXPR_NAME_LEN - 1) + "…" : s; } function transformCondition(path, inline, deep) { @@ -910,10 +911,12 @@ function createTemplate(path, result, wrap) { } if (wrap && result.dynamic && config.memoWrapper) { const args = [result.exprs[0]]; - const name = describeExpression( - t__namespace.isArrowFunctionExpression(result.exprs[0]) ? result.exprs[0].body : result.exprs[0] - ); - if (config.addSourceInfo && name) args.push(t__namespace.booleanLiteral(false), t__namespace.stringLiteral(name)); + 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 01a678601..4d012b3d2 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/shared/utils.js @@ -271,7 +271,8 @@ export function describeExpression(node, depth = 0) { if (!obj) return null; if (node.computed) { const prop = t.isIdentifier(node.property) ? node.property.name : - t.isStringLiteral(node.property) ? node.property.value : "…"; + 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 || "?"}`); @@ -298,7 +299,7 @@ export function describeExpression(node, depth = 0) { } function truncName(s) { - return s && s.length > MAX_EXPR_NAME_LEN ? s.slice(0, MAX_EXPR_NAME_LEN - 1) + "…" : 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) { 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 22865b6ef..1d54864f9 100644 --- a/packages/babel-plugin-jsx-dom-expressions/src/universal/template.js +++ b/packages/babel-plugin-jsx-dom-expressions/src/universal/template.js @@ -30,10 +30,12 @@ export function createTemplate(path, result, wrap) { } if (wrap && result.dynamic && config.memoWrapper) { const args = [result.exprs[0]]; - const name = describeExpression( - t.isArrowFunctionExpression(result.exprs[0]) ? result.exprs[0].body : result.exprs[0] - ); - if (config.addSourceInfo && name) args.push(t.booleanLiteral(false), t.stringLiteral(name)); + 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];