Skip to content

Commit 99e8a2a

Browse files
committed
Support for method-binding patterns
1 parent cff4f2f commit 99e8a2a

File tree

6 files changed

+139
-5
lines changed

6 files changed

+139
-5
lines changed

CHANGELOG.md

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

3+
- Added
4+
- Support for method-binding patterns e.g. `this.foo = this.foo.bind(this);`
5+
36
## 0.1.8
47

58
- Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,8 @@ If you need to enforce specific styles, use Prettier or ESLint or whatever is yo
242242
- [x] Use `useCallback` if deemed necessary
243243
- [x] Auto-expand direct callback call (like `this.props.onClick()`) to indirect call
244244
- [x] Rename methods if necessary
245-
- [ ] Skip method-binding expressions (e.g. `onClick={this.onClick.bind(this)}`)
246-
- [ ] Skip method-binding statements (e.g. `this.onClick = this.onClick.bind(this)`)
245+
- [x] Skip method-binding expressions (e.g. `onClick={this.onClick.bind(this)}`)
246+
- [x] Skip method-binding statements (e.g. `this.onClick = this.onClick.bind(this)`)
247247
- [ ] Support for `this.state`
248248
- [x] Decompose `this.state` into `useState` variables
249249
- [x] Rename states if necessary

src/analysis.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { NodePath } from "@babel/core";
22
import type { Scope } from "@babel/traverse";
33
import type { ClassDeclaration, ClassMethod, Identifier, JSXIdentifier, TSType, TSTypeParameterDeclaration } from "@babel/types";
44
import { AnalysisError } from "./analysis/error.js";
5-
import { analyzeClassFields } from "./analysis/class_fields.js";
5+
import { BindThisSite, analyzeClassFields } from "./analysis/class_fields.js";
66
import { analyzeState, StateObjAnalysis } from "./analysis/state.js";
77
import { getAndDelete } from "./utils.js";
88
import { analyzeProps, needAlias, PropsObjAnalysis } from "./analysis/prop.js";
@@ -45,14 +45,15 @@ export type AnalysisResult = {
4545
props: PropsObjAnalysis;
4646
userDefined: UserDefinedAnalysis;
4747
effects: EffectAnalysis;
48+
bindThisSites: BindThisSite[];
4849
};
4950

5051
export function analyzeClass(
5152
path: NodePath<ClassDeclaration>,
5253
preanalysis: PreAnalysisResult
5354
): AnalysisResult {
5455
const locals = new LocalManager(path);
55-
const { instanceFields: sites, staticFields } = analyzeClassFields(path);
56+
const { instanceFields: sites, staticFields, bindThisSites } = analyzeClassFields(path);
5657

5758
const propsObjAnalysis = getAndDelete(sites, "props") ?? { sites: [] };
5859
const defaultPropsObjAnalysis = getAndDelete(staticFields, "defaultProps") ?? { sites: [] };
@@ -146,6 +147,7 @@ export function analyzeClass(
146147
props,
147148
userDefined,
148149
effects,
150+
bindThisSites,
149151
};
150152
}
151153

src/analysis/class_fields.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export type ClassFieldsAnalysis = {
1616
instanceFields: Map<string, ClassFieldAnalysis>;
1717
/** Access to static fields (`C.foo`, where `C` is the class), indexed by their names. */
1818
staticFields: Map<string, ClassFieldAnalysis>;
19+
/** Appearances of `this` as in `this.foo.bind(this)` */
20+
bindThisSites: BindThisSite[];
1921
};
2022

2123
/**
@@ -105,6 +107,24 @@ export type FieldInit = {
105107
methodPath: NodePath<ClassMethod | ClassPrivateMethod>;
106108
};
107109

110+
/**
111+
* Appearance of `this` as in `this.foo.bind(this)`
112+
*/
113+
export type BindThisSite = {
114+
/**
115+
* true if the bind call has more arguments e.g. `this.foo.bind(this, 42)`
116+
*/
117+
bindsMore: boolean;
118+
/** `this` as in the argument to `Function.prototype.bind` */
119+
thisArgPath: NodePath<ThisExpression>;
120+
/** The whole bind expression */
121+
binderPath: NodePath<CallExpression>;
122+
/** The member expression that `this` is being bound to e.g. `this.foo` */
123+
bindeePath: NodePath<MemberExpression>;
124+
/** true if this is part of self-binding: `this.foo = this.foo.bind(this);` */
125+
isSelfBindingInitialization: boolean;
126+
};
127+
108128
/**
109129
* Collect declarations and uses of the following:
110130
*
@@ -301,13 +321,33 @@ export function analyzeClassFields(path: NodePath<ClassDeclaration>): ClassField
301321
}
302322

303323
// 2nd pass: look for uses within items
324+
const bindThisSites: BindThisSite[] = [];
304325
function traverseItem(owner: string | undefined, path: NodePath) {
305326
traverseThis(path, (thisPath) => {
306327
// Ensure this is part of `this.foo`
307328
const thisMemberPath = thisPath.parentPath;
308329
if (!thisMemberPath.isMemberExpression({
309330
object: thisPath.node
310331
})) {
332+
// Check for bind arguments: `this.foo.bind(this)`
333+
if (
334+
thisMemberPath.isCallExpression()
335+
&& thisMemberPath.node.arguments[0] === thisPath.node
336+
&& thisMemberPath.node.callee.type === "MemberExpression"
337+
&& memberRefName(thisMemberPath.node.callee) === "bind"
338+
&& thisMemberPath.node.callee.object.type === "MemberExpression"
339+
&& thisMemberPath.node.callee.object.object.type === "ThisExpression"
340+
) {
341+
bindThisSites.push({
342+
bindsMore: thisMemberPath.node.arguments.length > 1,
343+
thisArgPath: thisPath,
344+
binderPath: thisMemberPath,
345+
bindeePath: (thisMemberPath.get("callee") as NodePath<MemberExpression>).get("object") as NodePath<MemberExpression>,
346+
// Checked later
347+
isSelfBindingInitialization: false,
348+
});
349+
return;
350+
}
311351
throw new AnalysisError(`Stray this`);
312352
}
313353

@@ -345,6 +385,41 @@ export function analyzeClassFields(path: NodePath<ClassDeclaration>): ClassField
345385
traverseItem(body.owner, body.path);
346386
}
347387

388+
// Special handling for self-binding initialization (`this.foo = this.foo.bind(this)`)
389+
for (const [name, field] of instanceFields) {
390+
field.sites = field.sites.filter((site) => {
391+
if (
392+
site.type === "decl"
393+
&& site.init?.type === "init_value"
394+
) {
395+
const valuePath = site.init.valuePath;
396+
const bindThisSite = bindThisSites.find((binder) => binder.binderPath === valuePath)
397+
if (
398+
bindThisSite
399+
&& !bindThisSite.bindsMore
400+
&& memberRefName(bindThisSite.bindeePath.node) === name
401+
) {
402+
bindThisSite.isSelfBindingInitialization = true;
403+
// Skip the self-binding initialization (lhs)
404+
return false;
405+
}
406+
}
407+
return true;
408+
});
409+
}
410+
for (const [, field] of instanceFields) {
411+
field.sites = field.sites.filter((site) => {
412+
if (site.type === "expr") {
413+
const bindThisSite = bindThisSites.find((binder) => binder.bindeePath === site.path)
414+
if (bindThisSite?.isSelfBindingInitialization) {
415+
// Skip the self-binding initialization (rhs)
416+
return false;
417+
}
418+
}
419+
return true;
420+
});
421+
}
422+
348423
// Post validation
349424
for (const [name, field] of instanceFields) {
350425
if (field.sites.length === 0) {
@@ -373,7 +448,7 @@ export function analyzeClassFields(path: NodePath<ClassDeclaration>): ClassField
373448
}
374449
}
375450

376-
return { instanceFields, staticFields };
451+
return { instanceFields, staticFields, bindThisSites };
377452
}
378453

379454
function traverseThis(path: NodePath, visit: (path: NodePath<ThisExpression>) => void) {

src/index.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,53 @@ describe("react-declassify", () => {
760760
`;
761761
expect(transform(input)).toBe(output);
762762
});
763+
764+
it("transforms binding initialization", () => {
765+
const input = dedent`\
766+
class C extends React.Component {
767+
constructor(props) {
768+
super(props);
769+
this.foo = this.foo.bind(this);
770+
}
771+
772+
render() {
773+
return (
774+
<Component2
775+
foo={this.foo}
776+
bar={this.bar.bind(this)}
777+
/>
778+
);
779+
}
780+
781+
foo() {
782+
console.log("foo");
783+
}
784+
785+
bar() {
786+
console.log("bar");
787+
}
788+
}
789+
`;
790+
const output = dedent`\
791+
const C = () => {
792+
const foo = React.useCallback(function foo() {
793+
console.log("foo");
794+
}, []);
795+
796+
const bar = React.useCallback(function bar() {
797+
console.log("bar");
798+
}, []);
799+
800+
return (
801+
<Component2
802+
foo={foo}
803+
bar={bar}
804+
/>
805+
);
806+
};
807+
`;
808+
expect(transform(input)).toBe(output);
809+
});
763810
});
764811

765812
describe("State transformation", () => {

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ function transformClass(analysis: AnalysisResult, options: { ts: boolean }, babe
180180
}
181181
}
182182
}
183+
for (const bindThisSite of analysis.bindThisSites) {
184+
if (bindThisSite.bindsMore) {
185+
bindThisSite.thisArgPath.replaceWith(t.nullLiteral());
186+
} else {
187+
bindThisSite.binderPath.replaceWith(bindThisSite.bindeePath.node);
188+
}
189+
}
183190
// Preamble is a set of statements to be added before the original render body.
184191
const preamble: Statement[] = [];
185192
const propsWithAlias = Array.from(analysis.props.props).filter(([, prop]) => needAlias(prop));

0 commit comments

Comments
 (0)