Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[react-transform] Support more component definitions & expand tests #441

Merged
merged 37 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
88b7ce3
Add transform test for memo'ed components
andrewiggins Oct 5, 2023
7d7d8c3
Handle using memo and forwardRef on components in transform
andrewiggins Oct 5, 2023
6a8921d
Remove top-level restriction to transform components written in tests
andrewiggins Oct 5, 2023
bf6ed90
Add notes about additional tests to add
andrewiggins Oct 5, 2023
003cf2d
Add some more tests
andrewiggins Oct 6, 2023
e4a7ee9
Start build test generator
andrewiggins Oct 6, 2023
60063c2
Add call exp wrappers and restructure generators
andrewiggins Oct 8, 2023
95614f0
Align prettier versions
andrewiggins Oct 8, 2023
7198591
Add var decl, assignment, obj prop, export default & named generation
andrewiggins Oct 8, 2023
c1a882f
Simplify hoc tests to only test valid names or not
andrewiggins Oct 8, 2023
56cf7e1
Always check state first before checking name
andrewiggins Oct 8, 2023
7c36d15
Rename helpers extension to make TS importing work
andrewiggins Oct 8, 2023
4f6b041
Start using helpers in test file
andrewiggins Oct 8, 2023
de1da1e
Add TODO to refactor get function name methods
andrewiggins Oct 8, 2023
30d4ad0
Fix assignment expression tests
andrewiggins Oct 8, 2023
b87dcda
Add some notes to assist future debugging
andrewiggins Oct 8, 2023
02c384e
Change formatting a bit
andrewiggins Oct 8, 2023
dc17f03
Update transform to parse filename for export default components
andrewiggins Oct 9, 2023
251a0bc
WIP: Hook comment generation to tests
andrewiggins Oct 9, 2023
71f829f
Fix searching for opt in/out comment through HoCs
andrewiggins Oct 9, 2023
d0dbdcc
Clean up code a bit
andrewiggins Oct 9, 2023
c9349ed
Use block comments instead of line comments in generated code
andrewiggins Oct 9, 2023
dd2c9cf
Remove some redundant tests
andrewiggins Oct 9, 2023
7f9c314
Remove some redundant helpers
andrewiggins Oct 9, 2023
9ebc6f4
Cover additional test cases
andrewiggins Oct 9, 2023
1165bba
Rename some older tests to make them more scan-able
andrewiggins Oct 9, 2023
7642eb0
Support multiple test ids in debug helper
andrewiggins Oct 9, 2023
9527ce6
Setup inline variable comment tests
andrewiggins Oct 10, 2023
ab0dc8c
Fix object property tests
andrewiggins Oct 10, 2023
415210d
Update debug code again
andrewiggins Oct 10, 2023
150a8c3
Add some notes at the top of helpers.ts
andrewiggins Oct 10, 2023
1d2edd9
Refactor function name functions
andrewiggins Nov 15, 2023
5952b73
Add support for components assigned to member expressions
andrewiggins Nov 15, 2023
6ae9e81
Support object method components
andrewiggins Nov 16, 2023
575d530
Merge branch 'main' into improve-transform
andrewiggins Nov 16, 2023
ce7fc4a
Add changeset
andrewiggins Nov 16, 2023
ac06c35
Clean up old tests
andrewiggins Nov 16, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sour-bears-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@preact/signals-react-transform": patch
---

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
2 changes: 2 additions & 0 deletions packages/react-transform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@
"@types/babel__core": "^7.20.1",
"@types/babel__helper-module-imports": "^7.18.0",
"@types/babel__helper-plugin-utils": "^7.10.0",
"@types/prettier": "^2.7.3",
"@types/react": "^18.0.18",
"@types/react-dom": "^18.0.6",
"@types/use-sync-external-store": "^0.0.3",
"assert": "^2.0.0",
"buffer": "^6.0.3",
"path": "^0.12.7",
"prettier": "^2.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.9.0"
Expand Down
198 changes: 153 additions & 45 deletions packages/react-transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ import {
} from "@babel/core";
import { isModule, addNamed } from "@babel/helper-module-imports";

// TODO:
// - how to trigger rerenders on attributes change if transform never sees
// `.value`?

interface PluginArgs {
types: typeof BabelTypes;
template: typeof BabelTemplate;
Expand Down Expand Up @@ -51,35 +47,125 @@ function setOnFunctionScope(path: NodePath, key: string, value: any) {
type FunctionLike =
| BabelTypes.ArrowFunctionExpression
| BabelTypes.FunctionExpression
| BabelTypes.FunctionDeclaration;

function testFunctionName<T extends FunctionLike>(
predicate: (name: string | null) => boolean
): (path: NodePath<T>) => boolean {
return (path: NodePath<T>) => {
if (
path.node.type === "ArrowFunctionExpression" ||
path.node.type === "FunctionExpression"
) {
return (
path.parentPath.node.type === "VariableDeclarator" &&
path.parentPath.node.id.type === "Identifier" &&
predicate(path.parentPath.node.id.name)
);
} else if (path.node.type === "FunctionDeclaration") {
return predicate(path.node.id?.name ?? null);
| BabelTypes.FunctionDeclaration
| BabelTypes.ObjectMethod;

/**
* Simple "best effort" to get the base name of a file path. Not fool proof but
* works in browsers and servers. Good enough for our purposes.
*/
function basename(filename: string | undefined): string | undefined {
return filename?.split(/[\\/]/).pop();
}

const DefaultExportSymbol = Symbol("DefaultExportSymbol");

/**
* If the function node has a name (i.e. is a function declaration with a
* name), return that. Else return null.
*/
function getFunctionNodeName(path: NodePath<FunctionLike>): string | null {
if (path.node.type === "FunctionDeclaration" && path.node.id) {
return path.node.id.name;
} else if (path.node.type === "ObjectMethod") {
if (path.node.key.type === "Identifier") {
return path.node.key.name;
} else if (path.node.key.type === "StringLiteral") {
return path.node.key.value;
}
}

return null;
}

/**
* Given a function path's parent path, determine the "name" associated with the
* function. If the function is an inline default export (e.g. `export default
* () => {}`), returns a symbol indicating it is a default export. If the
* function is an anonymous function wrapped in higher order functions (e.g.
* memo(() => {})) we'll climb through the higher order functions to find the
* name of the variable that the function is assigned to, if any. Other cases
* handled too (see implementation). Else returns null.
*/
function getFunctionNameFromParent(
parentPath: NodePath<BabelTypes.Node>
): string | null | typeof DefaultExportSymbol {
if (
parentPath.node.type === "VariableDeclarator" &&
parentPath.node.id.type === "Identifier"
) {
return parentPath.node.id.name;
} else if (parentPath.node.type === "AssignmentExpression") {
const left = parentPath.node.left;
if (left.type === "Identifier") {
return left.name;
} else if (left.type === "MemberExpression") {
let property = left.property;
while (property.type === "MemberExpression") {
property = property.property;
}

if (property.type === "Identifier") {
return property.name;
} else if (property.type === "StringLiteral") {
return property.value;
}

return null;
} else {
return false;
return null;
}
};
} else if (parentPath.node.type === "ExportDefaultDeclaration") {
return DefaultExportSymbol;
} else if (
parentPath.node.type === "CallExpression" &&
parentPath.parentPath != null
) {
// If our parent is a Call Expression, then this function expression is
// wrapped in some higher order functions. Recurse through the higher order
// functions to determine if this expression is assigned to a name we can
// use as the function name
return getFunctionNameFromParent(parentPath.parentPath);
} else {
return null;
}
}

/* Determine the name of a function */
function getFunctionName(
path: NodePath<FunctionLike>
): string | typeof DefaultExportSymbol | null {
let nodeName = getFunctionNodeName(path);
if (nodeName) {
return nodeName;
}

return getFunctionNameFromParent(path.parentPath);
}

function fnNameStartsWithCapital(
path: NodePath<FunctionLike>,
filename: string | undefined
): boolean {
const name = getFunctionName(path);
if (!name) return false;
if (name === DefaultExportSymbol) {
return basename(filename)?.match(/^[A-Z]/) != null ?? false;
}
return name.match(/^[A-Z]/) != null;
}
function fnNameStartsWithUse(
path: NodePath<FunctionLike>,
filename: string | undefined
): boolean {
const name = getFunctionName(path);
if (!name) return false;
if (name === DefaultExportSymbol) {
return basename(filename)?.match(/^use[A-Z]/) != null ?? false;
}

const fnNameStartsWithCapital = testFunctionName(
name => name?.match(/^[A-Z]/) !== null
);
const fnNameStartsWithUse = testFunctionName(
name => name?.match(/^use[A-Z]/) !== null
);
return name.match(/^use[A-Z]/) != null;
}

function hasLeadingComment(path: NodePath, comment: RegExp): boolean {
const comments = path.node.leadingComments;
Expand All @@ -101,9 +187,12 @@ function isOptedIntoSignalTracking(path: NodePath | null): boolean {
case "ArrowFunctionExpression":
case "FunctionExpression":
case "FunctionDeclaration":
case "ObjectMethod":
case "ObjectExpression":
case "VariableDeclarator":
case "VariableDeclaration":
case "AssignmentExpression":
case "CallExpression":
return (
hasLeadingOptInComment(path) ||
isOptedIntoSignalTracking(path.parentPath)
Expand All @@ -125,9 +214,12 @@ function isOptedOutOfSignalTracking(path: NodePath | null): boolean {
case "ArrowFunctionExpression":
case "FunctionExpression":
case "FunctionDeclaration":
case "ObjectMethod":
case "ObjectExpression":
case "VariableDeclarator":
case "VariableDeclaration":
case "AssignmentExpression":
case "CallExpression":
return (
hasLeadingOptOutComment(path) ||
isOptedOutOfSignalTracking(path.parentPath)
Expand All @@ -142,19 +234,26 @@ function isOptedOutOfSignalTracking(path: NodePath | null): boolean {
}
}

function isComponentFunction(path: NodePath<FunctionLike>): boolean {
function isComponentFunction(
path: NodePath<FunctionLike>,
filename: string | undefined
): boolean {
return (
fnNameStartsWithCapital(path) && // Function name indicates it's a component
getData(path.scope, containsJSX) === true // Function contains JSX
getData(path.scope, containsJSX) === true && // Function contains JSX
fnNameStartsWithCapital(path, filename) // Function name indicates it's a component
);
}

function isCustomHook(path: NodePath<FunctionLike>): boolean {
return fnNameStartsWithUse(path); // Function name indicates it's a hook
function isCustomHook(
path: NodePath<FunctionLike>,
filename: string | undefined
): boolean {
return fnNameStartsWithUse(path, filename); // Function name indicates it's a hook
}

function shouldTransform(
path: NodePath<FunctionLike>,
filename: string | undefined,
options: PluginOptions
): boolean {
if (getData(path, alreadyTransformed) === true) return false;
Expand All @@ -165,14 +264,14 @@ function shouldTransform(
if (isOptedIntoSignalTracking(path)) return true;

if (options.mode === "all") {
return isComponentFunction(path);
return isComponentFunction(path, filename);
}

if (options.mode == null || options.mode === "auto") {
return (
(isComponentFunction(path) || isCustomHook(path)) &&
getData(path.scope, maybeUsesSignal) === true
); // Function appears to use signals;
getData(path.scope, maybeUsesSignal) === true && // Function appears to use signals;
(isComponentFunction(path, filename) || isCustomHook(path, filename))
);
}

return false;
Expand Down Expand Up @@ -242,10 +341,11 @@ function transformFunction(
t: typeof BabelTypes,
options: PluginOptions,
path: NodePath<FunctionLike>,
filename: string | undefined,
state: PluginPass
) {
let newFunction: FunctionLike;
if (isCustomHook(path) || options.experimental?.noTryFinally) {
if (isCustomHook(path, filename) || options.experimental?.noTryFinally) {
// For custom hooks, we don't need to wrap the function body in a
// try/finally block because later code in the function's render body could
// read signals and we want to track and associate those signals with this
Expand Down Expand Up @@ -369,24 +469,32 @@ export default function signalsTransform(
// seeing a function would probably be faster than running an entire
// babel pass with plugins on components twice.
exit(path, state) {
if (shouldTransform(path, options)) {
transformFunction(t, options, path, state);
if (shouldTransform(path, this.filename, options)) {
transformFunction(t, options, path, this.filename, state);
}
},
},

FunctionExpression: {
exit(path, state) {
if (shouldTransform(path, options)) {
transformFunction(t, options, path, state);
if (shouldTransform(path, this.filename, options)) {
transformFunction(t, options, path, this.filename, state);
}
},
},

FunctionDeclaration: {
exit(path, state) {
if (shouldTransform(path, options)) {
transformFunction(t, options, path, state);
if (shouldTransform(path, this.filename, options)) {
transformFunction(t, options, path, this.filename, state);
}
},
},

ObjectMethod: {
exit(path, state) {
if (shouldTransform(path, this.filename, options)) {
transformFunction(t, options, path, this.filename, state);
}
},
},
Expand Down
Loading
Loading