@thinksharpe/react-compiler-unmemo 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/docs/architecture.md +157 -0
- package/docs/edge-cases.md +247 -0
- package/helpers/fix-type-annotations.mjs +153 -0
- package/helpers/remove-hooks.mjs +522 -0
- package/package.json +51 -0
- package/react-compiler-unmemo.mjs +128 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* fix-type-annotations.mjs
|
|
5
|
+
*
|
|
6
|
+
* Post-migration helper that attempts to restore type annotations
|
|
7
|
+
* that were lost when useMemo<Type>() generics were stripped.
|
|
8
|
+
*
|
|
9
|
+
* Scans for common patterns (untyped `columns` arrays, `formFields` arrays)
|
|
10
|
+
* and infers the correct type annotation from surrounding code context.
|
|
11
|
+
*
|
|
12
|
+
* Can be used standalone or imported by run.mjs.
|
|
13
|
+
*
|
|
14
|
+
* Standalone usage:
|
|
15
|
+
* node fix-type-annotations.mjs --dir <path> [--dry-run]
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "fs";
|
|
19
|
+
import { glob } from "glob";
|
|
20
|
+
import path from "path";
|
|
21
|
+
|
|
22
|
+
// ─── Fix Patterns ────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
// Each pattern describes a variable that may have lost its type annotation
|
|
25
|
+
// when useMemo<Type>() was stripped. The script tries to infer the correct
|
|
26
|
+
// type from surrounding code context.
|
|
27
|
+
const fixPatterns = [
|
|
28
|
+
{
|
|
29
|
+
// const columns = [ -> const columns: ColumnsType<T> = [
|
|
30
|
+
description: "ColumnsType on columns arrays",
|
|
31
|
+
filePattern: /\.(tsx|ts)$/,
|
|
32
|
+
search: /^(\s*const columns)\s*=\s*\[/m,
|
|
33
|
+
alreadyTyped: /const columns\s*:\s*ColumnsType/,
|
|
34
|
+
getType: (content) => {
|
|
35
|
+
// Infer T from SorterResult<T>, Table<T>, or ColumnType<T> usage
|
|
36
|
+
const sorterMatch = content.match(/SorterResult<(\w+)>/);
|
|
37
|
+
if (sorterMatch) return `ColumnsType<${sorterMatch[1]}>`;
|
|
38
|
+
const tableMatch = content.match(/Table<(\w+)>/);
|
|
39
|
+
if (tableMatch) return `ColumnsType<${tableMatch[1]}>`;
|
|
40
|
+
const colMatch = content.match(/ColumnType<(\w+)>/);
|
|
41
|
+
if (colMatch) return `ColumnsType<${colMatch[1]}>`;
|
|
42
|
+
return null;
|
|
43
|
+
},
|
|
44
|
+
replace: (groups, type) => `${groups[1]}: ${type} = [`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
// const formFields = [ -> const formFields: FormFieldProps[] = [
|
|
48
|
+
description: "FormFieldProps[] on formFields arrays",
|
|
49
|
+
filePattern: /\.(tsx|ts)$/,
|
|
50
|
+
search: /^(\s*const formFields)\s*=\s*\[/m,
|
|
51
|
+
alreadyTyped: /const formFields\s*:\s*FormFieldProps/,
|
|
52
|
+
getType: () => "FormFieldProps[]",
|
|
53
|
+
replace: (groups, type) => `${groups[1]}: ${type} = [`,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// ─── Processing ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function processFile(filePath, targetDir, { dryRun = false } = {}) {
|
|
60
|
+
let content = fs.readFileSync(filePath, "utf8");
|
|
61
|
+
let changed = false;
|
|
62
|
+
const fixes = [];
|
|
63
|
+
|
|
64
|
+
for (const pattern of fixPatterns) {
|
|
65
|
+
if (!pattern.filePattern.test(filePath)) continue;
|
|
66
|
+
if (pattern.alreadyTyped.test(content)) continue;
|
|
67
|
+
|
|
68
|
+
const match = content.match(pattern.search);
|
|
69
|
+
if (!match) continue;
|
|
70
|
+
|
|
71
|
+
const type = pattern.getType(content);
|
|
72
|
+
if (!type) continue;
|
|
73
|
+
|
|
74
|
+
content = content.replace(pattern.search, (fullMatch, ...groups) => {
|
|
75
|
+
return pattern.replace([fullMatch, ...groups], type);
|
|
76
|
+
});
|
|
77
|
+
changed = true;
|
|
78
|
+
fixes.push({ type, description: pattern.description });
|
|
79
|
+
console.log(` ✓ ${path.relative(targetDir, filePath)} — added ${type}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (changed && !dryRun) {
|
|
83
|
+
fs.writeFileSync(filePath, content);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { changed, fixes };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Run ─────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export function run(targetDir, { fileGlob = "src/**/*.{tsx,ts}", dryRun = false } = {}) {
|
|
92
|
+
console.log("╔══════════════════════════════════════════════════╗");
|
|
93
|
+
console.log("║ Fix Type Annotations After Hook Removal ║");
|
|
94
|
+
console.log("╚══════════════════════════════════════════════════╝");
|
|
95
|
+
console.log();
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(targetDir)) {
|
|
98
|
+
console.error(`Error: Target directory not found: ${targetDir}`);
|
|
99
|
+
return { fixCount: 0, errors: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const files = glob.sync(fileGlob, {
|
|
103
|
+
cwd: targetDir,
|
|
104
|
+
absolute: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let fixCount = 0;
|
|
108
|
+
const errors = [];
|
|
109
|
+
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
try {
|
|
112
|
+
const { fixes } = processFile(file, targetDir, { dryRun });
|
|
113
|
+
fixCount += fixes.length;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
errors.push({ file: path.relative(targetDir, file), error: err.message });
|
|
116
|
+
console.error(` ✗ ${path.relative(targetDir, file)}: ${err.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log();
|
|
121
|
+
console.log(`Fixed ${fixCount} type annotations${dryRun ? " (dry run)" : ""}.`);
|
|
122
|
+
|
|
123
|
+
return { fixCount, errors };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Standalone CLI ──────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const isMain = process.argv[1] && (process.argv[1].endsWith("fix-type-annotations.mjs") || process.argv[1].endsWith("fix-type-annotations"));
|
|
129
|
+
|
|
130
|
+
if (isMain) {
|
|
131
|
+
const args = process.argv.slice(2);
|
|
132
|
+
const write = args.includes("--write");
|
|
133
|
+
const dryRun = !write;
|
|
134
|
+
|
|
135
|
+
const dirIdx = args.indexOf("--dir");
|
|
136
|
+
const targetDir = dirIdx !== -1 && args[dirIdx + 1] ? path.resolve(args[dirIdx + 1]) : null;
|
|
137
|
+
|
|
138
|
+
const filesIdx = args.indexOf("--files");
|
|
139
|
+
const fileGlob = filesIdx !== -1 && args[filesIdx + 1] ? args[filesIdx + 1] : undefined;
|
|
140
|
+
|
|
141
|
+
if (!targetDir) {
|
|
142
|
+
console.error("Usage: node fix-type-annotations.mjs --dir <path> [--write] [--files <glob>]");
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = run(targetDir, { fileGlob, dryRun });
|
|
147
|
+
if (dryRun) {
|
|
148
|
+
console.log("Dry run complete. Run with --write to apply changes.");
|
|
149
|
+
} else {
|
|
150
|
+
console.log("Run your build tool to check for remaining type errors.");
|
|
151
|
+
}
|
|
152
|
+
process.exit(result.errors.length > 0 ? 1 : 0);
|
|
153
|
+
}
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* remove-hooks.mjs
|
|
5
|
+
*
|
|
6
|
+
* Removes useMemo and useCallback hooks from React/TypeScript files
|
|
7
|
+
* to leverage React Compiler automatic optimization.
|
|
8
|
+
*
|
|
9
|
+
* Can be used standalone or imported by run.mjs.
|
|
10
|
+
*
|
|
11
|
+
* Standalone usage:
|
|
12
|
+
* node remove-hooks.mjs --dir <path> [options]
|
|
13
|
+
*
|
|
14
|
+
* Options:
|
|
15
|
+
* --dir <path> Target directory (required when run standalone)
|
|
16
|
+
* --files <glob> File glob pattern (default: src/**\/*.{tsx,ts})
|
|
17
|
+
* --dry-run Show what would change without writing files
|
|
18
|
+
* --verbose Show detailed output for each transformation
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "fs";
|
|
22
|
+
import { glob } from "glob";
|
|
23
|
+
import path from "path";
|
|
24
|
+
|
|
25
|
+
// ─── Core Parsing Helpers ────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find the matching closing paren for an opening paren at startIdx.
|
|
29
|
+
* Counts nested parens/brackets/braces and respects strings and template literals.
|
|
30
|
+
*/
|
|
31
|
+
export function findClosingParen(content, startIdx) {
|
|
32
|
+
let depth = 0;
|
|
33
|
+
let inString = false;
|
|
34
|
+
let stringChar = "";
|
|
35
|
+
let inTemplate = false;
|
|
36
|
+
let templateDepth = 0;
|
|
37
|
+
|
|
38
|
+
for (let i = startIdx; i < content.length; i++) {
|
|
39
|
+
const ch = content[i];
|
|
40
|
+
const prev = i > 0 ? content[i - 1] : "";
|
|
41
|
+
|
|
42
|
+
// Handle escape sequences
|
|
43
|
+
if ((inString || inTemplate) && ch === "\\" && prev !== "\\") {
|
|
44
|
+
i++; // skip next char
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Handle string boundaries
|
|
49
|
+
if (!inTemplate && (ch === '"' || ch === "'" || ch === "`")) {
|
|
50
|
+
if (ch === "`") {
|
|
51
|
+
if (!inTemplate && !inString) {
|
|
52
|
+
inTemplate = true;
|
|
53
|
+
templateDepth = 0;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
} else if (!inTemplate) {
|
|
57
|
+
if (!inString) {
|
|
58
|
+
inString = true;
|
|
59
|
+
stringChar = ch;
|
|
60
|
+
continue;
|
|
61
|
+
} else if (ch === stringChar) {
|
|
62
|
+
inString = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Handle template literal end
|
|
69
|
+
if (inTemplate && ch === "`" && templateDepth === 0) {
|
|
70
|
+
inTemplate = false;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle template literal expressions ${...}
|
|
75
|
+
if (inTemplate && ch === "$" && content[i + 1] === "{") {
|
|
76
|
+
templateDepth++;
|
|
77
|
+
i++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (inTemplate && templateDepth > 0 && ch === "}") {
|
|
81
|
+
templateDepth--;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (inString || inTemplate) continue;
|
|
86
|
+
|
|
87
|
+
if (ch === "(") depth++;
|
|
88
|
+
if (ch === ")") {
|
|
89
|
+
depth--;
|
|
90
|
+
if (depth === 0) return i;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return -1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Strip the trailing dependency array from the inner content of useMemo/useCallback.
|
|
98
|
+
* e.g. "() => foo, [a, b]" -> "() => foo"
|
|
99
|
+
* Handles nested brackets/parens at depth 0.
|
|
100
|
+
*/
|
|
101
|
+
export function stripDepsArray(inner) {
|
|
102
|
+
let depth = 0;
|
|
103
|
+
let lastCommaIdx = -1;
|
|
104
|
+
let inString = false;
|
|
105
|
+
let stringChar = "";
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < inner.length; i++) {
|
|
108
|
+
const ch = inner[i];
|
|
109
|
+
|
|
110
|
+
// Simple string tracking
|
|
111
|
+
if ((ch === '"' || ch === "'" || ch === "`") && (i === 0 || inner[i - 1] !== "\\")) {
|
|
112
|
+
if (!inString) {
|
|
113
|
+
inString = true;
|
|
114
|
+
stringChar = ch;
|
|
115
|
+
continue;
|
|
116
|
+
} else if (ch === stringChar) {
|
|
117
|
+
inString = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (inString) continue;
|
|
122
|
+
|
|
123
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
124
|
+
if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
125
|
+
if (ch === "," && depth === 0) {
|
|
126
|
+
// Check if what follows (after whitespace/newlines) is "["
|
|
127
|
+
const rest = inner.slice(i + 1).trimStart();
|
|
128
|
+
if (rest.startsWith("[")) {
|
|
129
|
+
lastCommaIdx = i;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (lastCommaIdx === -1) return inner;
|
|
134
|
+
return inner.slice(0, lastCommaIdx);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Find the arrow "=>" at depth 0 in the inner content.
|
|
139
|
+
* Returns { params, body } or null.
|
|
140
|
+
*/
|
|
141
|
+
export function unwrapArrowFn(inner) {
|
|
142
|
+
let depth = 0;
|
|
143
|
+
let inString = false;
|
|
144
|
+
let stringChar = "";
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < inner.length - 1; i++) {
|
|
147
|
+
const ch = inner[i];
|
|
148
|
+
|
|
149
|
+
if ((ch === '"' || ch === "'" || ch === "`") && (i === 0 || inner[i - 1] !== "\\")) {
|
|
150
|
+
if (!inString) {
|
|
151
|
+
inString = true;
|
|
152
|
+
stringChar = ch;
|
|
153
|
+
continue;
|
|
154
|
+
} else if (ch === stringChar) {
|
|
155
|
+
inString = false;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (inString) continue;
|
|
160
|
+
|
|
161
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
162
|
+
if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
163
|
+
if (ch === "=" && inner[i + 1] === ">" && depth === 0) {
|
|
164
|
+
const params = inner.slice(0, i).trim();
|
|
165
|
+
const body = inner.slice(i + 2).trim();
|
|
166
|
+
return { params, body };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if a position in the content is inside a comment (line or block).
|
|
174
|
+
*/
|
|
175
|
+
export function isInsideComment(content, idx) {
|
|
176
|
+
// Check for line comment: find the start of the line
|
|
177
|
+
const lineStart = content.lastIndexOf("\n", idx) + 1;
|
|
178
|
+
const linePrefix = content.slice(lineStart, idx);
|
|
179
|
+
if (linePrefix.includes("//")) return true;
|
|
180
|
+
|
|
181
|
+
// Check for block comment: find the last /* before idx
|
|
182
|
+
let lastBlockOpen = content.lastIndexOf("/*", idx);
|
|
183
|
+
if (lastBlockOpen !== -1) {
|
|
184
|
+
let lastBlockClose = content.lastIndexOf("*/", idx);
|
|
185
|
+
if (lastBlockClose < lastBlockOpen) return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Main Processing ─────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export function processFile(filePath, { dryRun = false } = {}) {
|
|
194
|
+
let content = fs.readFileSync(filePath, "utf8");
|
|
195
|
+
const original = content;
|
|
196
|
+
let changed = false;
|
|
197
|
+
const transformations = [];
|
|
198
|
+
|
|
199
|
+
// Phase 1: Strip generic type params like useMemo<ColumnsType<Foo>>( -> useMemo(
|
|
200
|
+
const hookNames = [
|
|
201
|
+
"React.useMemo",
|
|
202
|
+
"React.useCallback",
|
|
203
|
+
"useMemo",
|
|
204
|
+
"useCallback",
|
|
205
|
+
];
|
|
206
|
+
for (const hookName of hookNames) {
|
|
207
|
+
let searchFrom = 0;
|
|
208
|
+
while (true) {
|
|
209
|
+
const hookIdx = content.indexOf(hookName + "<", searchFrom);
|
|
210
|
+
if (hookIdx === -1) break;
|
|
211
|
+
|
|
212
|
+
// Make sure this isn't part of a larger identifier
|
|
213
|
+
if (hookIdx > 0) {
|
|
214
|
+
const prevChar = content[hookIdx - 1];
|
|
215
|
+
if (/[a-zA-Z0-9_$.]/.test(prevChar) && !hookName.startsWith("React.")) {
|
|
216
|
+
searchFrom = hookIdx + 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (isInsideComment(content, hookIdx)) {
|
|
222
|
+
searchFrom = hookIdx + 1;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const angleStart = hookIdx + hookName.length;
|
|
227
|
+
// Count nested angle brackets to find the matching '>'
|
|
228
|
+
let depth = 0;
|
|
229
|
+
let angleEnd = -1;
|
|
230
|
+
for (let i = angleStart; i < content.length; i++) {
|
|
231
|
+
if (content[i] === "<") depth++;
|
|
232
|
+
if (content[i] === ">") {
|
|
233
|
+
depth--;
|
|
234
|
+
if (depth === 0) {
|
|
235
|
+
angleEnd = i;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (angleEnd === -1 || content[angleEnd + 1] !== "(") {
|
|
241
|
+
searchFrom = hookIdx + 1;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const genericType = content.slice(angleStart + 1, angleEnd);
|
|
246
|
+
content =
|
|
247
|
+
content.slice(0, angleStart) + content.slice(angleEnd + 1);
|
|
248
|
+
changed = true;
|
|
249
|
+
transformations.push({
|
|
250
|
+
type: "strip-generic",
|
|
251
|
+
hook: hookName,
|
|
252
|
+
genericType,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Phase 2: Replace useMemo(...) and useCallback(...) calls
|
|
258
|
+
const patterns = [
|
|
259
|
+
"React.useMemo(",
|
|
260
|
+
"React.useCallback(",
|
|
261
|
+
"useMemo(",
|
|
262
|
+
"useCallback(",
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
let safety = 0;
|
|
266
|
+
while (safety++ < 200) {
|
|
267
|
+
let found = false;
|
|
268
|
+
|
|
269
|
+
for (const pattern of patterns) {
|
|
270
|
+
const idx = content.indexOf(pattern);
|
|
271
|
+
if (idx === -1) continue;
|
|
272
|
+
|
|
273
|
+
// Skip if inside a comment
|
|
274
|
+
if (isInsideComment(content, idx)) {
|
|
275
|
+
// Replace the pattern temporarily to avoid infinite loop
|
|
276
|
+
// We'll restore comments at the end... actually just skip
|
|
277
|
+
// by searching for the next occurrence
|
|
278
|
+
const nextIdx = content.indexOf(pattern, idx + pattern.length);
|
|
279
|
+
if (nextIdx === -1) continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Make sure this isn't part of a larger identifier (e.g. "myUseMemo(")
|
|
283
|
+
if (idx > 0 && !pattern.startsWith("React.")) {
|
|
284
|
+
const prevChar = content[idx - 1];
|
|
285
|
+
if (/[a-zA-Z0-9_$]/.test(prevChar)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (isInsideComment(content, idx)) continue;
|
|
291
|
+
|
|
292
|
+
const openParenIdx = idx + pattern.length - 1;
|
|
293
|
+
const closeParenIdx = findClosingParen(content, openParenIdx);
|
|
294
|
+
if (closeParenIdx === -1) continue;
|
|
295
|
+
|
|
296
|
+
const inner = content.slice(openParenIdx + 1, closeParenIdx);
|
|
297
|
+
const withoutDeps = stripDepsArray(inner).trim();
|
|
298
|
+
|
|
299
|
+
const isMemo = pattern.includes("useMemo");
|
|
300
|
+
const isCallback = pattern.includes("useCallback");
|
|
301
|
+
|
|
302
|
+
const arrow = unwrapArrowFn(withoutDeps);
|
|
303
|
+
|
|
304
|
+
let replacement;
|
|
305
|
+
if (isMemo && arrow) {
|
|
306
|
+
const body = arrow.body;
|
|
307
|
+
if (body.startsWith("{")) {
|
|
308
|
+
// Multi-statement body: check for simple { return X; } pattern
|
|
309
|
+
const trimmed = body.slice(1, -1).trim();
|
|
310
|
+
const returnMatch = trimmed.match(/^return\s+([\s\S]+?);?\s*$/);
|
|
311
|
+
if (returnMatch) {
|
|
312
|
+
replacement = returnMatch[1].trim();
|
|
313
|
+
if (replacement.endsWith(";"))
|
|
314
|
+
replacement = replacement.slice(0, -1);
|
|
315
|
+
// Wrap in parens if it starts with ( to preserve grouping
|
|
316
|
+
// or if it's a multi-line expression
|
|
317
|
+
if (
|
|
318
|
+
replacement.includes("\n") &&
|
|
319
|
+
!replacement.startsWith("(") &&
|
|
320
|
+
!replacement.startsWith("[") &&
|
|
321
|
+
!replacement.startsWith("{")
|
|
322
|
+
) {
|
|
323
|
+
replacement = `(\n${replacement}\n)`;
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
// Complex body - wrap in IIFE
|
|
327
|
+
replacement = `(() => ${body})()`;
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
// Simple expression body
|
|
331
|
+
replacement = body;
|
|
332
|
+
}
|
|
333
|
+
} else if (isCallback && arrow) {
|
|
334
|
+
replacement = `${arrow.params} => ${arrow.body}`;
|
|
335
|
+
} else {
|
|
336
|
+
// Fallback: just remove the wrapper
|
|
337
|
+
replacement = withoutDeps;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const beforeMatch = content.slice(
|
|
341
|
+
Math.max(0, idx - 50),
|
|
342
|
+
idx,
|
|
343
|
+
);
|
|
344
|
+
const lineNum =
|
|
345
|
+
content.slice(0, idx).split("\n").length;
|
|
346
|
+
|
|
347
|
+
content =
|
|
348
|
+
content.slice(0, idx) +
|
|
349
|
+
replacement +
|
|
350
|
+
content.slice(closeParenIdx + 1);
|
|
351
|
+
changed = true;
|
|
352
|
+
found = true;
|
|
353
|
+
|
|
354
|
+
transformations.push({
|
|
355
|
+
type: isMemo ? "useMemo" : "useCallback",
|
|
356
|
+
line: lineNum,
|
|
357
|
+
pattern,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
break; // restart since indices changed
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!found) break;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Phase 3: Clean up imports
|
|
367
|
+
if (changed) {
|
|
368
|
+
// Handle: import React, { useMemo, useCallback, ... } from "react";
|
|
369
|
+
content = content.replace(
|
|
370
|
+
/import React, \{([^}]*)\} from "react";/g,
|
|
371
|
+
(match, imports) => {
|
|
372
|
+
const cleaned = imports
|
|
373
|
+
.split(",")
|
|
374
|
+
.map((s) => s.trim())
|
|
375
|
+
.filter((s) => s && s !== "useMemo" && s !== "useCallback")
|
|
376
|
+
.join(", ");
|
|
377
|
+
if (!cleaned) return 'import React from "react";';
|
|
378
|
+
return `import React, { ${cleaned} } from "react";`;
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Handle: import { useMemo, useCallback, ... } from "react";
|
|
383
|
+
content = content.replace(
|
|
384
|
+
/import \{([^}]*)\} from "react";/g,
|
|
385
|
+
(match, imports) => {
|
|
386
|
+
const cleaned = imports
|
|
387
|
+
.split(",")
|
|
388
|
+
.map((s) => s.trim())
|
|
389
|
+
.filter((s) => s && s !== "useMemo" && s !== "useCallback")
|
|
390
|
+
.join(", ");
|
|
391
|
+
if (!cleaned) return ""; // Remove empty import entirely
|
|
392
|
+
return `import { ${cleaned} } from "react";`;
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// Clean up any resulting blank lines from removed imports
|
|
397
|
+
content = content.replace(/\n\n\n+/g, "\n\n");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (changed && !dryRun) {
|
|
401
|
+
fs.writeFileSync(filePath, content);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { changed, transformations, original, result: content };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── Run ─────────────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
// Run the hook removal on a directory.
|
|
410
|
+
// targetDir: absolute path to the project root
|
|
411
|
+
// options: { fileGlob, dryRun, verbose }
|
|
412
|
+
export function run(targetDir, { fileGlob = "src/**/*.{tsx,ts}", dryRun = false, verbose = false } = {}) {
|
|
413
|
+
console.log("╔══════════════════════════════════════════════════╗");
|
|
414
|
+
console.log("║ React Compiler - Remove useMemo/useCallback ║");
|
|
415
|
+
console.log("╚══════════════════════════════════════════════════╝");
|
|
416
|
+
console.log();
|
|
417
|
+
|
|
418
|
+
if (!fs.existsSync(targetDir)) {
|
|
419
|
+
console.error(`Error: Target directory not found: ${targetDir}`);
|
|
420
|
+
return { changedCount: 0, totalTransformations: 0, errors: [{ file: targetDir, error: "not found" }], remaining: [] };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const files = glob.sync(fileGlob, {
|
|
424
|
+
cwd: targetDir,
|
|
425
|
+
absolute: true,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
console.log(`Target: ${targetDir}`);
|
|
429
|
+
console.log(`Pattern: ${fileGlob}`);
|
|
430
|
+
console.log(`Files: ${files.length}`);
|
|
431
|
+
console.log(`Mode: ${dryRun ? "DRY RUN" : "LIVE"}`);
|
|
432
|
+
console.log();
|
|
433
|
+
|
|
434
|
+
let changedCount = 0;
|
|
435
|
+
let totalTransformations = 0;
|
|
436
|
+
const errors = [];
|
|
437
|
+
|
|
438
|
+
for (const file of files) {
|
|
439
|
+
try {
|
|
440
|
+
const { changed, transformations } = processFile(file, { dryRun });
|
|
441
|
+
if (changed) {
|
|
442
|
+
changedCount++;
|
|
443
|
+
totalTransformations += transformations.length;
|
|
444
|
+
const rel = path.relative(targetDir, file);
|
|
445
|
+
console.log(` ✓ ${rel} (${transformations.length} changes)`);
|
|
446
|
+
if (verbose) {
|
|
447
|
+
for (const t of transformations) {
|
|
448
|
+
if (t.type === "strip-generic") {
|
|
449
|
+
console.log(` ↳ stripped generic <${t.genericType}> from ${t.hook}`);
|
|
450
|
+
} else {
|
|
451
|
+
console.log(` ↳ removed ${t.type} at line ${t.line}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} catch (err) {
|
|
457
|
+
const rel = path.relative(targetDir, file);
|
|
458
|
+
errors.push({ file: rel, error: err.message });
|
|
459
|
+
console.error(` ✗ ${rel}: ${err.message}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
console.log();
|
|
464
|
+
console.log(`Modified: ${changedCount} files`);
|
|
465
|
+
console.log(`Changes: ${totalTransformations} transformations`);
|
|
466
|
+
if (dryRun) console.log(`(dry run — no files were written)`);
|
|
467
|
+
|
|
468
|
+
if (errors.length > 0) {
|
|
469
|
+
console.log();
|
|
470
|
+
console.log(`Errors: ${errors.length}`);
|
|
471
|
+
for (const e of errors) {
|
|
472
|
+
console.log(` - ${e.file}: ${e.error}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Report remaining
|
|
477
|
+
const remaining = [];
|
|
478
|
+
for (const file of files) {
|
|
479
|
+
const content = fs.readFileSync(file, "utf8");
|
|
480
|
+
if (/\buseMemo\b/.test(content) || /\buseCallback\b/.test(content)) {
|
|
481
|
+
remaining.push(path.relative(targetDir, file));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (remaining.length > 0) {
|
|
485
|
+
console.log();
|
|
486
|
+
console.log(`⚠ ${remaining.length} files still reference useMemo/useCallback:`);
|
|
487
|
+
remaining.forEach((f) => console.log(` - ${f}`));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return { changedCount, totalTransformations, errors, remaining };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ─── Standalone CLI ──────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
const isMain = process.argv[1] && (process.argv[1].endsWith("remove-hooks.mjs") || process.argv[1].endsWith("remove-hooks"));
|
|
496
|
+
|
|
497
|
+
if (isMain) {
|
|
498
|
+
const args = process.argv.slice(2);
|
|
499
|
+
const write = args.includes("--write");
|
|
500
|
+
const dryRun = !write;
|
|
501
|
+
const verbose = args.includes("--verbose");
|
|
502
|
+
|
|
503
|
+
const dirIdx = args.indexOf("--dir");
|
|
504
|
+
const targetDir = dirIdx !== -1 && args[dirIdx + 1] ? path.resolve(args[dirIdx + 1]) : null;
|
|
505
|
+
|
|
506
|
+
const filesIdx = args.indexOf("--files");
|
|
507
|
+
const fileGlob = filesIdx !== -1 && args[filesIdx + 1] ? args[filesIdx + 1] : undefined;
|
|
508
|
+
|
|
509
|
+
if (!targetDir) {
|
|
510
|
+
console.error("Usage: node remove-hooks.mjs --dir <path> [--write] [--files <glob>] [--verbose]");
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const result = run(targetDir, { fileGlob, dryRun, verbose });
|
|
515
|
+
console.log();
|
|
516
|
+
if (dryRun) {
|
|
517
|
+
console.log("Dry run complete. Run with --write to apply changes.");
|
|
518
|
+
} else {
|
|
519
|
+
console.log("Done. Run your build tool to verify no type errors were introduced.");
|
|
520
|
+
}
|
|
521
|
+
process.exit(result.errors.length > 0 ? 1 : 0);
|
|
522
|
+
}
|