@omnimod/plugin-react-class-to-hooks 0.1.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 +27 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.mjs +590 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 salnika
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @omnimod/plugin-react-class-to-hooks
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@omnimod/plugin-react-class-to-hooks)
|
|
4
|
+
[](https://github.com/Salnika/omnimod/actions/workflows/ci.yml)
|
|
5
|
+
[](../../LICENSE)
|
|
6
|
+
|
|
7
|
+
omnimod plugin that migrates simple React class components to function
|
|
8
|
+
components with hooks.
|
|
9
|
+
|
|
10
|
+
## Use With The CLI
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
omnimod run react-class-to-hooks "src/**/*.{tsx,jsx}"
|
|
14
|
+
omnimod run react-class-to-hooks "src/**/*.{tsx,jsx}" --write
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Use As A Library
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { reactClassToHooks } from "@omnimod/plugin-react-class-to-hooks";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Notes
|
|
24
|
+
|
|
25
|
+
The plugin targets straightforward class components with state, props, and common
|
|
26
|
+
lifecycle methods. Complex patterns such as HOCs, refs, and derived state are
|
|
27
|
+
reported as diagnostics.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/plugin.d.ts
|
|
2
|
+
type ReactClassToHooksOptions = Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* Migrate SIMPLE React class components to function components + hooks. This is
|
|
5
|
+
* intentionally conservative: only classes with a `render()`, optional
|
|
6
|
+
* `state = { ... }`, optional mount/unmount/update lifecycles, and plain methods
|
|
7
|
+
* are converted. Anything with a constructor body, refs, context, unsupported
|
|
8
|
+
* lifecycle, or a multi-key/functional `setState` is left untouched with a warn.
|
|
9
|
+
*/
|
|
10
|
+
declare const reactClassToHooks: import("@omnimod/core").Plugin<ReactClassToHooksOptions, unknown>;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { type ReactClassToHooksOptions, reactClassToHooks as default, reactClassToHooks };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { definePlugin, walk } from "@omnimod/core";
|
|
2
|
+
import { cast } from "@omnimod/plugin-utils";
|
|
3
|
+
//#region src/detect.ts
|
|
4
|
+
/** The React class-component base classes we recognise as a superclass. */
|
|
5
|
+
const REACT_BASES = /* @__PURE__ */ new Set(["Component", "PureComponent"]);
|
|
6
|
+
/**
|
|
7
|
+
* True when `superClass` is one of `React.Component`, `Component`,
|
|
8
|
+
* `React.PureComponent`, `PureComponent`.
|
|
9
|
+
*/
|
|
10
|
+
function isReactComponentSuper(superClass) {
|
|
11
|
+
if (!superClass) return false;
|
|
12
|
+
if (superClass.type === "Identifier") return REACT_BASES.has(cast(superClass).name);
|
|
13
|
+
if (superClass.type === "MemberExpression") {
|
|
14
|
+
const member = cast(superClass);
|
|
15
|
+
if (member.computed || member.property.type !== "Identifier") return false;
|
|
16
|
+
if (member.object.type !== "Identifier") return false;
|
|
17
|
+
return cast(member.object).name === "React" && REACT_BASES.has(cast(member.property).name);
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const UNSUPPORTED_LIFECYCLE = /* @__PURE__ */ new Set([
|
|
22
|
+
"getDerivedStateFromProps",
|
|
23
|
+
"getSnapshotBeforeUpdate",
|
|
24
|
+
"shouldComponentUpdate",
|
|
25
|
+
"componentDidCatch",
|
|
26
|
+
"getDerivedStateFromError",
|
|
27
|
+
"componentWillMount",
|
|
28
|
+
"UNSAFE_componentWillMount",
|
|
29
|
+
"componentWillReceiveProps",
|
|
30
|
+
"UNSAFE_componentWillReceiveProps",
|
|
31
|
+
"componentWillUpdate",
|
|
32
|
+
"UNSAFE_componentWillUpdate"
|
|
33
|
+
]);
|
|
34
|
+
/** Property key as a plain string, or null if it is computed / non-identifier. */
|
|
35
|
+
function memberName(key, computed) {
|
|
36
|
+
if (computed) return null;
|
|
37
|
+
if (key.type === "Identifier") return cast(key).name;
|
|
38
|
+
if (key.type === "Literal") {
|
|
39
|
+
const value = key.value;
|
|
40
|
+
return typeof value === "string" ? value : null;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Inspect a React class component and decide whether it is "simple" enough to
|
|
46
|
+
* migrate. Returns a `bail` reason (non-null) when it must be left untouched.
|
|
47
|
+
*/
|
|
48
|
+
function analyzeClass(node) {
|
|
49
|
+
const name = node.id ? cast(node.id).name : null;
|
|
50
|
+
const members = [];
|
|
51
|
+
let bail = null;
|
|
52
|
+
let hasRender = false;
|
|
53
|
+
for (const raw of node.body.body) {
|
|
54
|
+
if (bail) break;
|
|
55
|
+
if (raw.type === "MethodDefinition") {
|
|
56
|
+
const method = cast(raw);
|
|
57
|
+
const key = memberName(method.key, method.computed);
|
|
58
|
+
if (method.kind === "constructor") {
|
|
59
|
+
bail = "constructor contains logic beyond super()/this.state";
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (method.static) {
|
|
63
|
+
bail = `unsupported static method \`${key ?? "?"}\``;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (key === null) {
|
|
67
|
+
bail = "computed method name";
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (key === "render") {
|
|
71
|
+
hasRender = true;
|
|
72
|
+
members.push({
|
|
73
|
+
node: raw,
|
|
74
|
+
kind: "render",
|
|
75
|
+
name: key
|
|
76
|
+
});
|
|
77
|
+
} else if (key === "componentDidMount") members.push({
|
|
78
|
+
node: raw,
|
|
79
|
+
kind: "lifecycle-mount",
|
|
80
|
+
name: key
|
|
81
|
+
});
|
|
82
|
+
else if (key === "componentWillUnmount") members.push({
|
|
83
|
+
node: raw,
|
|
84
|
+
kind: "lifecycle-unmount",
|
|
85
|
+
name: key
|
|
86
|
+
});
|
|
87
|
+
else if (key === "componentDidUpdate") members.push({
|
|
88
|
+
node: raw,
|
|
89
|
+
kind: "lifecycle-update",
|
|
90
|
+
name: key
|
|
91
|
+
});
|
|
92
|
+
else if (UNSUPPORTED_LIFECYCLE.has(key)) bail = `unsupported lifecycle method \`${key}\``;
|
|
93
|
+
else if (method.kind === "get" || method.kind === "set") bail = `unsupported accessor \`${key}\``;
|
|
94
|
+
else members.push({
|
|
95
|
+
node: raw,
|
|
96
|
+
kind: "method",
|
|
97
|
+
name: key
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (raw.type === "PropertyDefinition") {
|
|
102
|
+
const prop = cast(raw);
|
|
103
|
+
const key = memberName(prop.key, prop.computed);
|
|
104
|
+
if (key === null) {
|
|
105
|
+
bail = "computed class field";
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (prop.static) {
|
|
109
|
+
if (key === "defaultProps") members.push({
|
|
110
|
+
node: raw,
|
|
111
|
+
kind: "default-props",
|
|
112
|
+
name: key
|
|
113
|
+
});
|
|
114
|
+
else bail = `unsupported static field \`${key}\``;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (key === "state") {
|
|
118
|
+
members.push({
|
|
119
|
+
node: raw,
|
|
120
|
+
kind: "state-field",
|
|
121
|
+
name: key
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (prop.value && prop.value.type === "ArrowFunctionExpression") {
|
|
126
|
+
members.push({
|
|
127
|
+
node: raw,
|
|
128
|
+
kind: "method",
|
|
129
|
+
name: key
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
bail = `unsupported instance field \`${key}\` (only \`state\` and bound methods are handled)`;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (raw.type === "StaticBlock") {
|
|
137
|
+
bail = "static initialization block";
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
bail = `unsupported class member \`${raw.type}\``;
|
|
141
|
+
}
|
|
142
|
+
if (!bail && !hasRender) bail = "no render() method";
|
|
143
|
+
return {
|
|
144
|
+
node,
|
|
145
|
+
name,
|
|
146
|
+
members,
|
|
147
|
+
bail
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function findClassComponents(program) {
|
|
151
|
+
const found = [];
|
|
152
|
+
for (const stmt of program.body) {
|
|
153
|
+
let classNode = null;
|
|
154
|
+
let isDeclaration = false;
|
|
155
|
+
if (stmt.type === "ClassDeclaration") {
|
|
156
|
+
classNode = cast(stmt);
|
|
157
|
+
isDeclaration = true;
|
|
158
|
+
} else if (stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportDefaultDeclaration") {
|
|
159
|
+
const decl = stmt.declaration;
|
|
160
|
+
if (decl && decl.type === "ClassDeclaration") {
|
|
161
|
+
classNode = cast(decl);
|
|
162
|
+
isDeclaration = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!classNode) continue;
|
|
166
|
+
if (!isReactComponentSuper(classNode.superClass)) continue;
|
|
167
|
+
found.push({
|
|
168
|
+
info: analyzeClass(classNode),
|
|
169
|
+
statement: stmt,
|
|
170
|
+
isDeclaration
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return found;
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/rewrite.ts
|
|
177
|
+
/** `count` → `setCount`. */
|
|
178
|
+
function setterName(key) {
|
|
179
|
+
return `set${key.charAt(0).toUpperCase()}${key.slice(1)}`;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Rewrite the `this.*` references inside a region of source into their function
|
|
183
|
+
* equivalents. Returns the transformed text and a flag noting whether a
|
|
184
|
+
* multi-key / functional `setState` was encountered (which forces a bail).
|
|
185
|
+
*
|
|
186
|
+
* Handled forms:
|
|
187
|
+
* - `this.state.k` → `k`
|
|
188
|
+
* - `this.props.x` / `this.props` → `props.x` / `props`
|
|
189
|
+
* - `this.setState({ k: v })` → `setK(v)` (single-key object updates only)
|
|
190
|
+
* - `this.foo` → `foo` (methods / bound fields)
|
|
191
|
+
*/
|
|
192
|
+
function rewriteThis(source, region, state) {
|
|
193
|
+
const edits = [];
|
|
194
|
+
let unsupportedSetState = false;
|
|
195
|
+
walk(region, (node) => {
|
|
196
|
+
if (node.type !== "MemberExpression") return;
|
|
197
|
+
const member = cast(node);
|
|
198
|
+
if (member.object.type !== "ThisExpression") return;
|
|
199
|
+
if (member.computed || member.property.type !== "Identifier") return;
|
|
200
|
+
const prop = cast(member.property).name;
|
|
201
|
+
if (prop === "state") return;
|
|
202
|
+
if (prop === "props") {
|
|
203
|
+
edits.push({
|
|
204
|
+
start: member.object.start,
|
|
205
|
+
end: member.property.start,
|
|
206
|
+
text: ""
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
edits.push({
|
|
211
|
+
start: member.object.start,
|
|
212
|
+
end: member.property.start,
|
|
213
|
+
text: ""
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
walk(region, (node) => {
|
|
217
|
+
if (node.type === "MemberExpression") {
|
|
218
|
+
const outer = cast(node);
|
|
219
|
+
if (outer.object.type === "MemberExpression" && !outer.computed && outer.property.type === "Identifier") {
|
|
220
|
+
const inner = cast(outer.object);
|
|
221
|
+
if (inner.object.type === "ThisExpression" && inner.property.type === "Identifier" && cast(inner.property).name === "state") {
|
|
222
|
+
const key = cast(outer.property).name;
|
|
223
|
+
if (state.keys.includes(key)) edits.push({
|
|
224
|
+
start: outer.start,
|
|
225
|
+
end: outer.property.end,
|
|
226
|
+
text: key
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (node.type === "CallExpression") {
|
|
232
|
+
const call = cast(node);
|
|
233
|
+
if (call.callee.type === "MemberExpression" && !cast(call.callee).computed) {
|
|
234
|
+
const callee = cast(call.callee);
|
|
235
|
+
if (callee.object.type === "ThisExpression" && callee.property.type === "Identifier" && cast(callee.property).name === "setState") {
|
|
236
|
+
const arg = call.arguments[0];
|
|
237
|
+
if (!arg || arg.type !== "ObjectExpression") {
|
|
238
|
+
unsupportedSetState = true;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const obj = cast(arg);
|
|
242
|
+
if (obj.properties.length !== 1) {
|
|
243
|
+
unsupportedSetState = true;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const only = obj.properties[0];
|
|
247
|
+
if (!only || only.type !== "Property") {
|
|
248
|
+
unsupportedSetState = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const property = cast(only);
|
|
252
|
+
if (property.computed || property.key.type !== "Identifier") {
|
|
253
|
+
unsupportedSetState = true;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const key = cast(property.key).name;
|
|
257
|
+
const setter = state.setters.get(key);
|
|
258
|
+
if (!setter) {
|
|
259
|
+
unsupportedSetState = true;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const valueText = rewriteThis(source, property.value, state).text;
|
|
263
|
+
edits.push({
|
|
264
|
+
start: call.start,
|
|
265
|
+
end: call.end,
|
|
266
|
+
text: `${setter}(${valueText})`
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
text: applyEdits(source, region, edits),
|
|
274
|
+
unsupportedSetState
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Apply non-overlapping edits within [region.start, region.end) to the source
|
|
279
|
+
* and return the rewritten substring. Inner `this.state.k` / `setState` edits
|
|
280
|
+
* subsume the prefix-strip edits, so drop any prefix-strip that overlaps a
|
|
281
|
+
* span-replacing edit.
|
|
282
|
+
*/
|
|
283
|
+
function applyEdits(source, region, edits) {
|
|
284
|
+
const sorted = [...edits].sort((a, b) => a.start - b.start || b.end - a.end);
|
|
285
|
+
const kept = [];
|
|
286
|
+
let lastEnd = -1;
|
|
287
|
+
for (const edit of sorted) {
|
|
288
|
+
if (edit.start < lastEnd) continue;
|
|
289
|
+
kept.push(edit);
|
|
290
|
+
lastEnd = edit.end;
|
|
291
|
+
}
|
|
292
|
+
let out = "";
|
|
293
|
+
let cursor = region.start;
|
|
294
|
+
for (const edit of kept) {
|
|
295
|
+
out += source.slice(cursor, edit.start);
|
|
296
|
+
out += edit.text;
|
|
297
|
+
cursor = edit.end;
|
|
298
|
+
}
|
|
299
|
+
out += source.slice(cursor, region.end);
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/plugin.ts
|
|
304
|
+
/**
|
|
305
|
+
* Migrate SIMPLE React class components to function components + hooks. This is
|
|
306
|
+
* intentionally conservative: only classes with a `render()`, optional
|
|
307
|
+
* `state = { ... }`, optional mount/unmount/update lifecycles, and plain methods
|
|
308
|
+
* are converted. Anything with a constructor body, refs, context, unsupported
|
|
309
|
+
* lifecycle, or a multi-key/functional `setState` is left untouched with a warn.
|
|
310
|
+
*/
|
|
311
|
+
const reactClassToHooks = definePlugin({
|
|
312
|
+
name: "react-class-to-hooks",
|
|
313
|
+
description: "Migrate simple React class components to function components + hooks.",
|
|
314
|
+
include: ["**/*.{tsx,jsx}"],
|
|
315
|
+
transform(file) {
|
|
316
|
+
const classes = findClassComponents(file.program);
|
|
317
|
+
if (classes.length === 0) return;
|
|
318
|
+
const hooksUsed = /* @__PURE__ */ new Set();
|
|
319
|
+
let anyConverted = false;
|
|
320
|
+
for (const found of classes) {
|
|
321
|
+
const info = found.info;
|
|
322
|
+
if (info.bail) {
|
|
323
|
+
file.report({
|
|
324
|
+
message: `Left \`${info.name ?? "class component"}\` as a class: ${info.bail}.`,
|
|
325
|
+
severity: "warn"
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (!info.name) {
|
|
330
|
+
file.report({
|
|
331
|
+
message: "Left an anonymous class component untouched (no name to convert to).",
|
|
332
|
+
severity: "warn"
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const converted = convertClass(file, info, info.name);
|
|
337
|
+
if (!converted) continue;
|
|
338
|
+
for (const hook of converted.hooks) hooksUsed.add(hook);
|
|
339
|
+
anyConverted = true;
|
|
340
|
+
}
|
|
341
|
+
if (anyConverted && hooksUsed.size > 0) ensureReactHookImports(file, hooksUsed);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
/** Build the function-component text and replace the class in place. */
|
|
345
|
+
function convertClass(file, info, name) {
|
|
346
|
+
const hooks = /* @__PURE__ */ new Set();
|
|
347
|
+
let stateField = null;
|
|
348
|
+
let render = null;
|
|
349
|
+
let mount = null;
|
|
350
|
+
let unmount = null;
|
|
351
|
+
let update = null;
|
|
352
|
+
const methods = [];
|
|
353
|
+
for (const member of info.members) switch (member.kind) {
|
|
354
|
+
case "state-field":
|
|
355
|
+
stateField = member;
|
|
356
|
+
break;
|
|
357
|
+
case "render":
|
|
358
|
+
render = member;
|
|
359
|
+
break;
|
|
360
|
+
case "lifecycle-mount":
|
|
361
|
+
mount = member;
|
|
362
|
+
break;
|
|
363
|
+
case "lifecycle-unmount":
|
|
364
|
+
unmount = member;
|
|
365
|
+
break;
|
|
366
|
+
case "lifecycle-update":
|
|
367
|
+
update = member;
|
|
368
|
+
break;
|
|
369
|
+
case "method":
|
|
370
|
+
methods.push(member);
|
|
371
|
+
break;
|
|
372
|
+
case "default-props": break;
|
|
373
|
+
case "constructor": break;
|
|
374
|
+
}
|
|
375
|
+
if (!render) {
|
|
376
|
+
file.report({
|
|
377
|
+
message: `Left \`${name}\`: no render() found.`,
|
|
378
|
+
severity: "warn"
|
|
379
|
+
});
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const state = parseState(file, stateField);
|
|
383
|
+
if (state === "bail") {
|
|
384
|
+
file.report({
|
|
385
|
+
message: `Left \`${name}\`: \`state\` is not a simple object literal.`,
|
|
386
|
+
severity: "warn"
|
|
387
|
+
});
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
if (state.keys.length > 0) hooks.add("useState");
|
|
391
|
+
const lines = [];
|
|
392
|
+
for (const key of state.keys) {
|
|
393
|
+
const initText = state.inits.get(key) ?? "undefined";
|
|
394
|
+
lines.push(` const [${key}, ${state.setters.get(key)}] = useState(${initText});`);
|
|
395
|
+
}
|
|
396
|
+
for (const method of methods) {
|
|
397
|
+
const rendered = renderMethod(file, method, state);
|
|
398
|
+
if (rendered === null) {
|
|
399
|
+
file.report({
|
|
400
|
+
message: `Left \`${name}\`: method \`${method.name}\` uses an unsupported \`setState\` (multi-key or updater fn).`,
|
|
401
|
+
severity: "warn"
|
|
402
|
+
});
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
lines.push(rendered);
|
|
406
|
+
}
|
|
407
|
+
if (mount || unmount) {
|
|
408
|
+
hooks.add("useEffect");
|
|
409
|
+
const effect = renderMountEffect(file, mount, unmount, state);
|
|
410
|
+
if (effect === null) {
|
|
411
|
+
file.report({
|
|
412
|
+
message: `Left \`${name}\`: a lifecycle method uses an unsupported \`setState\`.`,
|
|
413
|
+
severity: "warn"
|
|
414
|
+
});
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
lines.push(effect);
|
|
418
|
+
}
|
|
419
|
+
if (update) {
|
|
420
|
+
hooks.add("useEffect");
|
|
421
|
+
const body = methodBodyText(file, update, state);
|
|
422
|
+
if (body === null) {
|
|
423
|
+
file.report({
|
|
424
|
+
message: `Left \`${name}\`: componentDidUpdate uses an unsupported \`setState\`.`,
|
|
425
|
+
severity: "warn"
|
|
426
|
+
});
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
lines.push(` // TODO(omnimod): review this effect's dependency array.`);
|
|
430
|
+
lines.push(` useEffect(() => {${body}});`);
|
|
431
|
+
file.report({
|
|
432
|
+
message: `Converted \`${name}\`.componentDidUpdate to a useEffect — review its dependency array.`,
|
|
433
|
+
severity: "info"
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const renderReturn = renderRenderBody(file, render, state);
|
|
437
|
+
if (renderReturn === null) {
|
|
438
|
+
file.report({
|
|
439
|
+
message: `Left \`${name}\`: render() uses an unsupported \`setState\`.`,
|
|
440
|
+
severity: "warn"
|
|
441
|
+
});
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
lines.push(renderReturn);
|
|
445
|
+
const fnText = `function ${name}(props) {\n${lines.join("\n")}\n}`;
|
|
446
|
+
const classNode = info.node;
|
|
447
|
+
file.magic.update(classNode.start, classNode.end, fnText);
|
|
448
|
+
return { hooks };
|
|
449
|
+
}
|
|
450
|
+
/** Parse `state = { k: v }` into keys, setters and init-expression text. */
|
|
451
|
+
function parseState(file, field) {
|
|
452
|
+
const keys = [];
|
|
453
|
+
const setters = /* @__PURE__ */ new Map();
|
|
454
|
+
const inits = /* @__PURE__ */ new Map();
|
|
455
|
+
if (!field) return {
|
|
456
|
+
keys,
|
|
457
|
+
setters,
|
|
458
|
+
inits
|
|
459
|
+
};
|
|
460
|
+
const prop = cast(field.node);
|
|
461
|
+
if (!prop.value || prop.value.type !== "ObjectExpression") return "bail";
|
|
462
|
+
const obj = cast(prop.value);
|
|
463
|
+
for (const raw of obj.properties) {
|
|
464
|
+
if (raw.type !== "Property") return "bail";
|
|
465
|
+
const property = cast(raw);
|
|
466
|
+
if (property.computed || property.kind !== "init") return "bail";
|
|
467
|
+
if (property.key.type !== "Identifier") return "bail";
|
|
468
|
+
const key = cast(property.key).name;
|
|
469
|
+
keys.push(key);
|
|
470
|
+
setters.set(key, setterName(key));
|
|
471
|
+
inits.set(key, file.source.slice(property.value.start, property.value.end));
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
keys,
|
|
475
|
+
setters,
|
|
476
|
+
inits
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/** The `StateInfo` view expected by the `this.*` rewriter. */
|
|
480
|
+
function stateInfo(state) {
|
|
481
|
+
return {
|
|
482
|
+
keys: state.keys,
|
|
483
|
+
setters: state.setters
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/** Render a plain method as `const foo = (params) => { body };`. */
|
|
487
|
+
function renderMethod(file, member, state) {
|
|
488
|
+
const node = member.node;
|
|
489
|
+
let fn;
|
|
490
|
+
if (node.type === "MethodDefinition") fn = cast(node).value;
|
|
491
|
+
else fn = cast(cast(node).value);
|
|
492
|
+
const params = file.source.slice(paramsStart(file, fn), paramsEnd(file, fn));
|
|
493
|
+
const rewritten = rewriteThis(file.source, fn.body, stateInfo(state));
|
|
494
|
+
if (rewritten.unsupportedSetState) return null;
|
|
495
|
+
return ` const ${member.name} = (${params}) => ${rewritten.text};`;
|
|
496
|
+
}
|
|
497
|
+
/** Get the source span text of a function/method body ({...} → inner text). */
|
|
498
|
+
function methodBodyText(file, member, state) {
|
|
499
|
+
const node = member.node;
|
|
500
|
+
const fn = cast(node).value;
|
|
501
|
+
const block = cast(fn.body);
|
|
502
|
+
const rewritten = rewriteThis(file.source, block, stateInfo(state));
|
|
503
|
+
if (rewritten.unsupportedSetState) return null;
|
|
504
|
+
return rewritten.text.slice(1, -1);
|
|
505
|
+
}
|
|
506
|
+
/** componentDidMount → useEffect(() => { B; [return cleanup;] }, []). */
|
|
507
|
+
function renderMountEffect(file, mount, unmount, state) {
|
|
508
|
+
let mountBody = "";
|
|
509
|
+
if (mount) {
|
|
510
|
+
const body = methodBodyText(file, mount, state);
|
|
511
|
+
if (body === null) return null;
|
|
512
|
+
mountBody = body;
|
|
513
|
+
}
|
|
514
|
+
let cleanup = "";
|
|
515
|
+
if (unmount) {
|
|
516
|
+
const body = methodBodyText(file, unmount, state);
|
|
517
|
+
if (body === null) return null;
|
|
518
|
+
cleanup = `\n return () => {${body}};`;
|
|
519
|
+
}
|
|
520
|
+
return ` useEffect(() => {${mountBody}${cleanup}\n }, []);`;
|
|
521
|
+
}
|
|
522
|
+
/** render() { return X; } → ` return X;` (with `this.*` rewritten). */
|
|
523
|
+
function renderRenderBody(file, render, state) {
|
|
524
|
+
const fn = cast(render.node).value;
|
|
525
|
+
const block = cast(fn.body);
|
|
526
|
+
const rewritten = rewriteThis(file.source, block, stateInfo(state));
|
|
527
|
+
if (rewritten.unsupportedSetState) return null;
|
|
528
|
+
const reindented = reindent(rewritten.text.slice(1, -1), " ");
|
|
529
|
+
return reindented.length > 0 ? reindented : " return null;";
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Remove the shared leading indentation from a multi-line block and re-apply
|
|
533
|
+
* `prefix` to each non-blank line. Leading/trailing blank lines are trimmed.
|
|
534
|
+
*/
|
|
535
|
+
function reindent(block, prefix) {
|
|
536
|
+
const lines = block.replace(/^\n+/, "").replace(/\s+$/, "").split("\n");
|
|
537
|
+
let common = Infinity;
|
|
538
|
+
for (const line of lines) {
|
|
539
|
+
if (line.trim().length === 0) continue;
|
|
540
|
+
const indent = line.length - line.trimStart().length;
|
|
541
|
+
if (indent < common) common = indent;
|
|
542
|
+
}
|
|
543
|
+
if (!Number.isFinite(common)) common = 0;
|
|
544
|
+
return lines.map((line) => line.trim().length === 0 ? "" : `${prefix}${line.slice(common)}`).join("\n");
|
|
545
|
+
}
|
|
546
|
+
/** Start offset of a function's parameter list (just after the `(`). */
|
|
547
|
+
function paramsStart(file, fn) {
|
|
548
|
+
const params = fn.params;
|
|
549
|
+
if (params.length > 0) return params[0].start;
|
|
550
|
+
return file.source.indexOf("(", fn.start) + 1;
|
|
551
|
+
}
|
|
552
|
+
/** End offset of a function's parameter list (just before the `)`). */
|
|
553
|
+
function paramsEnd(file, fn) {
|
|
554
|
+
const params = fn.params;
|
|
555
|
+
if (params.length > 0) return params[params.length - 1].end;
|
|
556
|
+
return file.source.indexOf("(", fn.start) + 1;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Merge the required hooks into an existing `import ... from "react"` (adding a
|
|
560
|
+
* named clause when needed) or prepend a fresh named import.
|
|
561
|
+
*/
|
|
562
|
+
function ensureReactHookImports(file, hooks) {
|
|
563
|
+
const wanted = [...hooks].sort();
|
|
564
|
+
for (const stmt of file.program.body) {
|
|
565
|
+
if (stmt.type !== "ImportDeclaration") continue;
|
|
566
|
+
const imp = cast(stmt);
|
|
567
|
+
if (imp.source.value !== "react") continue;
|
|
568
|
+
const existing = /* @__PURE__ */ new Set();
|
|
569
|
+
let lastNamed = null;
|
|
570
|
+
let hasNamedBlock = false;
|
|
571
|
+
for (const spec of imp.specifiers) if (spec.type === "ImportSpecifier") {
|
|
572
|
+
const s = cast(spec);
|
|
573
|
+
if (s.imported.type === "Identifier") existing.add(cast(s.imported).name);
|
|
574
|
+
lastNamed = spec;
|
|
575
|
+
hasNamedBlock = true;
|
|
576
|
+
}
|
|
577
|
+
const missing = wanted.filter((hook) => !existing.has(hook));
|
|
578
|
+
if (missing.length === 0) return;
|
|
579
|
+
if (hasNamedBlock && lastNamed) file.magic.appendLeft(lastNamed.end, `, ${missing.join(", ")}`);
|
|
580
|
+
else {
|
|
581
|
+
const def = imp.specifiers[0];
|
|
582
|
+
if (def) file.magic.appendLeft(def.end, `, { ${missing.join(", ")} }`);
|
|
583
|
+
else file.magic.appendLeft(imp.source.start - 6, `{ ${missing.join(", ")} } `);
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
file.magic.prepend(`import { ${wanted.join(", ")} } from "react";\n`);
|
|
588
|
+
}
|
|
589
|
+
//#endregion
|
|
590
|
+
export { reactClassToHooks as default, reactClassToHooks };
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omnimod/plugin-react-class-to-hooks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "omnimod plugin: migrate simple React class components to function components + hooks.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"codemod",
|
|
7
|
+
"hooks",
|
|
8
|
+
"migration",
|
|
9
|
+
"omnimod-plugin",
|
|
10
|
+
"react"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://salnika.github.io/omnimod/",
|
|
13
|
+
"bugs": "https://github.com/salnika/omnimod/issues",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "salnika",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/salnika/omnimod.git",
|
|
19
|
+
"directory": "packages/plugin-react-class-to-hooks"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./dist/index.mjs",
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@omnimod/core": "0.1.0",
|
|
34
|
+
"@omnimod/plugin-utils": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^25.6.2",
|
|
38
|
+
"@typescript/native-preview": "7.0.0-dev.20260509.2",
|
|
39
|
+
"typescript": "^6.0.3",
|
|
40
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2",
|
|
41
|
+
"vite-plus": "0.2.2"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "vp pack",
|
|
45
|
+
"dev": "vp pack --watch",
|
|
46
|
+
"test": "vp test",
|
|
47
|
+
"check": "vp check"
|
|
48
|
+
}
|
|
49
|
+
}
|