Skip to content

Commit 4c97e2f

Browse files
committed
port replace-react-test-rendered-import
1 parent be3a862 commit 4c97e2f

28 files changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# replace-react-test-renderer-import
2+
3+
Replace `react-test-renderer/shallow` module specifiers with `react-shallow-renderer`.
4+
5+
This codemod mirrors `react/19/replace-react-test-renderer-import` from `codemod/commons`.
6+
7+
## Usage
8+
9+
```bash
10+
npx codemod @react-new/replace-react-test-renderer-import --target <path>
11+
```
12+
13+
## Example
14+
15+
### Before
16+
17+
```tsx
18+
import ShallowRenderer from "react-test-renderer/shallow";
19+
```
20+
21+
### After
22+
23+
```tsx
24+
import ShallowRenderer from "react-shallow-renderer";
25+
```
26+
27+
## Behavior
28+
29+
- Rewrites ESM import declarations.
30+
- Rewrites direct CommonJS `require("react-test-renderer/shallow")` calls.
31+
- Also rewrites export-from declarations and dynamic `import(...)` calls that reference the same module specifier.
32+
- Does not rewrite arbitrary string literals or `require.resolve(...)` calls.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
schema_version: "1.0"
2+
3+
name: "@react-new/replace-react-test-renderer-import"
4+
version: "0.1.1"
5+
description: "Replace react-test-renderer shallow renderer imports with react-shallow-renderer"
6+
author: "Codemod <hello@codemod.com>"
7+
license: "MIT"
8+
workflow: "workflow.yaml"
9+
repository: "https://github.com/codemod/jssg-react-codemods/tree/main/codemods/replace-react-test-renderer-import"
10+
11+
targets:
12+
languages: ["tsx"]
13+
14+
keywords: ["React", "react-test-renderer", "shallow", "migration"]
15+
16+
registry:
17+
access: "public"
18+
visibility: "private"
19+
20+
capabilities: []
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "@react-new/replace-react-test-renderer-import",
3+
"version": "0.1.1",
4+
"description": "Replace react-test-renderer shallow renderer imports with react-shallow-renderer",
5+
"type": "module",
6+
"scripts": {
7+
"test": "pnpm dlx codemod@latest jssg test -l tsx ./scripts/codemod.ts",
8+
"check-types": "tsc --noEmit"
9+
},
10+
"devDependencies": {
11+
"@codemod.com/jssg-types": "latest",
12+
"@types/node": "latest",
13+
"typescript": "latest"
14+
}
15+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import type { Edit, SgNode, Transform } from "codemod:ast-grep";
2+
import type TSX from "codemod:ast-grep/langs/tsx";
3+
import { useMetricAtom } from "codemod:metrics";
4+
5+
const OLD_MODULE = "react-test-renderer/shallow";
6+
const NEW_MODULE = "react-shallow-renderer";
7+
8+
type ReplacementPattern = "import" | "require" | "export" | "dynamic-import";
9+
10+
function metricFile(filename: string): string {
11+
const cwd = process.cwd() + "/";
12+
return filename.startsWith(cwd) ? filename.slice(cwd.length) : filename;
13+
}
14+
15+
function namedChildren(node: SgNode<TSX> | null): SgNode<TSX>[] {
16+
if (!node) {
17+
return [];
18+
}
19+
20+
return node.children().filter((child) => child.isNamed() && child.kind() !== "comment");
21+
}
22+
23+
function literalContent(node: SgNode<TSX>): string | null {
24+
if (!node.is("string")) {
25+
return null;
26+
}
27+
28+
const text = node.text();
29+
if (text.length < 2) {
30+
return null;
31+
}
32+
33+
return text.slice(1, -1).replaceAll("\\/", "/");
34+
}
35+
36+
function literalMatchesOldModule(node: SgNode<TSX>): boolean {
37+
return literalContent(node) === OLD_MODULE;
38+
}
39+
40+
function fieldMatches(parent: SgNode<TSX>, fieldName: "source", child: SgNode<TSX>): boolean {
41+
return parent.field(fieldName)?.id() === child.id();
42+
}
43+
44+
function nearestAncestor(node: SgNode<TSX>, kind: string): SgNode<TSX> | null {
45+
return node.ancestors().find((ancestor) => ancestor.kind() === kind) ?? null;
46+
}
47+
48+
function isFirstCallArgument(call: SgNode<TSX>, node: SgNode<TSX>): boolean {
49+
return namedChildren(call.field("arguments"))[0]?.id() === node.id();
50+
}
51+
52+
function isDirectRequireCall(call: SgNode<TSX>): boolean {
53+
const callee = call.field("function");
54+
return callee?.kind() === "identifier" && callee.text() === "require";
55+
}
56+
57+
function isDynamicImportCall(call: SgNode<TSX>): boolean {
58+
const callee = call.field("function");
59+
return callee?.kind() === "import" && callee.text() === "import";
60+
}
61+
62+
function replacementPatternForLiteral(node: SgNode<TSX>): ReplacementPattern | null {
63+
const importStatement = nearestAncestor(node, "import_statement");
64+
if (importStatement && fieldMatches(importStatement, "source", node)) {
65+
return "import";
66+
}
67+
68+
const exportStatement = nearestAncestor(node, "export_statement");
69+
if (exportStatement && fieldMatches(exportStatement, "source", node)) {
70+
return "export";
71+
}
72+
73+
const callExpression = nearestAncestor(node, "call_expression");
74+
if (!callExpression || !isFirstCallArgument(callExpression, node)) {
75+
return null;
76+
}
77+
78+
if (isDirectRequireCall(callExpression)) {
79+
return "require";
80+
}
81+
82+
if (isDynamicImportCall(callExpression)) {
83+
return "dynamic-import";
84+
}
85+
86+
return null;
87+
}
88+
89+
function replacementEdit(node: SgNode<TSX>): Edit {
90+
const fragment = node.find({
91+
rule: {
92+
kind: "string_fragment",
93+
regex: `^${OLD_MODULE}$`,
94+
},
95+
});
96+
97+
if (fragment) {
98+
return fragment.replace(NEW_MODULE);
99+
}
100+
101+
const quote = node.text().startsWith("'") ? "'" : "\"";
102+
return node.replace(`${quote}${NEW_MODULE}${quote}`);
103+
}
104+
105+
const transform: Transform<TSX> = async (root) => {
106+
const rootNode = root.root();
107+
const metric = useMetricAtom("replace-react-test-renderer-import-replacements");
108+
const edits: Edit[] = [];
109+
110+
const moduleLiterals = rootNode.findAll({
111+
rule: {
112+
kind: "string",
113+
has: { kind: "string_fragment" },
114+
},
115+
});
116+
117+
for (const moduleLiteral of moduleLiterals) {
118+
if (!literalMatchesOldModule(moduleLiteral)) {
119+
continue;
120+
}
121+
122+
const pattern = replacementPatternForLiteral(moduleLiteral);
123+
if (!pattern) {
124+
continue;
125+
}
126+
127+
edits.push(replacementEdit(moduleLiteral));
128+
metric.increment({ file: metricFile(root.filename()), pattern });
129+
}
130+
131+
return edits.length > 0 ? rootNode.commitEdits(edits) : null;
132+
};
133+
134+
export default transform;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
async function loadRenderer() {
2+
return import("react-shallow-renderer");
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
async function loadRenderer() {
2+
return import("react-test-renderer/shallow");
3+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"replace-react-test-renderer-import-replacements": [
3+
{
4+
"cardinality": {
5+
"file": "tests/dynamic-import/input.tsx",
6+
"pattern": "dynamic-import"
7+
},
8+
"count": 1
9+
}
10+
]
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as ShallowRenderer } from "react-shallow-renderer";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as ShallowRenderer } from "react-test-renderer/shallow";
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"replace-react-test-renderer-import-replacements": [
3+
{
4+
"cardinality": {
5+
"file": "tests/export-from/input.tsx",
6+
"pattern": "export"
7+
},
8+
"count": 1
9+
}
10+
]
11+
}

0 commit comments

Comments
 (0)