Skip to content

Commit f68367e

Browse files
committed
Implement Effects MVP
1 parent 2376f14 commit f68367e

File tree

6 files changed

+334
-1
lines changed

6 files changed

+334
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## Unreleased
22

3+
- Added
4+
- Implement MVP for componentDidMount/componentDidUpdate/componentWillUnmount
35
- Fixed
46
- Don't fail if user-defined class field (e.g. `this.foo`) is assigned without initializing.
57

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,9 @@ If you need to enforce specific styles, use Prettier or ESLint or whatever is yo
256256
- [ ] Transform legacy string refs as far as possible
257257
- [ ] Support for lifecycles
258258
- [ ] Transform componentDidMount, componentDidUpdate, and componentWillUnmount
259-
- [ ]
259+
- [x] Support "raw" effects -- simply mapping the three callbacks to guarded effects.
260+
- [ ] Support re-pairing effects
261+
- [ ] Transform shouldComponentUpdate
260262
- [ ] Support for receiving refs
261263
- [ ] Use `forwardRef` + `useImperativeHandle` when requested by the user
262264
- [ ] Support for contexts

src/analysis.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { LocalManager, RemovableNode } from "./analysis/local.js";
1010
import { analyzeUserDefined, postAnalyzeCallbackDependencies, UserDefinedAnalysis } from "./analysis/user_defined.js";
1111
import type { PreAnalysisResult } from "./analysis/pre.js";
1212
import type { LibRef } from "./analysis/lib.js";
13+
import { EffectAnalysis, analyzeEffects } from "./analysis/effect.js";
1314

1415
export { AnalysisError } from "./analysis/error.js";
1516

@@ -43,6 +44,7 @@ export type AnalysisResult = {
4344
state: StateObjAnalysis;
4445
props: PropsObjAnalysis;
4546
userDefined: UserDefinedAnalysis;
47+
effects: EffectAnalysis;
4648
};
4749

4850
export function analyzeClass(
@@ -59,6 +61,10 @@ export function analyzeClass(
5961
const setStateAnalysis = getAndDelete(sites, "setState") ?? { sites: [] };
6062
const states = analyzeState(stateObjAnalysis, setStateAnalysis, locals, preanalysis);
6163

64+
const componentDidMount = getAndDelete(sites, "componentDidMount") ?? { sites: [] };
65+
const componentDidUpdate = getAndDelete(sites, "componentDidUpdate") ?? { sites: [] };
66+
const componentWillUnmount = getAndDelete(sites, "componentWillUnmount") ?? { sites: [] };
67+
6268
const renderAnalysis = getAndDelete(sites, "render") ?? { sites: [] };
6369

6470
analyzeOuterCapturings(path, locals);
@@ -102,6 +108,13 @@ export function analyzeClass(
102108
}
103109
}
104110

111+
const effects = analyzeEffects(
112+
componentDidMount,
113+
componentDidUpdate,
114+
componentWillUnmount,
115+
userDefined,
116+
);
117+
105118
const render = analyzeRender(renderPath, locals);
106119

107120
for (const [name, stateAnalysis] of states.entries()) {
@@ -114,6 +127,13 @@ export function analyzeClass(
114127
field.localName = locals.newLocal(name, field.sites.map((site) => site.path));
115128
}
116129

130+
if (effects.cdmPath || effects.cduPath || effects.cwuPath) {
131+
effects.isMountedLocalName = locals.newLocal("isMounted", []);
132+
if (effects.cwuPath) {
133+
effects.cleanupLocalName = locals.newLocal("cleanup", []);
134+
}
135+
}
136+
117137
return {
118138
name: preanalysis.name,
119139
typeParameters: preanalysis.typeParameters,
@@ -125,6 +145,7 @@ export function analyzeClass(
125145
state: states,
126146
props,
127147
userDefined,
148+
effects,
128149
};
129150
}
130151

src/analysis/effect.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { ClassMethod } from "@babel/types";
2+
import type { NodePath } from "@babel/traverse";
3+
import type { ClassFieldAnalysis } from "./class_fields.js";
4+
import { AnalysisError } from "./error.js";
5+
import type { UserDefinedAnalysis } from "./user_defined.js";
6+
7+
export type EffectAnalysis = {
8+
cdmPath: NodePath<ClassMethod> | undefined;
9+
cduPath: NodePath<ClassMethod> | undefined;
10+
cwuPath: NodePath<ClassMethod> | undefined;
11+
isMountedLocalName?: string | undefined;
12+
cleanupLocalName?: string | undefined;
13+
};
14+
export function analyzeEffects(
15+
componentDidMount: ClassFieldAnalysis,
16+
componentDidUpdate: ClassFieldAnalysis,
17+
componentWillUnmount: ClassFieldAnalysis,
18+
userDefined: UserDefinedAnalysis,
19+
): EffectAnalysis {
20+
const cdmInit = componentDidMount.sites.find((site) => site.init);
21+
const cduInit = componentDidUpdate.sites.find((site) => site.init);
22+
const cwuInit = componentWillUnmount.sites.find((site) => site.init);
23+
if (componentDidMount.sites.some((site) => !site.init)) {
24+
throw new AnalysisError("Do not use componentDidMount by yourself");
25+
}
26+
if (componentDidUpdate.sites.some((site) => !site.init)) {
27+
throw new AnalysisError("Do not use componentDidUpdate by yourself");
28+
}
29+
if (componentWillUnmount.sites.some((site) => !site.init)) {
30+
throw new AnalysisError("Do not use componentWillUnmount by yourself");
31+
}
32+
let cdmPath: NodePath<ClassMethod> | undefined = undefined;
33+
let cduPath: NodePath<ClassMethod> | undefined = undefined;
34+
let cwuPath: NodePath<ClassMethod> | undefined = undefined;
35+
if (cdmInit) {
36+
if (!cdmInit.path.isClassMethod()) {
37+
throw new AnalysisError("Not a class method: componentDidMount");
38+
}
39+
if (cdmInit.path.node.params.length > 0) {
40+
throw new AnalysisError("Invalid parameter of componentDidMount");
41+
}
42+
cdmPath = cdmInit.path;
43+
}
44+
if (cduInit) {
45+
if (!cduInit.path.isClassMethod()) {
46+
throw new AnalysisError("Not a class method: componentDidUpdate");
47+
}
48+
if (cduInit.path.node.params.length > 0) {
49+
throw new AnalysisError("Not supported: componentDidUpdate parameters");
50+
}
51+
cduPath = cduInit.path;
52+
}
53+
if (cwuInit) {
54+
if (!cwuInit.path.isClassMethod()) {
55+
throw new AnalysisError("Not a class method: componentWillUnmount");
56+
}
57+
if (cwuInit.path.node.params.length > 0) {
58+
throw new AnalysisError("Invalid parameter of componentWillUnmount");
59+
}
60+
cwuPath = cwuInit.path;
61+
}
62+
63+
for (const [name, field] of userDefined.fields) {
64+
if (
65+
field.type === "user_defined_function"
66+
&& field.sites.some((site) =>
67+
site.type === "expr"
68+
&& site.owner === "componentWillUnmount"
69+
&& !site.path.parentPath.isCallExpression()
70+
)
71+
) {
72+
// A user-defined function is used without immediately calling in componentWillUnmount.
73+
// This is likely the following idiom:
74+
//
75+
// ```js
76+
// onMouseOver = () => {
77+
// ...
78+
// }
79+
// componentDidMount() {
80+
// this.div.addEventListener("mouseover", this.onMouseOver);
81+
// }
82+
// componentWillUnmount() {
83+
// this.div.removeEventListener("mouseover", this.onMouseOver);
84+
// }
85+
// ```
86+
//
87+
// It may break in our "raw effect" transformation
88+
// because function identity may change over time.
89+
//
90+
// We will implement a separate paths for the patterns above,
91+
// but for now we just error out to avoid risks.
92+
93+
throw new AnalysisError(`Possible event unregistration of ${name} in componentWillUnmount`);
94+
}
95+
}
96+
return {
97+
cdmPath,
98+
cduPath,
99+
cwuPath,
100+
};
101+
}

src/index.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,61 @@ describe("react-declassify", () => {
11581158
});
11591159
});
11601160

1161+
describe("Effects", () => {
1162+
it("transforms raw effects", () => {
1163+
const input = dedent`\
1164+
class C extends React.Component {
1165+
componentDidMount() {
1166+
console.log("mounted");
1167+
}
1168+
componentDidUpdate() {
1169+
console.log("updated");
1170+
}
1171+
componentWillUnmount() {
1172+
console.log("unmounting");
1173+
}
1174+
render() {
1175+
return null;
1176+
}
1177+
}
1178+
`;
1179+
const output = dedent`\
1180+
const C = () => {
1181+
const isMounted = React.useRef(false);
1182+
1183+
// TODO(react-declassify): refactor this effect (automatically generated from lifecycle)
1184+
React.useEffect(() => {
1185+
if (!isMounted.current) {
1186+
isMounted.current = true;
1187+
console.log("mounted");
1188+
} else {
1189+
console.log("updated");
1190+
}
1191+
});
1192+
1193+
const cleanup = React.useRef(null);
1194+
1195+
cleanup.current = () => {
1196+
console.log("unmounting");
1197+
};
1198+
1199+
// TODO(react-declassify): refactor this effect (automatically generated from lifecycle)
1200+
React.useEffect(() => {
1201+
return () => {
1202+
if (isMounted.current) {
1203+
isMounted.current = false;
1204+
cleanup.current?.();
1205+
}
1206+
};
1207+
}, []);
1208+
1209+
return null;
1210+
};
1211+
`;
1212+
expect(transform(input)).toBe(output);
1213+
});
1214+
});
1215+
11611216
test("readme example 1", () => {
11621217
const input = dedent`\
11631218
import React from "react";

0 commit comments

Comments
 (0)