From b5a366e14585dfa022f4cd56a6ffe5bfb60fa7fa Mon Sep 17 00:00:00 2001 From: Rafi Olaverria Date: Thu, 29 Oct 2026 16:25:07 -0400 Subject: [PATCH 1/4] fix(remove-memoization): replace useMemo with the factory result, not the factory `useMemo(factory, deps)` evaluates to what the factory returns, but the codemod replaced the call with the factory function itself, so every memoized value became a function. Context providers received a function instead of an object, `states.some(...)` threw on a function, and so on. The `use-memo` fixtures encoded this: a boolean turned into `() => boolean`. An expression-bodied arrow now becomes its body (parenthesised outside declarators, arguments, returns, pairs, arrays and JSX expressions so precedence survives); a block-bodied or async factory is invoked in place as an IIFE; a bare identifier is called. Import rewriting is also narrowed. An import that loses no specifier is left untouched, which stops `import type { X } from 'react'` being rewritten as a value import and stops a trailing semicolon being added to semicolon-free files. Only the memoization specifiers themselves are candidates for removal; unrelated unused React imports are no longer deleted. A rewritten import keeps the file's semicolon style. Co-Authored-By: Claude Fable 5.1 --- .changeset/fix-use-memo-result.md | 7 ++ codemods/remove-memoization/README.md | 7 ++ .../remove-memoization/scripts/codemod.ts | 91 +++++++++++++++++-- .../tests/aliased-named-imports/expected.tsx | 2 +- .../tests/multiple-react-imports/expected.tsx | 2 +- .../tests/react-member-calls/expected.tsx | 4 +- .../type-only-import-preserved/expected.tsx | 10 ++ .../type-only-import-preserved/input.tsx | 14 +++ .../type-only-import-preserved/metrics.json | 11 +++ .../type-only-import-removed/expected.tsx | 7 ++ .../tests/type-only-import-removed/input.tsx | 7 ++ .../type-only-import-removed/metrics.json | 11 +++ .../expected.tsx | 4 +- .../tests/typescript-use-memo/expected.tsx | 4 +- .../use-memo-expression-body/expected.tsx | 15 +++ .../tests/use-memo-expression-body/input.tsx | 15 +++ .../use-memo-expression-body/metrics.json | 18 ++++ .../tests/use-memo/expected.tsx | 4 +- 18 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 .changeset/fix-use-memo-result.md create mode 100644 codemods/remove-memoization/tests/type-only-import-preserved/expected.tsx create mode 100644 codemods/remove-memoization/tests/type-only-import-preserved/input.tsx create mode 100644 codemods/remove-memoization/tests/type-only-import-preserved/metrics.json create mode 100644 codemods/remove-memoization/tests/type-only-import-removed/expected.tsx create mode 100644 codemods/remove-memoization/tests/type-only-import-removed/input.tsx create mode 100644 codemods/remove-memoization/tests/type-only-import-removed/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-expression-body/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-expression-body/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-expression-body/metrics.json diff --git a/.changeset/fix-use-memo-result.md b/.changeset/fix-use-memo-result.md new file mode 100644 index 0000000..2dcee8f --- /dev/null +++ b/.changeset/fix-use-memo-result.md @@ -0,0 +1,7 @@ +--- +"react-19-remove-memoization": patch +--- + +Replace `useMemo(factory, deps)` with the factory's result instead of the factory function, so memoized values +stay values. Preserve `import type` and semicolon style on React imports, and leave imports that lose no +specifier untouched. diff --git a/codemods/remove-memoization/README.md b/codemods/remove-memoization/README.md index 243937b..9f06f04 100644 --- a/codemods/remove-memoization/README.md +++ b/codemods/remove-memoization/README.md @@ -4,6 +4,13 @@ Remove manual React memoization calls: `useCallback(...)`, `useMemo(...)`, and ` This codemod is intended for React Compiler adoption and mirrors `react/19/remove-memoization` from `codemod/commons`. +`useCallback(fn, deps)` and `memo(Component)` are replaced by their first argument. `useMemo(factory, deps)` +is replaced by the factory's *result*: an expression-bodied arrow becomes its body, anything else is invoked in +place (`useMemo(() => { ... }, deps)` becomes `(() => { ... })()`), so the surrounding code keeps receiving a +value rather than a function. + +Only React imports that lose a specifier are rewritten; `import type` and the file's semicolon style are preserved. + > This is not a universally safe codemod. React Compiler is not a 1:1 replacement for every manual `useMemo`, `useCallback`, or `memo` occurrence, so some memoization may need to be kept to preserve semantics. ## Usage diff --git a/codemods/remove-memoization/scripts/codemod.ts b/codemods/remove-memoization/scripts/codemod.ts index 3e15c0a..620cd5b 100644 --- a/codemods/remove-memoization/scripts/codemod.ts +++ b/codemods/remove-memoization/scripts/codemod.ts @@ -232,8 +232,71 @@ function firstMemoizationArgument( return firstArg; } -function replacementText(call: SgNode, selectedCallIds: Set): string | null { - return firstMemoizationArgument(call, selectedCallIds)?.text() ?? null; +// Parents where an arbitrary expression can stand in for the call without +// extra parentheses. Anywhere else (`a + useMemo(...)`, `!useMemo(...)`) the +// factory result is wrapped so operator precedence is preserved. +const BARE_EXPRESSION_PARENTS = new Set([ + "variable_declarator", + "assignment_expression", + "return_statement", + "arguments", + "array", + "pair", + "jsx_expression", + "parenthesized_expression", +]); + +function hasAnonymousChild(node: SgNode, text: string): boolean { + return node.children().some((child) => !child.isNamed() && child.text() === text); +} + +function unwrapParentheses(node: SgNode): SgNode { + let current = node; + while (current.kind() === "parenthesized_expression") { + const inner = namedChildren(current)[0]; + if (!inner || inner.kind() === "sequence_expression") { + return current; + } + current = inner; + } + return current; +} + +// `useMemo(factory, deps)` evaluates to the factory's *result*, so the call is +// replaced by that result, not by the factory itself. Expression-bodied arrows +// become their body; everything else is invoked in place. +function memoFactoryResultText(call: SgNode, factory: SgNode): string { + const bareParent = BARE_EXPRESSION_PARENTS.has(call.parent()?.kind() ?? ""); + const wrap = (text: string) => (bareParent ? text : `(${text})`); + + if (factory.kind() === "arrow_function" && !hasAnonymousChild(factory, "async")) { + const body = factory.field("body"); + if (body && body.kind() !== "statement_block") { + return wrap(unwrapParentheses(body).text()); + } + } + + if (factory.kind() === "identifier") { + return `${factory.text()}()`; + } + + return `(${factory.text()})()`; +} + +function replacementText( + match: MemoizationCall, + selectedCallIds: Set, +): string | null { + const firstArg = firstMemoizationArgument(match.call, selectedCallIds); + if (!firstArg) { + return null; + } + + if (match.importedName === "useMemo") { + return memoFactoryResultText(match.call, firstArg); + } + + return firstArg.text(); } function keptRanges(call: SgNode, selectedCallIds: Set): Range[] { @@ -304,11 +367,24 @@ function importStatementReplacement( reactImport: ReactImportInfo, usedImportLocals: Set, sourceCode: string, -): Edit { - const keptNamedSpecifiers = reactImport.namedSpecifiers.filter((specifier) => usedImportLocals.has(specifier.localName)); +): Edit | null { + // Only the memoization imports are candidates for removal; an unrelated + // specifier that happens to be unused is not this codemod's business. + const keptNamedSpecifiers = reactImport.namedSpecifiers.filter((specifier) => + usedImportLocals.has(specifier.localName) || !MEMOIZATION_IMPORTS.has(specifier.importedName) + ); const keepDefault = reactImport.defaultSpecifier ? usedImportLocals.has(reactImport.defaultSpecifier.localName) : false; const keepNamespace = reactImport.namespaceSpecifier ? usedImportLocals.has(reactImport.namespaceSpecifier.localName) : false; + // An import that loses no specifier keeps its original text, so formatting, + // `import type` and semicolon style survive untouched. + const droppedDefault = Boolean(reactImport.defaultSpecifier) && !keepDefault; + const droppedNamespace = Boolean(reactImport.namespaceSpecifier) && !keepNamespace; + const droppedNamed = keptNamedSpecifiers.length !== reactImport.namedSpecifiers.length; + if (!droppedDefault && !droppedNamespace && !droppedNamed) { + return null; + } + const range = nodeRange(reactImport.importNode); const allRemoved = !keepDefault && !keepNamespace && keptNamedSpecifiers.length === 0; if (allRemoved) { @@ -329,10 +405,11 @@ function importStatementReplacement( } const quote = sourceQuote(reactImport.sourceNode); + const semicolon = reactImport.importNode.text().trimEnd().endsWith(";") ? ";" : ""; return { startPos: range.start, endPos: range.end, - insertedText: `import ${clauses.join(", ")} from ${quote}react${quote};`, + insertedText: `import ${clauses.join(", ")} from ${quote}react${quote}${semicolon}`, }; } @@ -427,7 +504,7 @@ const transform: Transform = async (root) => { const metricCounts = new Map(); for (const match of selectedMatches) { - const replacement = replacementText(match.call, selectedCallIds); + const replacement = replacementText(match, selectedCallIds); if (!replacement) { continue; } @@ -457,7 +534,7 @@ const transform: Transform = async (root) => { const sourceCode = rootNode.text(); for (const reactImport of reactImports) { const edit = importStatementReplacement(reactImport, usedImportLocals, sourceCode); - if (edit.insertedText !== reactImport.importNode.text()) { + if (edit) { edits.push(edit); } } diff --git a/codemods/remove-memoization/tests/aliased-named-imports/expected.tsx b/codemods/remove-memoization/tests/aliased-named-imports/expected.tsx index 4074508..852861a 100644 --- a/codemods/remove-memoization/tests/aliased-named-imports/expected.tsx +++ b/codemods/remove-memoization/tests/aliased-named-imports/expected.tsx @@ -1,3 +1,3 @@ -const value = () => computeValue(); +const value = computeValue(); const handler = () => value; const MemoizedWidget = Widget; diff --git a/codemods/remove-memoization/tests/multiple-react-imports/expected.tsx b/codemods/remove-memoization/tests/multiple-react-imports/expected.tsx index 1b1b8a0..d717f5a 100644 --- a/codemods/remove-memoization/tests/multiple-react-imports/expected.tsx +++ b/codemods/remove-memoization/tests/multiple-react-imports/expected.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -const value = () => computeValue(); +const value = computeValue(); const handler = () => value; const [state] = useState(0); diff --git a/codemods/remove-memoization/tests/react-member-calls/expected.tsx b/codemods/remove-memoization/tests/react-member-calls/expected.tsx index 1521295..d6ac7ae 100644 --- a/codemods/remove-memoization/tests/react-member-calls/expected.tsx +++ b/codemods/remove-memoization/tests/react-member-calls/expected.tsx @@ -3,10 +3,10 @@ import React from 'react'; function Component() { const state = React.useState(); - const example1 = () => { + const example1 = (() => { const diff = today.diff(selectedDate, "days"); return diff > 3 || diff < -3; - }; + })(); const example2 = () => { const diff = today.diff(selectedDate, "days"); diff --git a/codemods/remove-memoization/tests/type-only-import-preserved/expected.tsx b/codemods/remove-memoization/tests/type-only-import-preserved/expected.tsx new file mode 100644 index 0000000..068e437 --- /dev/null +++ b/codemods/remove-memoization/tests/type-only-import-preserved/expected.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' +import { createContext, useState } from 'react' + +const Context = createContext({ open: false }) + +export const Panel = ({ children }: { children: ReactNode }) => { + const [open] = useState(false) + const value = { open } + return {children} +} diff --git a/codemods/remove-memoization/tests/type-only-import-preserved/input.tsx b/codemods/remove-memoization/tests/type-only-import-preserved/input.tsx new file mode 100644 index 0000000..4a47a71 --- /dev/null +++ b/codemods/remove-memoization/tests/type-only-import-preserved/input.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' +import { + createContext, + useMemo, + useState, +} from 'react' + +const Context = createContext({ open: false }) + +export const Panel = ({ children }: { children: ReactNode }) => { + const [open] = useState(false) + const value = useMemo(() => ({ open }), [open]) + return {children} +} diff --git a/codemods/remove-memoization/tests/type-only-import-preserved/metrics.json b/codemods/remove-memoization/tests/type-only-import-preserved/metrics.json new file mode 100644 index 0000000..86b60c0 --- /dev/null +++ b/codemods/remove-memoization/tests/type-only-import-preserved/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/type-only-import-preserved/input.tsx", + "pattern": "useMemo" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/type-only-import-removed/expected.tsx b/codemods/remove-memoization/tests/type-only-import-removed/expected.tsx new file mode 100644 index 0000000..c1f2f0b --- /dev/null +++ b/codemods/remove-memoization/tests/type-only-import-removed/expected.tsx @@ -0,0 +1,7 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { type FC } from 'react'; + +const Label: FC<{ children: ReactNode }> = ({ children }) => { + const style = { fontWeight: 'bold' }; + return {children}; +}; diff --git a/codemods/remove-memoization/tests/type-only-import-removed/input.tsx b/codemods/remove-memoization/tests/type-only-import-removed/input.tsx new file mode 100644 index 0000000..a2b7c11 --- /dev/null +++ b/codemods/remove-memoization/tests/type-only-import-removed/input.tsx @@ -0,0 +1,7 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { useMemo, type FC } from 'react'; + +const Label: FC<{ children: ReactNode }> = ({ children }) => { + const style = useMemo(() => ({ fontWeight: 'bold' }), []); + return {children}; +}; diff --git a/codemods/remove-memoization/tests/type-only-import-removed/metrics.json b/codemods/remove-memoization/tests/type-only-import-removed/metrics.json new file mode 100644 index 0000000..c05f776 --- /dev/null +++ b/codemods/remove-memoization/tests/type-only-import-removed/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/type-only-import-removed/input.tsx", + "pattern": "useMemo" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/typescript-react-member-calls/expected.tsx b/codemods/remove-memoization/tests/typescript-react-member-calls/expected.tsx index 684d6f3..336e85d 100644 --- a/codemods/remove-memoization/tests/typescript-react-member-calls/expected.tsx +++ b/codemods/remove-memoization/tests/typescript-react-member-calls/expected.tsx @@ -3,10 +3,10 @@ import React from 'react'; function Component({ url }: { url: string }) { const state = React.useState(); - const example1 = () => { + const example1 = (() => { const diff = today.diff(selectedDate, "days"); return diff > 3 || diff < -3; - }; + })(); const example2 = () => { const diff = today.diff(selectedDate, "days"); diff --git a/codemods/remove-memoization/tests/typescript-use-memo/expected.tsx b/codemods/remove-memoization/tests/typescript-use-memo/expected.tsx index 5c8db40..b5c0801 100644 --- a/codemods/remove-memoization/tests/typescript-use-memo/expected.tsx +++ b/codemods/remove-memoization/tests/typescript-use-memo/expected.tsx @@ -1,6 +1,6 @@ function Component({ url }: { url: string }) { - const selectedDateMin3DaysDifference = () => { + const selectedDateMin3DaysDifference = (() => { const diff = today.diff(selectedDate, "days"); return diff > 3 || diff < -3; - }; + })(); } diff --git a/codemods/remove-memoization/tests/use-memo-expression-body/expected.tsx b/codemods/remove-memoization/tests/use-memo-expression-body/expected.tsx new file mode 100644 index 0000000..0569f7f --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-expression-body/expected.tsx @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +const Context = createContext(null); + +function Component({ items, onSelect }) { + const count = items.length; + const context = { items, count }; + const label = count === 1 ? 'item' : 'items'; + const handler = () => onSelect(items); + const total = 1 + (count * 2); + const value = compute(); + const lazy = (async () => fetchValue())(); + + return {label}; +} diff --git a/codemods/remove-memoization/tests/use-memo-expression-body/input.tsx b/codemods/remove-memoization/tests/use-memo-expression-body/input.tsx new file mode 100644 index 0000000..a09ed1d --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-expression-body/input.tsx @@ -0,0 +1,15 @@ +import { createContext, useCallback, useMemo } from 'react'; + +const Context = createContext(null); + +function Component({ items, onSelect }) { + const count = useMemo(() => items.length, [items]); + const context = useMemo(() => ({ items, count }), [items, count]); + const label = useMemo(() => (count === 1 ? 'item' : 'items'), [count]); + const handler = useCallback(() => onSelect(items), [onSelect, items]); + const total = 1 + useMemo(() => count * 2, [count]); + const value = useMemo(compute, [compute]); + const lazy = useMemo(async () => fetchValue(), []); + + return ({ handler }), [handler])}>{label}; +} diff --git a/codemods/remove-memoization/tests/use-memo-expression-body/metrics.json b/codemods/remove-memoization/tests/use-memo-expression-body/metrics.json new file mode 100644 index 0000000..268c7d1 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-expression-body/metrics.json @@ -0,0 +1,18 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-expression-body/input.tsx", + "pattern": "useCallback" + }, + "count": 1 + }, + { + "cardinality": { + "file": "tests/use-memo-expression-body/input.tsx", + "pattern": "useMemo" + }, + "count": 7 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo/expected.tsx b/codemods/remove-memoization/tests/use-memo/expected.tsx index bafc971..58abe84 100644 --- a/codemods/remove-memoization/tests/use-memo/expected.tsx +++ b/codemods/remove-memoization/tests/use-memo/expected.tsx @@ -1,6 +1,6 @@ function Component() { - const selectedDateMin3DaysDifference = () => { + const selectedDateMin3DaysDifference = (() => { const diff = today.diff(selectedDate, "days"); return diff > 3 || diff < -3; - }; + })(); } From cd7b212a9817825a3f458ad2c397fd77405e5b2f Mon Sep 17 00:00:00 2001 From: Alex Bit Date: Fri, 30 Oct 2026 11:55:41 -0700 Subject: [PATCH 2/4] test(remove-memoization): add fixtures for remaining useMemo edge gaps Cover parenthesized factories, block bodies that only return an expression, and preserving an unused default React import while stripping useMemo. Co-authored-by: Cursor --- .../unused-default-import-preserved/expected.tsx | 5 +++++ .../tests/unused-default-import-preserved/input.tsx | 5 +++++ .../unused-default-import-preserved/metrics.json | 11 +++++++++++ .../tests/use-memo-block-return/expected.tsx | 1 + .../tests/use-memo-block-return/input.tsx | 5 +++++ .../tests/use-memo-block-return/metrics.json | 11 +++++++++++ .../tests/use-memo-parenthesized-factory/expected.tsx | 1 + .../tests/use-memo-parenthesized-factory/input.tsx | 3 +++ .../tests/use-memo-parenthesized-factory/metrics.json | 11 +++++++++++ 9 files changed, 53 insertions(+) create mode 100644 codemods/remove-memoization/tests/unused-default-import-preserved/expected.tsx create mode 100644 codemods/remove-memoization/tests/unused-default-import-preserved/input.tsx create mode 100644 codemods/remove-memoization/tests/unused-default-import-preserved/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-block-return/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-block-return/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-block-return/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-parenthesized-factory/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-parenthesized-factory/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-parenthesized-factory/metrics.json diff --git a/codemods/remove-memoization/tests/unused-default-import-preserved/expected.tsx b/codemods/remove-memoization/tests/unused-default-import-preserved/expected.tsx new file mode 100644 index 0000000..e89efb0 --- /dev/null +++ b/codemods/remove-memoization/tests/unused-default-import-preserved/expected.tsx @@ -0,0 +1,5 @@ +import React from 'react'; +import { useState } from 'react'; + +const [state] = useState(0); +const value = state; diff --git a/codemods/remove-memoization/tests/unused-default-import-preserved/input.tsx b/codemods/remove-memoization/tests/unused-default-import-preserved/input.tsx new file mode 100644 index 0000000..22574f3 --- /dev/null +++ b/codemods/remove-memoization/tests/unused-default-import-preserved/input.tsx @@ -0,0 +1,5 @@ +import React from 'react'; +import { useMemo, useState } from 'react'; + +const [state] = useState(0); +const value = useMemo(() => state, [state]); diff --git a/codemods/remove-memoization/tests/unused-default-import-preserved/metrics.json b/codemods/remove-memoization/tests/unused-default-import-preserved/metrics.json new file mode 100644 index 0000000..4311762 --- /dev/null +++ b/codemods/remove-memoization/tests/unused-default-import-preserved/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/unused-default-import-preserved/input.tsx", + "pattern": "useMemo" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-block-return/expected.tsx b/codemods/remove-memoization/tests/use-memo-block-return/expected.tsx new file mode 100644 index 0000000..10dc773 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-block-return/expected.tsx @@ -0,0 +1 @@ +const value = computeValue(); diff --git a/codemods/remove-memoization/tests/use-memo-block-return/input.tsx b/codemods/remove-memoization/tests/use-memo-block-return/input.tsx new file mode 100644 index 0000000..8b8778d --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-block-return/input.tsx @@ -0,0 +1,5 @@ +import { useMemo } from 'react'; + +const value = useMemo(() => { + return computeValue(); +}, [computeValue]); diff --git a/codemods/remove-memoization/tests/use-memo-block-return/metrics.json b/codemods/remove-memoization/tests/use-memo-block-return/metrics.json new file mode 100644 index 0000000..1fb7f3d --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-block-return/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-block-return/input.tsx", + "pattern": "useMemo" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-parenthesized-factory/expected.tsx b/codemods/remove-memoization/tests/use-memo-parenthesized-factory/expected.tsx new file mode 100644 index 0000000..10dc773 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-parenthesized-factory/expected.tsx @@ -0,0 +1 @@ +const value = computeValue(); diff --git a/codemods/remove-memoization/tests/use-memo-parenthesized-factory/input.tsx b/codemods/remove-memoization/tests/use-memo-parenthesized-factory/input.tsx new file mode 100644 index 0000000..f7c8d0c --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-parenthesized-factory/input.tsx @@ -0,0 +1,3 @@ +import { useMemo } from 'react'; + +const value = useMemo((() => computeValue()), [computeValue]); diff --git a/codemods/remove-memoization/tests/use-memo-parenthesized-factory/metrics.json b/codemods/remove-memoization/tests/use-memo-parenthesized-factory/metrics.json new file mode 100644 index 0000000..42377e8 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-parenthesized-factory/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-parenthesized-factory/input.tsx", + "pattern": "useMemo" + }, + "count": 1 + } + ] +} \ No newline at end of file From f22bb65d235c9a061f0a75ed44578e70f8fc7a33 Mon Sep 17 00:00:00 2001 From: rjolaverria Date: Sat, 31 Oct 2026 16:14:24 -0400 Subject: [PATCH 3/4] fix(remove-memoization): unwrap parenthesized and return-only useMemo factories - `useMemo((() => x), deps)` unwraps like a bare arrow - a block body that is only `return ;` becomes that expression; blocks with other statements or comments, empty blocks and `function` factories are still invoked in place - sequence-expression results keep their parentheses - a default or namespace React import is only removed when the codemod removed its last uses; an already-unused one is left alone Adds fixtures for the paths mutation testing (Stryker) showed untested: blocks kept as IIFEs, sequence results, contexts where the result needs no parentheses, and removal of a default-only React import. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UzRWcG4VtigDwQdDeqTvhk --- codemods/remove-memoization/README.md | 8 ++-- .../remove-memoization/scripts/codemod.ts | 44 +++++++++++++++---- .../tests/default-import-removed/expected.tsx | 1 + .../tests/default-import-removed/input.tsx | 3 ++ .../tests/default-import-removed/metrics.json | 11 +++++ .../tests/use-memo-bare-contexts/expected.tsx | 11 +++++ .../tests/use-memo-bare-contexts/input.tsx | 13 ++++++ .../tests/use-memo-bare-contexts/metrics.json | 11 +++++ .../use-memo-block-kept-as-iife/expected.tsx | 19 ++++++++ .../use-memo-block-kept-as-iife/input.tsx | 21 +++++++++ .../use-memo-block-kept-as-iife/metrics.json | 11 +++++ .../use-memo-sequence-expression/expected.tsx | 2 + .../use-memo-sequence-expression/input.tsx | 6 +++ .../use-memo-sequence-expression/metrics.json | 11 +++++ 14 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 codemods/remove-memoization/tests/default-import-removed/expected.tsx create mode 100644 codemods/remove-memoization/tests/default-import-removed/input.tsx create mode 100644 codemods/remove-memoization/tests/default-import-removed/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-bare-contexts/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-bare-contexts/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-bare-contexts/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-block-kept-as-iife/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-block-kept-as-iife/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-block-kept-as-iife/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-sequence-expression/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-sequence-expression/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-sequence-expression/metrics.json diff --git a/codemods/remove-memoization/README.md b/codemods/remove-memoization/README.md index 9f06f04..e4b1105 100644 --- a/codemods/remove-memoization/README.md +++ b/codemods/remove-memoization/README.md @@ -5,11 +5,13 @@ Remove manual React memoization calls: `useCallback(...)`, `useMemo(...)`, and ` This codemod is intended for React Compiler adoption and mirrors `react/19/remove-memoization` from `codemod/commons`. `useCallback(fn, deps)` and `memo(Component)` are replaced by their first argument. `useMemo(factory, deps)` -is replaced by the factory's *result*: an expression-bodied arrow becomes its body, anything else is invoked in -place (`useMemo(() => { ... }, deps)` becomes `(() => { ... })()`), so the surrounding code keeps receiving a -value rather than a function. +is replaced by the factory's *result*: an expression-bodied arrow becomes its body, as does a block body that only +returns an expression (`useMemo(() => { return x; }, deps)` becomes `x`); anything else is invoked in place +(`useMemo(() => { ... }, deps)` becomes `(() => { ... })()`), so the surrounding code keeps receiving a value +rather than a function. Only React imports that lose a specifier are rewritten; `import type` and the file's semicolon style are preserved. +A default or namespace React import is removed only when the removed memoization calls were its last uses. > This is not a universally safe codemod. React Compiler is not a 1:1 replacement for every manual `useMemo`, `useCallback`, or `memo` occurrence, so some memoization may need to be kept to preserve semantics. diff --git a/codemods/remove-memoization/scripts/codemod.ts b/codemods/remove-memoization/scripts/codemod.ts index 620cd5b..555b9a9 100644 --- a/codemods/remove-memoization/scripts/codemod.ts +++ b/codemods/remove-memoization/scripts/codemod.ts @@ -254,7 +254,7 @@ function unwrapParentheses(node: SgNode): SgNode { let current = node; while (current.kind() === "parenthesized_expression") { const inner = namedChildren(current)[0]; - if (!inner || inner.kind() === "sequence_expression") { + if (!inner) { return current; } current = inner; @@ -262,17 +262,39 @@ function unwrapParentheses(node: SgNode): SgNode { return current; } +// The expression returned by a block that is nothing but `return ;`. +// Any other statement or comment in the block means it is kept as an IIFE. +function soleReturnExpression(block: SgNode): SgNode | null { + const statements = block.children().filter((child) => child.isNamed()); + const statement = statements[0]; + if (statements.length !== 1 || statement?.kind() !== "return_statement") { + return null; + } + + return namedChildren(statement)[0] ?? null; +} + // `useMemo(factory, deps)` evaluates to the factory's *result*, so the call is // replaced by that result, not by the factory itself. Expression-bodied arrows -// become their body; everything else is invoked in place. -function memoFactoryResultText(call: SgNode, factory: SgNode): string { +// and blocks that only return an expression become that expression; +// everything else is invoked in place. +function memoFactoryResultText(call: SgNode, memoFactory: SgNode): string { const bareParent = BARE_EXPRESSION_PARENTS.has(call.parent()?.kind() ?? ""); - const wrap = (text: string) => (bareParent ? text : `(${text})`); + const wrap = (expression: SgNode) => { + const text = expression.text(); + return bareParent && expression.kind() !== "sequence_expression" ? text : `(${text})`; + }; + const factory = unwrapParentheses(memoFactory); if (factory.kind() === "arrow_function" && !hasAnonymousChild(factory, "async")) { const body = factory.field("body"); if (body && body.kind() !== "statement_block") { - return wrap(unwrapParentheses(body).text()); + return wrap(unwrapParentheses(body)); + } + + const returned = body ? soleReturnExpression(body) : null; + if (returned) { + return wrap(returned); } } @@ -366,6 +388,7 @@ function collectUsedImportLocals( function importStatementReplacement( reactImport: ReactImportInfo, usedImportLocals: Set, + referencedImportLocals: Set, sourceCode: string, ): Edit | null { // Only the memoization imports are candidates for removal; an unrelated @@ -373,8 +396,12 @@ function importStatementReplacement( const keptNamedSpecifiers = reactImport.namedSpecifiers.filter((specifier) => usedImportLocals.has(specifier.localName) || !MEMOIZATION_IMPORTS.has(specifier.importedName) ); - const keepDefault = reactImport.defaultSpecifier ? usedImportLocals.has(reactImport.defaultSpecifier.localName) : false; - const keepNamespace = reactImport.namespaceSpecifier ? usedImportLocals.has(reactImport.namespaceSpecifier.localName) : false; + // A default or namespace import is only removed when this codemod is what + // made it unused; one that was already unused is left in place. + const keepObjectImport = (specifier: ImportSpecifierInfo | null) => + specifier ? usedImportLocals.has(specifier.localName) || !referencedImportLocals.has(specifier.localName) : false; + const keepDefault = keepObjectImport(reactImport.defaultSpecifier); + const keepNamespace = keepObjectImport(reactImport.namespaceSpecifier); // An import that loses no specifier keeps its original text, so formatting, // `import type` and semicolon style survive untouched. @@ -531,9 +558,10 @@ const transform: Transform = async (root) => { } const usedImportLocals = collectUsedImportLocals(rootNode, importedLocalNames, selectedCalls, selectedCallIds); + const referencedImportLocals = collectUsedImportLocals(rootNode, importedLocalNames, [], selectedCallIds); const sourceCode = rootNode.text(); for (const reactImport of reactImports) { - const edit = importStatementReplacement(reactImport, usedImportLocals, sourceCode); + const edit = importStatementReplacement(reactImport, usedImportLocals, referencedImportLocals, sourceCode); if (edit) { edits.push(edit); } diff --git a/codemods/remove-memoization/tests/default-import-removed/expected.tsx b/codemods/remove-memoization/tests/default-import-removed/expected.tsx new file mode 100644 index 0000000..10dc773 --- /dev/null +++ b/codemods/remove-memoization/tests/default-import-removed/expected.tsx @@ -0,0 +1 @@ +const value = computeValue(); diff --git a/codemods/remove-memoization/tests/default-import-removed/input.tsx b/codemods/remove-memoization/tests/default-import-removed/input.tsx new file mode 100644 index 0000000..a87c144 --- /dev/null +++ b/codemods/remove-memoization/tests/default-import-removed/input.tsx @@ -0,0 +1,3 @@ +import React from 'react'; + +const value = React.useMemo(() => computeValue(), [computeValue]); diff --git a/codemods/remove-memoization/tests/default-import-removed/metrics.json b/codemods/remove-memoization/tests/default-import-removed/metrics.json new file mode 100644 index 0000000..7d9d92a --- /dev/null +++ b/codemods/remove-memoization/tests/default-import-removed/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/default-import-removed/input.tsx", + "pattern": "useMemo" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-bare-contexts/expected.tsx b/codemods/remove-memoization/tests/use-memo-bare-contexts/expected.tsx new file mode 100644 index 0000000..350b0a5 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-bare-contexts/expected.tsx @@ -0,0 +1,11 @@ +let total; +total = a + b; + +function sum() { + return a + b; +} + +report(a + b); +const list = [a + b]; +const object = { sum: a + b }; +const grouped = (a + b); diff --git a/codemods/remove-memoization/tests/use-memo-bare-contexts/input.tsx b/codemods/remove-memoization/tests/use-memo-bare-contexts/input.tsx new file mode 100644 index 0000000..777d11f --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-bare-contexts/input.tsx @@ -0,0 +1,13 @@ +import { useMemo } from 'react'; + +let total; +total = useMemo(() => a + b, [a, b]); + +function sum() { + return useMemo(() => a + b, [a, b]); +} + +report(useMemo(() => a + b, [a, b])); +const list = [useMemo(() => a + b, [a, b])]; +const object = { sum: useMemo(() => a + b, [a, b]) }; +const grouped = (useMemo(() => a + b, [a, b])); diff --git a/codemods/remove-memoization/tests/use-memo-bare-contexts/metrics.json b/codemods/remove-memoization/tests/use-memo-bare-contexts/metrics.json new file mode 100644 index 0000000..fedb49e --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-bare-contexts/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-bare-contexts/input.tsx", + "pattern": "useMemo" + }, + "count": 6 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/expected.tsx b/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/expected.tsx new file mode 100644 index 0000000..a442c39 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/expected.tsx @@ -0,0 +1,19 @@ +const prepared = (() => { + const value = computeValue(); + return value; +})(); + +const commented = (() => { + // cached because computeValue is slow + return computeValue(); +})(); + +const sideEffect = (() => { + computeValue(); +})(); + +const empty = (() => {})(); + +const fromFunction = (function () { + return computeValue(); +})(); diff --git a/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/input.tsx b/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/input.tsx new file mode 100644 index 0000000..661d2e1 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/input.tsx @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; + +const prepared = useMemo(() => { + const value = computeValue(); + return value; +}, [computeValue]); + +const commented = useMemo(() => { + // cached because computeValue is slow + return computeValue(); +}, [computeValue]); + +const sideEffect = useMemo(() => { + computeValue(); +}, [computeValue]); + +const empty = useMemo(() => {}, []); + +const fromFunction = useMemo(function () { + return computeValue(); +}, [computeValue]); diff --git a/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/metrics.json b/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/metrics.json new file mode 100644 index 0000000..c872518 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-block-kept-as-iife/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-block-kept-as-iife/input.tsx", + "pattern": "useMemo" + }, + "count": 5 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-sequence-expression/expected.tsx b/codemods/remove-memoization/tests/use-memo-sequence-expression/expected.tsx new file mode 100644 index 0000000..19e0c4c --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-sequence-expression/expected.tsx @@ -0,0 +1,2 @@ +const fromBody = (track(), computeValue()); +const fromReturn = (track(), computeValue()); diff --git a/codemods/remove-memoization/tests/use-memo-sequence-expression/input.tsx b/codemods/remove-memoization/tests/use-memo-sequence-expression/input.tsx new file mode 100644 index 0000000..9e25118 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-sequence-expression/input.tsx @@ -0,0 +1,6 @@ +import { useMemo } from 'react'; + +const fromBody = useMemo(() => (track(), computeValue()), [computeValue]); +const fromReturn = useMemo(() => { + return track(), computeValue(); +}, [computeValue]); diff --git a/codemods/remove-memoization/tests/use-memo-sequence-expression/metrics.json b/codemods/remove-memoization/tests/use-memo-sequence-expression/metrics.json new file mode 100644 index 0000000..6c982ee --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-sequence-expression/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-sequence-expression/input.tsx", + "pattern": "useMemo" + }, + "count": 2 + } + ] +} \ No newline at end of file From 636dece71ab917972ff994f579ed7b1ef2a2bc79 Mon Sep 17 00:00:00 2001 From: Alex Bit Date: Sat, 31 Oct 2026 14:17:08 -0700 Subject: [PATCH 4/4] fix(remove-memoization): harden useMemo unwrap edge cases Address Codex review: keep parameterized factories as invocations, preserve return/generic types, invoke member factories unbound, convert nested memo calls inside-out, and remove unused import aliases by local binding. Add fixtures, update the README/changeset, and ignore local .pnpm-store caches. Co-authored-by: Cursor --- .changeset/fix-use-memo-result.md | 6 +- .gitignore | 1 + codemods/remove-memoization/README.md | 16 +- .../remove-memoization/scripts/codemod.ts | 288 ++++++++++++++---- .../expected.tsx | 6 + .../input.tsx | 6 + .../metrics.json | 18 ++ .../tests/use-memo-nested-calls/expected.tsx | 3 + .../tests/use-memo-nested-calls/input.tsx | 5 + .../tests/use-memo-nested-calls/metrics.json | 18 ++ .../expected.tsx | 4 + .../use-memo-parameterized-factory/input.tsx | 6 + .../metrics.json | 11 + .../use-memo-preserve-types/expected.tsx | 5 + .../tests/use-memo-preserve-types/input.tsx | 7 + .../use-memo-preserve-types/metrics.json | 11 + .../use-memo-unbound-member/expected.tsx | 3 + .../tests/use-memo-unbound-member/input.tsx | 5 + .../use-memo-unbound-member/metrics.json | 11 + 19 files changed, 361 insertions(+), 69 deletions(-) create mode 100644 codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/expected.tsx create mode 100644 codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/input.tsx create mode 100644 codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-nested-calls/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-nested-calls/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-nested-calls/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-parameterized-factory/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-parameterized-factory/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-parameterized-factory/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-preserve-types/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-preserve-types/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-preserve-types/metrics.json create mode 100644 codemods/remove-memoization/tests/use-memo-unbound-member/expected.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-unbound-member/input.tsx create mode 100644 codemods/remove-memoization/tests/use-memo-unbound-member/metrics.json diff --git a/.changeset/fix-use-memo-result.md b/.changeset/fix-use-memo-result.md index 2dcee8f..9845601 100644 --- a/.changeset/fix-use-memo-result.md +++ b/.changeset/fix-use-memo-result.md @@ -3,5 +3,7 @@ --- Replace `useMemo(factory, deps)` with the factory's result instead of the factory function, so memoized values -stay values. Preserve `import type` and semicolon style on React imports, and leave imports that lose no -specifier untouched. +stay values. Only unwrap parameterless factories; keep return-type annotations and `useMemo` typing; invoke +member factories unbound via `(0, obj.fn)()`; convert nested memoization inside-out; and remove unused import +bindings by local alias so duplicate `useMemo as a` / `useMemo as b` specifiers stay independent. Preserve +`import type` and semicolon style on React imports, and leave imports that lose no specifier untouched. diff --git a/.gitignore b/.gitignore index 9ab5e4e..4f0f42c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.pnpm-store/ dist/ *.log .DS_Store diff --git a/codemods/remove-memoization/README.md b/codemods/remove-memoization/README.md index e4b1105..a769f1f 100644 --- a/codemods/remove-memoization/README.md +++ b/codemods/remove-memoization/README.md @@ -5,13 +5,19 @@ Remove manual React memoization calls: `useCallback(...)`, `useMemo(...)`, and ` This codemod is intended for React Compiler adoption and mirrors `react/19/remove-memoization` from `codemod/commons`. `useCallback(fn, deps)` and `memo(Component)` are replaced by their first argument. `useMemo(factory, deps)` -is replaced by the factory's *result*: an expression-bodied arrow becomes its body, as does a block body that only -returns an expression (`useMemo(() => { return x; }, deps)` becomes `x`); anything else is invoked in place -(`useMemo(() => { ... }, deps)` becomes `(() => { ... })()`), so the surrounding code keeps receiving a value -rather than a function. +is replaced by the factory's *result*: + +- a parameterless expression-bodied arrow becomes its body (`useMemo(() => x, deps)` → `x`) +- a parameterless block that only returns an expression does the same (`useMemo(() => { return x; }, deps)` → `x`) +- arrows with parameters, `async` factories, and other bodies are invoked in place (`((x = f()) => x)()`, `(async () => …)()`) +- return-type annotations stay on an IIFE; `useMemo(…)` without a return annotation keeps `T` via `as T` +- member factories are invoked unbound (`useMemo(obj.fn, [])` → `(0, obj.fn)()`) to match React’s call +- nested memoization is converted inside-out (`useMemo(useMemo(() => () => 42))` → `42`) Only React imports that lose a specifier are rewritten; `import type` and the file's semicolon style are preserved. -A default or namespace React import is removed only when the removed memoization calls were its last uses. +Unused memoization bindings are removed by local alias, so duplicate aliases of the same export +(`useMemo as a` vs `useMemo as b`) are handled independently. A default or namespace React import is removed only +when the removed memoization calls were its last uses. > This is not a universally safe codemod. React Compiler is not a 1:1 replacement for every manual `useMemo`, `useCallback`, or `memo` occurrence, so some memoization may need to be kept to preserve semantics. diff --git a/codemods/remove-memoization/scripts/codemod.ts b/codemods/remove-memoization/scripts/codemod.ts index f1c97d0..3adb36c 100644 --- a/codemods/remove-memoization/scripts/codemod.ts +++ b/codemods/remove-memoization/scripts/codemod.ts @@ -122,22 +122,6 @@ function memoizationCallInfo( return MEMOIZATION_IMPORTS.has(importedName) ? { call, importedName } : null; } -function firstMemoizationArgument( - call: SgNode, - selectedCallIds: Set, -): SgNode | null { - const firstArg = callArguments(call)[0] ?? null; - if (!firstArg) { - return null; - } - - if (selectedCallIds.has(firstArg.id())) { - return firstMemoizationArgument(firstArg, selectedCallIds); - } - - return firstArg; -} - // Parents where an arbitrary expression can stand in for the call without // extra parentheses. Anywhere else (`a + useMemo(...)`, `!useMemo(...)`) the // factory result is wrapped so operator precedence is preserved. @@ -180,46 +164,215 @@ function soleReturnExpression(block: SgNode): SgNode | null { return namedChildren(statement)[0] ?? null; } +function arrowFormalParameters(factory: SgNode): SgNode | null { + const params = factory.field("parameters"); + if (params?.kind() === "formal_parameters") { + return params; + } + + return namedChildren(factory).find((child) => child.kind() === "formal_parameters") ?? null; +} + +// Parameterless factories are `() => ...`. Any binding, default, rest, or +// destructuring parameter keeps its own scope and must stay as an invocation. +function isParameterlessArrow(factory: SgNode): boolean { + if (factory.field("parameter")) { + return false; + } + + const formal = arrowFormalParameters(factory); + if (formal) { + return namedChildren(formal).length === 0; + } + + // `x => x` has no formal_parameters node; the binding is a named child that + // is not the body (and not a type annotation / type parameters). + const body = factory.field("body"); + return !namedChildren(factory).some((child) => { + if (body && child.id() === body.id()) { + return false; + } + const kind = child.kind(); + return kind !== "type_parameters" && kind !== "type_annotation" && kind !== "returning_type_annotation"; + }); +} + +function arrowReturnTypeAnnotation(factory: SgNode): SgNode | null { + return ( + factory.field("return_type") ?? + namedChildren(factory).find( + (child) => child.kind() === "type_annotation" || child.kind() === "returning_type_annotation", + ) ?? + null + ); +} + +function callTypeArgumentsText(call: SgNode): string | null { + const typeArgs = call.field("type_arguments"); + if (!typeArgs) { + return null; + } + + // type_arguments text is `` or ``; strip the brackets. + const text = typeArgs.text(); + if (text.startsWith("<") && text.endsWith(">")) { + return text.slice(1, -1).trim(); + } + + return text.trim() || null; +} + +function wrapExpressionText(call: SgNode, expression: SgNode): string { + const bareParent = BARE_EXPRESSION_PARENTS.has(call.parent()?.kind() ?? ""); + const text = expression.text(); + return bareParent && expression.kind() !== "sequence_expression" ? text : `(${text})`; +} + +function wrapRawExpressionText(call: SgNode, text: string, forceParen = false): string { + const bareParent = BARE_EXPRESSION_PARENTS.has(call.parent()?.kind() ?? ""); + return !forceParen && bareParent ? text : `(${text})`; +} + +function invokeFactoryText(call: SgNode, factory: SgNode): string { + if (factory.kind() === "identifier") { + return `${factory.text()}()`; + } + + // Member references must stay unbound: React calls the factory as a bare + // function, so `(obj.compute)()` would incorrectly set `this` to `obj`. + if (factory.kind() === "member_expression" || factory.kind() === "subscript_expression") { + return `(0, ${factory.text()})()`; + } + + return `(${factory.text()})()`; +} + +// Extract the expression a useMemo factory evaluates to, when that is safe. +// Returns null when the factory must be invoked instead (params, async, +// return type annotation, non-trivial body, etc.). +function extractableUseMemoResult(factory: SgNode): SgNode | null { + const unwrapped = unwrapParentheses(factory); + + if (unwrapped.kind() !== "arrow_function" || hasAnonymousChild(unwrapped, "async")) { + return null; + } + + if (!isParameterlessArrow(unwrapped) || arrowReturnTypeAnnotation(unwrapped)) { + return null; + } + + const body = unwrapped.field("body"); + if (!body) { + return null; + } + + if (body.kind() !== "statement_block") { + return unwrapParentheses(body); + } + + return soleReturnExpression(body); +} + +function applyTypeAssertion(call: SgNode, expressionText: string): string { + const typeArgs = callTypeArgumentsText(call); + if (!typeArgs) { + return expressionText; + } + + // `as` binds looser than most operators; parenthesize the asserted value and + // wrap again when the call sits in an operator position. + const asserted = `(${expressionText}) as ${typeArgs}`; + const bareParent = BARE_EXPRESSION_PARENTS.has(call.parent()?.kind() ?? ""); + return bareParent ? asserted : `(${asserted})`; +} + // `useMemo(factory, deps)` evaluates to the factory's *result*, so the call is // replaced by that result, not by the factory itself. Expression-bodied arrows // and blocks that only return an expression become that expression; // everything else is invoked in place. function memoFactoryResultText(call: SgNode, memoFactory: SgNode): string { - const bareParent = BARE_EXPRESSION_PARENTS.has(call.parent()?.kind() ?? ""); - const wrap = (expression: SgNode) => { - const text = expression.text(); - return bareParent && expression.kind() !== "sequence_expression" ? text : `(${text})`; - }; const factory = unwrapParentheses(memoFactory); - - if (factory.kind() === "arrow_function" && !hasAnonymousChild(factory, "async")) { - const body = factory.field("body"); - if (body && body.kind() !== "statement_block") { - return wrap(unwrapParentheses(body)); - } - - const returned = body ? soleReturnExpression(body) : null; - if (returned) { - return wrap(returned); + const extracted = extractableUseMemoResult(factory); + if (extracted) { + // Preserve useMemo contextual typing when the factory itself has no + // return annotation (those keep the annotated IIFE path below). + if (callTypeArgumentsText(call)) { + return applyTypeAssertion(call, extracted.text()); } + return wrapExpressionText(call, extracted); } - if (factory.kind() === "identifier") { - return `${factory.text()}()`; + // Annotated arrows keep their return type by staying as invocations. + // Type arguments on the call are redundant once the annotation remains. + if ( + factory.kind() === "arrow_function" && + !hasAnonymousChild(factory, "async") && + arrowReturnTypeAnnotation(factory) && + isParameterlessArrow(factory) + ) { + return `(${factory.text()})()`; } - return `(${factory.text()})()`; + const invoked = invokeFactoryText(call, factory); + return callTypeArgumentsText(call) && !arrowReturnTypeAnnotation(factory) + ? applyTypeAssertion(call, invoked) + : invoked; } -function replacementText( +function memoizationResultText( match: MemoizationCall, selectedCallIds: Set, + namedImports: Map, + reactObjectNames: Set, ): string | null { - const firstArg = firstMemoizationArgument(match.call, selectedCallIds); + const firstArg = callArguments(match.call)[0] ?? null; if (!firstArg) { return null; } + // Nested memoization: convert the inner call first, then apply this layer's + // semantics to that result (useMemo invokes it; useCallback/memo keep it). + if (selectedCallIds.has(firstArg.id()) && firstArg.kind() === "call_expression") { + const inner = memoizationCallInfo(firstArg, namedImports, reactObjectNames); + if (!inner) { + return null; + } + + const innerResult = memoizationResultText(inner, selectedCallIds, namedImports, reactObjectNames); + if (innerResult === null) { + return null; + } + + if (match.importedName !== "useMemo") { + return innerResult; + } + + // Prefer AST-based unwrapping when the inner result is still an extractable + // factory node (common for useCallback/memo wrappers). Otherwise invoke the + // inner result text as the outer factory. + if (inner.importedName !== "useMemo") { + const innerFactory = callArguments(inner.call)[0]; + if (innerFactory) { + return memoFactoryResultText(match.call, innerFactory); + } + } + + // Inner was useMemo: its result text is the outer factory value. If that + // text is itself a parameterless expression-bodied arrow we can unwrap + // once more via a lightweight parse of the common `() => expr` shape by + // looking at the inner factory's extractable result and treating *that* + // as the outer factory. + const innerFactory = callArguments(inner.call)[0]; + if (innerFactory) { + const innerExtracted = extractableUseMemoResult(unwrapParentheses(innerFactory)); + if (innerExtracted) { + return memoFactoryResultText(match.call, innerExtracted); + } + } + + return wrapRawExpressionText(match.call, `(${innerResult})()`, true); + } + if (match.importedName === "useMemo") { return memoFactoryResultText(match.call, firstArg); } @@ -227,6 +380,15 @@ function replacementText( return firstArg.text(); } +function replacementText( + match: MemoizationCall, + selectedCallIds: Set, + namedImports: Map, + reactObjectNames: Set, +): string | null { + return memoizationResultText(match, selectedCallIds, namedImports, reactObjectNames); +} + function keptRanges(call: SgNode, selectedCallIds: Set): Range[] { const firstArg = callArguments(call)[0] ?? null; if (!firstArg) { @@ -346,14 +508,21 @@ function consumeFollowingBlankLine(edit: Edit, programText: string): Edit { return edit; } +function specifierLocalName(specifier: SgNode): string { + return specifier.field("alias")?.text() ?? specifier.field("name")?.text() ?? ""; +} + // Removes several named specifiers from one statement that keeps at least one. -// `removeImport` takes a separator with each specifier, so removing neighbours +// Specifiers are matched by local binding name (alias or exported name) so two +// aliases of the same export can be removed independently. `removeImport` +// takes a separator with each specifier, so removing neighbours // (`useCallback, useMemo`) one by one would produce overlapping edits. Each // run of removed specifiers is instead cut up to the next kept one, or back // to the previous kept one when the run is last, preserving formatting. -function removeSpecifierEdits(statement: SgNode, importedNames: string[]): Edit[] { +function removeSpecifierEdits(statement: SgNode, unusedLocalNames: string[]): Edit[] { + const unused = new Set(unusedLocalNames); const specifiers = statement.findAll({ rule: { kind: "import_specifier" } }); - const removed = specifiers.map((specifier) => importedNames.includes(specifier.field("name")?.text() ?? "")); + const removed = specifiers.map((specifier) => unused.has(specifierLocalName(specifier))); const edits: Edit[] = []; for (let index = 0; index < specifiers.length; index++) { @@ -403,7 +572,10 @@ function cleanupUnusedImports( continue; } - const unusedInStatement: string[] = []; + // Track unused *local* bindings, not exported names, so `useMemo as a` + // can be removed while `useMemo as b` remains. + const unusedLocalsInStatement: string[] = []; + const unusedExportNames = new Set(); for (const memoName of MEMOIZATION_NAMES) { for (const sibling of getAllImports(rootNode, { type: "named", @@ -414,22 +586,24 @@ function cleanupUnusedImports( continue; } if (importStatementForBinding(sibling.node)?.id() === statement.id()) { - unusedInStatement.push(memoName); + unusedLocalsInStatement.push(sibling.alias); + unusedExportNames.add(memoName); } } } - const removingAllNamed = unusedInStatement.length >= namedSpecifierCount(statement); + const removingAllNamed = unusedLocalsInStatement.length >= namedSpecifierCount(statement); const binding = defaultOrNamespaceLocal(statement); const keepBinding = binding ? keepObjectImport(binding.alias) : false; - if (removingAllNamed && !keepBinding) { - // Drop the whole statement in one edit (avoids overlapping default+named removals). + if (removingAllNamed) { + // removeImport drops the whole statement, or only the `, { ... }` clause + // when a default/namespace binding must stay. const removeEdit = removeImport(rootNode, { type: "named", - specifiers: unusedInStatement, + specifiers: [...unusedExportNames], from: "react", - }) ?? (binding + }) ?? (!keepBinding && binding ? removeImport(rootNode, { type: binding.isNamespace ? "namespace" : "default", from: "react", @@ -443,24 +617,14 @@ function cleanupUnusedImports( continue; } - if (!removingAllNamed && unusedInStatement.length > 1) { - edits.push(...removeSpecifierEdits(statement, unusedInStatement)); + // Partial removal: match by local binding so duplicate export aliases + // (`useMemo as a` vs `useMemo as b`) can be removed independently. + // removeImport matches the first export name and would delete the wrong one. + if (unusedLocalsInStatement.length > 0) { + edits.push(...removeSpecifierEdits(statement, unusedLocalsInStatement)); handledStatements.add(statement.id()); continue; } - - const removeEdit = removeImport(rootNode, { - type: "named", - specifiers: removingAllNamed ? unusedInStatement : [name], - from: "react", - }); - - if (removeEdit) { - edits.push(consumeFollowingBlankLine(removeEdit, programText)); - if (removingAllNamed) { - handledStatements.add(statement.id()); - } - } } } @@ -527,7 +691,7 @@ const transform: Transform = async (root) => { const metricCounts = new Map(); for (const match of selectedMatches) { - const replacement = replacementText(match, selectedCallIds); + const replacement = replacementText(match, selectedCallIds, namedImports, reactObjectNames); if (!replacement) { continue; } diff --git a/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/expected.tsx b/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/expected.tsx new file mode 100644 index 0000000..aaf0237 --- /dev/null +++ b/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/expected.tsx @@ -0,0 +1,6 @@ +import { useMemo as b, useState } from 'react'; + +const value = computeValue(); +const handler = () => value; +const stillUsed = b; +const [state] = useState(0); diff --git a/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/input.tsx b/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/input.tsx new file mode 100644 index 0000000..3c0b76f --- /dev/null +++ b/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/input.tsx @@ -0,0 +1,6 @@ +import { useMemo as a, useMemo as b, useCallback, useState } from 'react'; + +const value = a(() => computeValue(), [computeValue]); +const handler = useCallback(() => value, [value]); +const stillUsed = b; +const [state] = useState(0); diff --git a/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/metrics.json b/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/metrics.json new file mode 100644 index 0000000..95ec9a3 --- /dev/null +++ b/codemods/remove-memoization/tests/duplicate-usememo-alias-preserved/metrics.json @@ -0,0 +1,18 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/duplicate-usememo-alias-preserved/input.tsx", + "pattern": "useCallback" + }, + "count": 1 + }, + { + "cardinality": { + "file": "tests/duplicate-usememo-alias-preserved/input.tsx", + "pattern": "useMemo" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-nested-calls/expected.tsx b/codemods/remove-memoization/tests/use-memo-nested-calls/expected.tsx new file mode 100644 index 0000000..7f80933 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-nested-calls/expected.tsx @@ -0,0 +1,3 @@ +const nestedMemos = 42; +const memoOverCallback = 42; +const callbackOverMemo = () => 42; diff --git a/codemods/remove-memoization/tests/use-memo-nested-calls/input.tsx b/codemods/remove-memoization/tests/use-memo-nested-calls/input.tsx new file mode 100644 index 0000000..f659638 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-nested-calls/input.tsx @@ -0,0 +1,5 @@ +import { useCallback, useMemo } from 'react'; + +const nestedMemos = useMemo(useMemo(() => () => 42, []), []); +const memoOverCallback = useMemo(useCallback(() => 42, []), []); +const callbackOverMemo = useCallback(useMemo(() => () => 42, []), []); diff --git a/codemods/remove-memoization/tests/use-memo-nested-calls/metrics.json b/codemods/remove-memoization/tests/use-memo-nested-calls/metrics.json new file mode 100644 index 0000000..eec01d3 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-nested-calls/metrics.json @@ -0,0 +1,18 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-nested-calls/input.tsx", + "pattern": "useCallback" + }, + "count": 1 + }, + { + "cardinality": { + "file": "tests/use-memo-nested-calls/input.tsx", + "pattern": "useMemo" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-parameterized-factory/expected.tsx b/codemods/remove-memoization/tests/use-memo-parameterized-factory/expected.tsx new file mode 100644 index 0000000..83d9500 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-parameterized-factory/expected.tsx @@ -0,0 +1,4 @@ +const withDefault = ((x = computeValue()) => x)(); +const withParam = ((x) => x)(); +const withRest = ((...args) => args.length)(); +const withDestructure = (({ value } = defaults) => value)(); diff --git a/codemods/remove-memoization/tests/use-memo-parameterized-factory/input.tsx b/codemods/remove-memoization/tests/use-memo-parameterized-factory/input.tsx new file mode 100644 index 0000000..1f122c5 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-parameterized-factory/input.tsx @@ -0,0 +1,6 @@ +import { useMemo } from 'react'; + +const withDefault = useMemo((x = computeValue()) => x, []); +const withParam = useMemo((x) => x, []); +const withRest = useMemo((...args) => args.length, []); +const withDestructure = useMemo(({ value } = defaults) => value, [defaults]); diff --git a/codemods/remove-memoization/tests/use-memo-parameterized-factory/metrics.json b/codemods/remove-memoization/tests/use-memo-parameterized-factory/metrics.json new file mode 100644 index 0000000..5f1693c --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-parameterized-factory/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-parameterized-factory/input.tsx", + "pattern": "useMemo" + }, + "count": 4 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-preserve-types/expected.tsx b/codemods/remove-memoization/tests/use-memo-preserve-types/expected.tsx new file mode 100644 index 0000000..3290aa8 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-preserve-types/expected.tsx @@ -0,0 +1,5 @@ +type Config = { mode: 'on' | 'off' }; + +const fromReturnType = ((): Config => ({ mode: 'on' }))(); +const fromGeneric = ({ mode: 'on' }) as Config; +const fromBoth = ((): Config => ({ mode: 'off' }))(); diff --git a/codemods/remove-memoization/tests/use-memo-preserve-types/input.tsx b/codemods/remove-memoization/tests/use-memo-preserve-types/input.tsx new file mode 100644 index 0000000..b689a81 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-preserve-types/input.tsx @@ -0,0 +1,7 @@ +import { useMemo } from 'react'; + +type Config = { mode: 'on' | 'off' }; + +const fromReturnType = useMemo((): Config => ({ mode: 'on' }), []); +const fromGeneric = useMemo(() => ({ mode: 'on' }), []); +const fromBoth = useMemo((): Config => ({ mode: 'off' }), []); diff --git a/codemods/remove-memoization/tests/use-memo-preserve-types/metrics.json b/codemods/remove-memoization/tests/use-memo-preserve-types/metrics.json new file mode 100644 index 0000000..736f339 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-preserve-types/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-preserve-types/input.tsx", + "pattern": "useMemo" + }, + "count": 3 + } + ] +} \ No newline at end of file diff --git a/codemods/remove-memoization/tests/use-memo-unbound-member/expected.tsx b/codemods/remove-memoization/tests/use-memo-unbound-member/expected.tsx new file mode 100644 index 0000000..9e0d75e --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-unbound-member/expected.tsx @@ -0,0 +1,3 @@ +const fromMember = (0, obj.compute)(); +const fromNestedMember = (0, helpers.obj.compute)(); +const fromParenMember = (0, obj.compute)(); diff --git a/codemods/remove-memoization/tests/use-memo-unbound-member/input.tsx b/codemods/remove-memoization/tests/use-memo-unbound-member/input.tsx new file mode 100644 index 0000000..c6ff0c4 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-unbound-member/input.tsx @@ -0,0 +1,5 @@ +import { useMemo } from 'react'; + +const fromMember = useMemo(obj.compute, []); +const fromNestedMember = useMemo(helpers.obj.compute, []); +const fromParenMember = useMemo((obj.compute), []); diff --git a/codemods/remove-memoization/tests/use-memo-unbound-member/metrics.json b/codemods/remove-memoization/tests/use-memo-unbound-member/metrics.json new file mode 100644 index 0000000..6a5f294 --- /dev/null +++ b/codemods/remove-memoization/tests/use-memo-unbound-member/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-memoization-replacements": [ + { + "cardinality": { + "file": "tests/use-memo-unbound-member/input.tsx", + "pattern": "useMemo" + }, + "count": 3 + } + ] +} \ No newline at end of file