Skip to content

Commit 4c433c3

Browse files
authored
[react-transform] Support more component definitions & expand tests (#441)
* Add transform test for memo'ed components * Handle using memo and forwardRef on components in transform * Remove top-level restriction to transform components written in tests * Add notes about additional tests to add * Add some more tests * Start build test generator * Add call exp wrappers and restructure generators * Align prettier versions * Add var decl, assignment, obj prop, export default & named generation * Simplify hoc tests to only test valid names or not * Always check state first before checking name Since state should be a simple look up while reading name is an AST climb * Rename helpers extension to make TS importing work * Start using helpers in test file * Add TODO to refactor get function name methods * Fix assignment expression tests * Add some notes to assist future debugging * Change formatting a bit * Update transform to parse filename for export default components * WIP: Hook comment generation to tests Tests are failing cuz of bug :) * Fix searching for opt in/out comment through HoCs * Clean up code a bit * Use block comments instead of line comments in generated code * Remove some redundant tests * Remove some redundant helpers * Cover additional test cases * Rename some older tests to make them more scan-able * Support multiple test ids in debug helper * Setup inline variable comment tests * Fix object property tests * Update debug code again * Add some notes at the top of helpers.ts * Refactor function name functions * Add support for components assigned to member expressions * Support object method components * Add changeset * Clean up old tests
1 parent fb6b050 commit 4c433c3

File tree

7 files changed

+1596
-676
lines changed

7 files changed

+1596
-676
lines changed

.changeset/sour-bears-complain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@preact/signals-react-transform": patch
3+
---
4+
5+
Add support for auto-transforming more ways to specify components: object methods, member assignments, export default components, components wrapped in HoCs like memo and forwardRef

packages/react-transform/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@
5959
"@types/babel__core": "^7.20.1",
6060
"@types/babel__helper-module-imports": "^7.18.0",
6161
"@types/babel__helper-plugin-utils": "^7.10.0",
62+
"@types/prettier": "^2.7.3",
6263
"@types/react": "^18.0.18",
6364
"@types/react-dom": "^18.0.6",
6465
"@types/use-sync-external-store": "^0.0.3",
6566
"assert": "^2.0.0",
6667
"buffer": "^6.0.3",
6768
"path": "^0.12.7",
69+
"prettier": "^2.7.1",
6870
"react": "^18.2.0",
6971
"react-dom": "^18.2.0",
7072
"react-router-dom": "^6.9.0"

packages/react-transform/src/index.ts

Lines changed: 153 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ import {
88
} from "@babel/core";
99
import { isModule, addNamed } from "@babel/helper-module-imports";
1010

11-
// TODO:
12-
// - how to trigger rerenders on attributes change if transform never sees
13-
// `.value`?
14-
1511
interface PluginArgs {
1612
types: typeof BabelTypes;
1713
template: typeof BabelTemplate;
@@ -51,35 +47,125 @@ function setOnFunctionScope(path: NodePath, key: string, value: any) {
5147
type FunctionLike =
5248
| BabelTypes.ArrowFunctionExpression
5349
| BabelTypes.FunctionExpression
54-
| BabelTypes.FunctionDeclaration;
55-
56-
function testFunctionName<T extends FunctionLike>(
57-
predicate: (name: string | null) => boolean
58-
): (path: NodePath<T>) => boolean {
59-
return (path: NodePath<T>) => {
60-
if (
61-
path.node.type === "ArrowFunctionExpression" ||
62-
path.node.type === "FunctionExpression"
63-
) {
64-
return (
65-
path.parentPath.node.type === "VariableDeclarator" &&
66-
path.parentPath.node.id.type === "Identifier" &&
67-
predicate(path.parentPath.node.id.name)
68-
);
69-
} else if (path.node.type === "FunctionDeclaration") {
70-
return predicate(path.node.id?.name ?? null);
50+
| BabelTypes.FunctionDeclaration
51+
| BabelTypes.ObjectMethod;
52+
53+
/**
54+
* Simple "best effort" to get the base name of a file path. Not fool proof but
55+
* works in browsers and servers. Good enough for our purposes.
56+
*/
57+
function basename(filename: string | undefined): string | undefined {
58+
return filename?.split(/[\\/]/).pop();
59+
}
60+
61+
const DefaultExportSymbol = Symbol("DefaultExportSymbol");
62+
63+
/**
64+
* If the function node has a name (i.e. is a function declaration with a
65+
* name), return that. Else return null.
66+
*/
67+
function getFunctionNodeName(path: NodePath<FunctionLike>): string | null {
68+
if (path.node.type === "FunctionDeclaration" && path.node.id) {
69+
return path.node.id.name;
70+
} else if (path.node.type === "ObjectMethod") {
71+
if (path.node.key.type === "Identifier") {
72+
return path.node.key.name;
73+
} else if (path.node.key.type === "StringLiteral") {
74+
return path.node.key.value;
75+
}
76+
}
77+
78+
return null;
79+
}
80+
81+
/**
82+
* Given a function path's parent path, determine the "name" associated with the
83+
* function. If the function is an inline default export (e.g. `export default
84+
* () => {}`), returns a symbol indicating it is a default export. If the
85+
* function is an anonymous function wrapped in higher order functions (e.g.
86+
* memo(() => {})) we'll climb through the higher order functions to find the
87+
* name of the variable that the function is assigned to, if any. Other cases
88+
* handled too (see implementation). Else returns null.
89+
*/
90+
function getFunctionNameFromParent(
91+
parentPath: NodePath<BabelTypes.Node>
92+
): string | null | typeof DefaultExportSymbol {
93+
if (
94+
parentPath.node.type === "VariableDeclarator" &&
95+
parentPath.node.id.type === "Identifier"
96+
) {
97+
return parentPath.node.id.name;
98+
} else if (parentPath.node.type === "AssignmentExpression") {
99+
const left = parentPath.node.left;
100+
if (left.type === "Identifier") {
101+
return left.name;
102+
} else if (left.type === "MemberExpression") {
103+
let property = left.property;
104+
while (property.type === "MemberExpression") {
105+
property = property.property;
106+
}
107+
108+
if (property.type === "Identifier") {
109+
return property.name;
110+
} else if (property.type === "StringLiteral") {
111+
return property.value;
112+
}
113+
114+
return null;
71115
} else {
72-
return false;
116+
return null;
73117
}
74-
};
118+
} else if (parentPath.node.type === "ExportDefaultDeclaration") {
119+
return DefaultExportSymbol;
120+
} else if (
121+
parentPath.node.type === "CallExpression" &&
122+
parentPath.parentPath != null
123+
) {
124+
// If our parent is a Call Expression, then this function expression is
125+
// wrapped in some higher order functions. Recurse through the higher order
126+
// functions to determine if this expression is assigned to a name we can
127+
// use as the function name
128+
return getFunctionNameFromParent(parentPath.parentPath);
129+
} else {
130+
return null;
131+
}
132+
}
133+
134+
/* Determine the name of a function */
135+
function getFunctionName(
136+
path: NodePath<FunctionLike>
137+
): string | typeof DefaultExportSymbol | null {
138+
let nodeName = getFunctionNodeName(path);
139+
if (nodeName) {
140+
return nodeName;
141+
}
142+
143+
return getFunctionNameFromParent(path.parentPath);
144+
}
145+
146+
function fnNameStartsWithCapital(
147+
path: NodePath<FunctionLike>,
148+
filename: string | undefined
149+
): boolean {
150+
const name = getFunctionName(path);
151+
if (!name) return false;
152+
if (name === DefaultExportSymbol) {
153+
return basename(filename)?.match(/^[A-Z]/) != null ?? false;
154+
}
155+
return name.match(/^[A-Z]/) != null;
75156
}
157+
function fnNameStartsWithUse(
158+
path: NodePath<FunctionLike>,
159+
filename: string | undefined
160+
): boolean {
161+
const name = getFunctionName(path);
162+
if (!name) return false;
163+
if (name === DefaultExportSymbol) {
164+
return basename(filename)?.match(/^use[A-Z]/) != null ?? false;
165+
}
76166

77-
const fnNameStartsWithCapital = testFunctionName(
78-
name => name?.match(/^[A-Z]/) !== null
79-
);
80-
const fnNameStartsWithUse = testFunctionName(
81-
name => name?.match(/^use[A-Z]/) !== null
82-
);
167+
return name.match(/^use[A-Z]/) != null;
168+
}
83169

84170
function hasLeadingComment(path: NodePath, comment: RegExp): boolean {
85171
const comments = path.node.leadingComments;
@@ -101,9 +187,12 @@ function isOptedIntoSignalTracking(path: NodePath | null): boolean {
101187
case "ArrowFunctionExpression":
102188
case "FunctionExpression":
103189
case "FunctionDeclaration":
190+
case "ObjectMethod":
191+
case "ObjectExpression":
104192
case "VariableDeclarator":
105193
case "VariableDeclaration":
106194
case "AssignmentExpression":
195+
case "CallExpression":
107196
return (
108197
hasLeadingOptInComment(path) ||
109198
isOptedIntoSignalTracking(path.parentPath)
@@ -125,9 +214,12 @@ function isOptedOutOfSignalTracking(path: NodePath | null): boolean {
125214
case "ArrowFunctionExpression":
126215
case "FunctionExpression":
127216
case "FunctionDeclaration":
217+
case "ObjectMethod":
218+
case "ObjectExpression":
128219
case "VariableDeclarator":
129220
case "VariableDeclaration":
130221
case "AssignmentExpression":
222+
case "CallExpression":
131223
return (
132224
hasLeadingOptOutComment(path) ||
133225
isOptedOutOfSignalTracking(path.parentPath)
@@ -142,19 +234,26 @@ function isOptedOutOfSignalTracking(path: NodePath | null): boolean {
142234
}
143235
}
144236

145-
function isComponentFunction(path: NodePath<FunctionLike>): boolean {
237+
function isComponentFunction(
238+
path: NodePath<FunctionLike>,
239+
filename: string | undefined
240+
): boolean {
146241
return (
147-
fnNameStartsWithCapital(path) && // Function name indicates it's a component
148-
getData(path.scope, containsJSX) === true // Function contains JSX
242+
getData(path.scope, containsJSX) === true && // Function contains JSX
243+
fnNameStartsWithCapital(path, filename) // Function name indicates it's a component
149244
);
150245
}
151246

152-
function isCustomHook(path: NodePath<FunctionLike>): boolean {
153-
return fnNameStartsWithUse(path); // Function name indicates it's a hook
247+
function isCustomHook(
248+
path: NodePath<FunctionLike>,
249+
filename: string | undefined
250+
): boolean {
251+
return fnNameStartsWithUse(path, filename); // Function name indicates it's a hook
154252
}
155253

156254
function shouldTransform(
157255
path: NodePath<FunctionLike>,
256+
filename: string | undefined,
158257
options: PluginOptions
159258
): boolean {
160259
if (getData(path, alreadyTransformed) === true) return false;
@@ -165,14 +264,14 @@ function shouldTransform(
165264
if (isOptedIntoSignalTracking(path)) return true;
166265

167266
if (options.mode === "all") {
168-
return isComponentFunction(path);
267+
return isComponentFunction(path, filename);
169268
}
170269

171270
if (options.mode == null || options.mode === "auto") {
172271
return (
173-
(isComponentFunction(path) || isCustomHook(path)) &&
174-
getData(path.scope, maybeUsesSignal) === true
175-
); // Function appears to use signals;
272+
getData(path.scope, maybeUsesSignal) === true && // Function appears to use signals;
273+
(isComponentFunction(path, filename) || isCustomHook(path, filename))
274+
);
176275
}
177276

178277
return false;
@@ -242,10 +341,11 @@ function transformFunction(
242341
t: typeof BabelTypes,
243342
options: PluginOptions,
244343
path: NodePath<FunctionLike>,
344+
filename: string | undefined,
245345
state: PluginPass
246346
) {
247347
let newFunction: FunctionLike;
248-
if (isCustomHook(path) || options.experimental?.noTryFinally) {
348+
if (isCustomHook(path, filename) || options.experimental?.noTryFinally) {
249349
// For custom hooks, we don't need to wrap the function body in a
250350
// try/finally block because later code in the function's render body could
251351
// read signals and we want to track and associate those signals with this
@@ -369,24 +469,32 @@ export default function signalsTransform(
369469
// seeing a function would probably be faster than running an entire
370470
// babel pass with plugins on components twice.
371471
exit(path, state) {
372-
if (shouldTransform(path, options)) {
373-
transformFunction(t, options, path, state);
472+
if (shouldTransform(path, this.filename, options)) {
473+
transformFunction(t, options, path, this.filename, state);
374474
}
375475
},
376476
},
377477

378478
FunctionExpression: {
379479
exit(path, state) {
380-
if (shouldTransform(path, options)) {
381-
transformFunction(t, options, path, state);
480+
if (shouldTransform(path, this.filename, options)) {
481+
transformFunction(t, options, path, this.filename, state);
382482
}
383483
},
384484
},
385485

386486
FunctionDeclaration: {
387487
exit(path, state) {
388-
if (shouldTransform(path, options)) {
389-
transformFunction(t, options, path, state);
488+
if (shouldTransform(path, this.filename, options)) {
489+
transformFunction(t, options, path, this.filename, state);
490+
}
491+
},
492+
},
493+
494+
ObjectMethod: {
495+
exit(path, state) {
496+
if (shouldTransform(path, this.filename, options)) {
497+
transformFunction(t, options, path, this.filename, state);
390498
}
391499
},
392500
},

0 commit comments

Comments
 (0)