diff --git a/.changeset/sour-bears-complain.md b/.changeset/sour-bears-complain.md new file mode 100644 index 000000000..be4ae916e --- /dev/null +++ b/.changeset/sour-bears-complain.md @@ -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 diff --git a/packages/react-transform/package.json b/packages/react-transform/package.json index 20cf67062..c65a65a2d 100644 --- a/packages/react-transform/package.json +++ b/packages/react-transform/package.json @@ -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" diff --git a/packages/react-transform/src/index.ts b/packages/react-transform/src/index.ts index 779bd642f..2767d23b5 100644 --- a/packages/react-transform/src/index.ts +++ b/packages/react-transform/src/index.ts @@ -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; @@ -51,35 +47,125 @@ function setOnFunctionScope(path: NodePath, key: string, value: any) { type FunctionLike = | BabelTypes.ArrowFunctionExpression | BabelTypes.FunctionExpression - | BabelTypes.FunctionDeclaration; - -function testFunctionName( - predicate: (name: string | null) => boolean -): (path: NodePath) => boolean { - return (path: NodePath) => { - 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): 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 +): 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 +): string | typeof DefaultExportSymbol | null { + let nodeName = getFunctionNodeName(path); + if (nodeName) { + return nodeName; + } + + return getFunctionNameFromParent(path.parentPath); +} + +function fnNameStartsWithCapital( + path: NodePath, + 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, + 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; @@ -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) @@ -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) @@ -142,19 +234,26 @@ function isOptedOutOfSignalTracking(path: NodePath | null): boolean { } } -function isComponentFunction(path: NodePath): boolean { +function isComponentFunction( + path: NodePath, + 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): boolean { - return fnNameStartsWithUse(path); // Function name indicates it's a hook +function isCustomHook( + path: NodePath, + filename: string | undefined +): boolean { + return fnNameStartsWithUse(path, filename); // Function name indicates it's a hook } function shouldTransform( path: NodePath, + filename: string | undefined, options: PluginOptions ): boolean { if (getData(path, alreadyTransformed) === true) return false; @@ -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; @@ -242,10 +341,11 @@ function transformFunction( t: typeof BabelTypes, options: PluginOptions, path: NodePath, + 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 @@ -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); } }, }, diff --git a/packages/react-transform/test/browser/e2e.test.tsx b/packages/react-transform/test/browser/e2e.test.tsx index 3db87a3c4..f9aaae793 100644 --- a/packages/react-transform/test/browser/e2e.test.tsx +++ b/packages/react-transform/test/browser/e2e.test.tsx @@ -2,6 +2,7 @@ import * as signalsCore from "@preact/signals-core"; import { batch, signal } from "@preact/signals-core"; import { PluginOptions } from "@preact/signals-react-transform"; import * as signalsRuntime from "@preact/signals-react/runtime"; +import * as React from "react"; import { createElement } from "react"; import * as jsxRuntime from "react/jsx-runtime"; import { @@ -17,6 +18,7 @@ const customSource = "useSignals-custom-source"; const modules: Record = { "@preact/signals-core": signalsCore, "@preact/signals-react/runtime": signalsRuntime, + react: React, "react/jsx-runtime": jsxRuntime, [customSource]: signalsRuntime, }; @@ -194,6 +196,151 @@ describe("React Signals babel transfrom - browser E2E tests", () => { expect(scratch.innerHTML).to.equal("
Hello John!
"); }); + it("should rerender components wrapped in memo", async () => { + const { MemoApp, name } = await createComponent(` + import { signal } from "@preact/signals-core"; + import { memo } from "react"; + + export const name = signal("John"); + + function App({ name }) { + return
Hello {name.value}
; + } + + export const MemoApp = memo(App); + `); + + await render(); + expect(scratch.innerHTML).to.equal("
Hello John
"); + + await act(() => { + name.value = "Jane"; + }); + expect(scratch.innerHTML).to.equal("
Hello Jane
"); + }); + + it("should rerender components wrapped in memo inline", async () => { + const { MemoApp, name } = await createComponent(` + import { signal } from "@preact/signals-core"; + import { memo } from "react"; + + export const name = signal("John"); + + export const MemoApp = memo(({ name }) => { + return
Hello {name.value}
; + }); + `); + + await render(); + expect(scratch.innerHTML).to.equal("
Hello John
"); + + await act(() => { + name.value = "Jane"; + }); + expect(scratch.innerHTML).to.equal("
Hello Jane
"); + }); + + it("should rerender components wrapped in forwardRef", async () => { + const { ForwardRefApp, name } = await createComponent(` + import { signal } from "@preact/signals-core"; + import { forwardRef } from "react"; + + export const name = signal("John"); + + function App({ name }, ref) { + return
Hello {name.value}
; + } + + export const ForwardRefApp = forwardRef(App); + `); + + const ref = React.createRef(); + await render(); + expect(scratch.innerHTML).to.equal("
Hello John
"); + expect(ref.current).to.equal(scratch.firstChild); + + await act(() => { + name.value = "Jane"; + }); + expect(scratch.innerHTML).to.equal("
Hello Jane
"); + expect(ref.current).to.equal(scratch.firstChild); + }); + + it("should rerender components wrapped in forwardRef inline", async () => { + const { ForwardRefApp, name } = await createComponent(` + import { signal } from "@preact/signals-core"; + import { forwardRef } from "react"; + + export const name = signal("John"); + + export const ForwardRefApp = forwardRef(({ name }, ref) => { + return
Hello {name.value}
; + }); + `); + + const ref = React.createRef(); + await render(); + expect(scratch.innerHTML).to.equal("
Hello John
"); + expect(ref.current).to.equal(scratch.firstChild); + + await act(() => { + name.value = "Jane"; + }); + expect(scratch.innerHTML).to.equal("
Hello Jane
"); + expect(ref.current).to.equal(scratch.firstChild); + }); + + it("should rerender components wrapped in forwardRef with memo", async () => { + const { MemoForwardRefApp, name } = await createComponent(` + import { signal } from "@preact/signals-core"; + import { memo, forwardRef } from "react"; + + export const name = signal("John"); + + export const MemoForwardRefApp = memo(forwardRef(({ name }, ref) => { + return
Hello {name.value}
; + })); + `); + + const ref = React.createRef(); + await render(); + expect(scratch.innerHTML).to.equal("
Hello John
"); + expect(ref.current).to.equal(scratch.firstChild); + + await act(() => { + name.value = "Jane"; + }); + expect(scratch.innerHTML).to.equal("
Hello Jane
"); + expect(ref.current).to.equal(scratch.firstChild); + }); + + it("should transform components authored inside a test's body", async () => { + const { name, App } = await createComponent(` + import { signal } from "@preact/signals-core"; + import { memo } from "react"; + + export const name = signal("John"); + export let App; + + const it = (name, fn) => fn(); + + it('should work', () => { + App = () => { + return
Hello {name.value}
; + } + }); + `); + + await render(); + expect(scratch.innerHTML).to.equal("
Hello John
"); + + await act(() => { + name.value = "Jane"; + }); + + expect(scratch.innerHTML).to.equal("
Hello Jane
"); + }); + it("loads useSignals from a custom source", async () => { const { App } = await createComponent( ` diff --git a/packages/react-transform/test/node/helpers.ts b/packages/react-transform/test/node/helpers.ts new file mode 100644 index 000000000..d0edbaf63 --- /dev/null +++ b/packages/react-transform/test/node/helpers.ts @@ -0,0 +1,995 @@ +/** + * This file generates test cases for the transform. It generates a bunch of + * different components and then generates the source code for them. The + * generated source code is then used as the input for the transform. The test + * can then assert whether the transform should transform the code into the + * expected output or leave it untouched. + * + * Many of the language constructs generated here are to test the logic that + * finds the component name. For example, the transform should be able to find + * the component name even if the component is wrapped in a memo or forwardRef + * call. So we generate a bunch of components wrapped in those calls. + * + * We also generate constructs to test where users may place the comment to opt + * in or out of tracking signals. For example, the comment may be placed on the + * function declaration, the variable declaration, or the export statement. + * + * Some common abbreviations you may see in this file: + * - Comp: component + * - Exp: expression + * - Decl: declaration + * - Var: variable + * - Obj: object + * - Prop: property + */ + +/** + * Interface representing the input and transformed output. A test may choose + * to use the transformed output or ignore it if the test is asserting the + * plugin does nothing + */ +interface InputOutput { + input: string; + transformed: string; +} + +export type CommentKind = "opt-in" | "opt-out" | undefined; +type VariableKind = "var" | "let" | "const"; +type ParamsConfig = 0 | 1 | 2 | 3 | undefined; + +interface FuncDeclComponent { + type: "FuncDeclComp"; + name: string; + body: string; + params?: ParamsConfig; + comment?: CommentKind; +} + +interface FuncExpComponent { + type: "FuncExpComp"; + name?: string; + body: string; + params?: ParamsConfig; +} + +interface ArrowFuncComponent { + type: "ArrowComp"; + return: "statement" | "expression"; + body: string; + params?: ParamsConfig; +} + +interface ObjMethodComponent { + type: "ObjectMethodComp"; + name: string; + body: string; + params?: ParamsConfig; + comment?: CommentKind; +} + +interface CallExp { + type: "CallExp"; + name: string; + args: Array; +} + +interface Variable { + type: "Variable"; + name: string; + body: InputOutput; + kind?: VariableKind; + comment?: CommentKind; + inlineComment?: CommentKind; +} + +interface Assignment { + type: "Assignment"; + name: string; + body: InputOutput; + kind?: VariableKind; + comment?: CommentKind; +} + +interface MemberExpAssign { + type: "MemberExpAssign"; + property: string; + body: InputOutput; + comment?: CommentKind; +} + +interface ObjectProperty { + type: "ObjectProperty"; + name: string; + body: InputOutput; + comment?: CommentKind; +} + +interface ExportDefault { + type: "ExportDefault"; + body: InputOutput; + comment?: CommentKind; +} + +interface ExportNamed { + type: "ExportNamed"; + body: InputOutput; + comment?: CommentKind; +} + +interface NodeTypes { + FuncDeclComp: FuncDeclComponent; + FuncExpComp: FuncExpComponent; + ArrowComp: ArrowFuncComponent; + ObjectMethodComp: ObjMethodComponent; + CallExp: CallExp; + ExportDefault: ExportDefault; + ExportNamed: ExportNamed; + Variable: Variable; + Assignment: Assignment; + MemberExpAssign: MemberExpAssign; + ObjectProperty: ObjectProperty; +} + +type Node = NodeTypes[keyof NodeTypes]; + +type Generators = { + [key in keyof NodeTypes]: (config: NodeTypes[key]) => InputOutput; +}; + +function transformComponent( + config: + | FuncDeclComponent + | FuncExpComponent + | ArrowFuncComponent + | ObjMethodComponent +): string { + const { type, body } = config; + const addReturn = type === "ArrowComp" && config.return === "expression"; + + return `var _effect = _useSignals(); + try { + ${addReturn ? "return " : ""}${body} + } finally { + _effect.f(); + }`; +} + +function generateParams(count?: ParamsConfig): string { + if (count == null || count === 0) return ""; + if (count === 1) return "props"; + if (count === 2) return "props, ref"; + return Array.from({ length: count }, (_, i) => `arg${i}`).join(", "); +} + +function generateComment(comment?: CommentKind): string { + if (comment === "opt-out") return "/* @noTrackSignals */\n"; + if (comment === "opt-in") return "/* @trackSignals */\n"; + return ""; +} + +const codeGenerators: Generators = { + FuncDeclComp(config) { + const params = generateParams(config.params); + const inputBody = config.body; + const outputBody = transformComponent(config); + let comment = generateComment(config.comment); + return { + input: `${comment}function ${config.name}(${params}) {\n${inputBody}\n}`, + transformed: `${comment}function ${config.name}(${params}) {\n${outputBody}\n}`, + }; + }, + FuncExpComp(config) { + const name = config.name ?? ""; + const params = generateParams(config.params); + const inputBody = config.body; + const outputBody = transformComponent(config); + return { + input: `(function ${name}(${params}) {\n${inputBody}\n})`, + transformed: `(function ${name}(${params}) {\n${outputBody}\n})`, + }; + }, + ArrowComp(config) { + const params = generateParams(config.params); + const isExpBody = config.return === "expression"; + const inputBody = isExpBody ? config.body : `{\n${config.body}\n}`; + const outputBody = transformComponent(config); + return { + input: `(${params}) => ${inputBody}`, + transformed: `(${params}) => {\n${outputBody}\n}`, + }; + }, + ObjectMethodComp(config) { + const params = generateParams(config.params); + const inputBody = config.body; + const outputBody = transformComponent(config); + const comment = generateComment(config.comment); + return { + input: `var o = {\n${comment}${config.name}(${params}) {\n${inputBody}\n}\n};`, + transformed: `var o = {\n${comment}${config.name}(${params}) {\n${outputBody}\n}\n};`, + }; + }, + CallExp(config) { + return { + input: `${config.name}(${config.args.map(arg => arg.input).join(", ")})`, + transformed: `${config.name}(${config.args + .map(arg => arg.transformed) + .join(", ")})`, + }; + }, + Variable(config) { + const kind = config.kind ?? "const"; + const comment = generateComment(config.comment); + const inlineComment = generateComment(config.inlineComment)?.trim(); + return { + input: `${comment}${kind} ${config.name} = ${inlineComment}${config.body.input}`, + transformed: `${comment}${kind} ${config.name} = ${inlineComment}${config.body.transformed}`, + }; + }, + Assignment(config) { + const kind = config.kind ?? "let"; + const comment = generateComment(config.comment); + return { + input: `${kind} ${config.name};\n ${comment}${config.name} = ${config.body.input}`, + transformed: `${kind} ${config.name};\n ${comment}${config.name} = ${config.body.transformed}`, + }; + }, + MemberExpAssign(config) { + const comment = generateComment(config.comment); + const isComputed = config.property.startsWith("["); + const property = isComputed ? config.property : `.${config.property}`; + return { + input: `${comment}obj.prop1${property} = ${config.body.input}`, + transformed: `${comment}obj.prop1${property} = ${config.body.transformed}`, + }; + }, + ObjectProperty(config) { + const comment = generateComment(config.comment); + return { + input: `var o = {\n ${comment}${config.name}: ${config.body.input} \n}`, + transformed: `var o = {\n ${comment}${config.name}: ${config.body.transformed} \n}`, + }; + }, + ExportDefault(config) { + const comment = generateComment(config.comment); + return { + input: `${comment}export default ${config.body.input}`, + transformed: `${comment}export default ${config.body.transformed}`, + }; + }, + ExportNamed(config) { + const comment = generateComment(config.comment); + return { + input: `${comment}export ${config.body.input}`, + transformed: `${comment}export ${config.body.transformed}`, + }; + }, +}; + +function generateCode(config: Node): InputOutput { + return codeGenerators[config.type](config as any); +} + +export interface GeneratedCode extends InputOutput { + name: string; +} + +interface CodeConfig { + /** Whether to output source code that auto should transform */ + auto: boolean; + /** What kind of opt-in or opt-out to include if any */ + comment?: CommentKind; + /** Name of the generated code (useful for test case titles) */ + name?: string; + /** Number of parameters the component function should have */ + params?: ParamsConfig; +} + +interface VariableCodeConfig extends CodeConfig { + inlineComment?: CommentKind; +} + +const codeTitle = (...parts: Array) => + parts.filter(Boolean).join(" "); + +function expressionComponents(config: CodeConfig): GeneratedCode[] { + const { name: baseName, params } = config; + if (config.auto) { + return [ + { + name: codeTitle(baseName, "as function without inline name"), + ...generateCode({ + type: "FuncExpComp", + body: "return
{signal.value}
", + params, + }), + }, + { + name: codeTitle(baseName, "as function with proper inline name"), + ...generateCode({ + type: "FuncExpComp", + name: "App", + body: "return
{signal.value}
", + params, + }), + }, + { + name: codeTitle(baseName, "as arrow function with statement body"), + ...generateCode({ + type: "ArrowComp", + return: "statement", + body: "return
{signal.value}
", + params, + }), + }, + { + name: codeTitle(baseName, "as arrow function with expression body"), + ...generateCode({ + type: "ArrowComp", + return: "expression", + body: "
{signal.value}
", + params, + }), + }, + ]; + } else { + return [ + { + name: codeTitle(baseName, "as function with bad inline name"), + ...generateCode({ + type: "FuncExpComp", + name: "app", + body: "return signal.value", + params, + }), + }, + { + name: codeTitle(baseName, "as function with no JSX"), + ...generateCode({ + type: "FuncExpComp", + body: "return signal.value", + params, + }), + }, + { + name: codeTitle(baseName, "as function with no signals"), + ...generateCode({ + type: "FuncExpComp", + body: "return
Hello World
", + params, + }), + }, + { + name: codeTitle(baseName, "as arrow function with no JSX"), + ...generateCode({ + type: "ArrowComp", + return: "expression", + body: "signal.value", + params, + }), + }, + { + name: codeTitle(baseName, "as arrow function with no signals"), + ...generateCode({ + type: "ArrowComp", + return: "expression", + body: "
Hello World
", + params, + }), + }, + ]; + } +} + +function withCallExpWrappers(config: CodeConfig): GeneratedCode[] { + const codeCases: GeneratedCode[] = []; + + // Simulate a component wrapped memo + const memoedComponents = expressionComponents({ ...config, params: 1 }); + for (let component of memoedComponents) { + codeCases.push({ + name: component.name + " wrapped in memo", + ...generateCode({ + type: "CallExp", + name: "memo", + args: [component], + }), + }); + } + + // Simulate a component wrapped in forwardRef + const forwardRefComponents = expressionComponents({ ...config, params: 2 }); + for (let component of forwardRefComponents) { + codeCases.push({ + name: component.name + " wrapped in forwardRef", + ...generateCode({ + type: "CallExp", + name: "forwardRef", + args: [component], + }), + }); + } + + //Simulate components wrapped in both memo and forwardRef + for (let component of forwardRefComponents) { + codeCases.push({ + name: component.name + " wrapped in memo and forwardRef", + ...generateCode({ + type: "CallExp", + name: "memo", + args: [ + generateCode({ + type: "CallExp", + name: "forwardRef", + args: [component], + }), + ], + }), + }); + } + + return codeCases; +} + +export function declarationComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, params, comment } = config; + if (config.auto) { + return [ + { + name: codeTitle(baseName, "with proper name, jsx, and signal usage"), + ...generateCode({ + type: "FuncDeclComp", + name: "App", + body: "return <>{signal.value}", + params, + comment, + }), + }, + ]; + } else { + return [ + { + name: codeTitle(baseName, "with bad name"), + ...generateCode({ + type: "FuncDeclComp", + name: "app", + body: "return
{signal.value}
", + params, + comment, + }), + }, + { + name: codeTitle(baseName, "with no JSX"), + ...generateCode({ + type: "FuncDeclComp", + name: "App", + body: "return signal.value", + params, + comment, + }), + }, + { + name: codeTitle(baseName, "with no signals"), + ...generateCode({ + type: "FuncDeclComp", + name: "App", + body: "return
Hello World
", + params, + comment, + }), + }, + ]; + } +} + +export function objMethodComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, params, comment } = config; + if (config.auto) { + return [ + { + name: codeTitle(baseName, "with proper name, jsx, and signal usage"), + ...generateCode({ + type: "ObjectMethodComp", + name: "App", + body: "return <>{signal.value}", + params, + comment, + }), + }, + { + name: codeTitle( + baseName, + "with computed literal name, jsx, and signal usage" + ), + ...generateCode({ + type: "ObjectMethodComp", + name: "['App']", + body: "return <>{signal.value}", + params, + comment, + }), + }, + ]; + } else { + return [ + { + name: codeTitle(baseName, "with bad name"), + ...generateCode({ + type: "ObjectMethodComp", + name: "app", + body: "return
{signal.value}
", + params, + comment, + }), + }, + { + name: codeTitle(baseName, "with dynamic name"), + ...generateCode({ + type: "ObjectMethodComp", + name: "['App' + '1']", + body: "return
{signal.value}
", + params, + comment, + }), + }, + { + name: codeTitle(baseName, "with no JSX"), + ...generateCode({ + type: "ObjectMethodComp", + name: "App", + body: "return signal.value", + params, + comment, + }), + }, + { + name: codeTitle(baseName, "with no signals"), + ...generateCode({ + type: "ObjectMethodComp", + name: "App", + body: "return
Hello World
", + params, + comment, + }), + }, + ]; + } +} + +export function variableComp(config: VariableCodeConfig): GeneratedCode[] { + const { name: baseName, comment, inlineComment } = config; + const codeCases: GeneratedCode[] = []; + + const components = expressionComponents(config); + for (const c of components) { + codeCases.push({ + name: codeTitle(c.name), + ...generateCode({ + type: "Variable", + name: "VarComp", + body: c, + comment, + inlineComment, + }), + }); + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(baseName, `as function with bad variable name`), + ...generateCode({ + type: "Variable", + name: "render", + comment, + inlineComment, + body: generateCode({ + type: "FuncExpComp", + body: "return
{signal.value}
", + }), + }), + }); + + codeCases.push({ + name: codeTitle(baseName, `as arrow function with bad variable name`), + ...generateCode({ + type: "Variable", + name: "render", + comment, + inlineComment, + body: generateCode({ + type: "ArrowComp", + return: "expression", + body: "
{signal.value}
", + }), + }), + }); + } + + // With HoC wrappers, we are testing the logic to find the component name. So + // only generate tests where the function body is correct ("auto" is true) and + // the name is either correct or bad. + const hocComponents = withCallExpWrappers({ + ...config, + auto: true, + }); + const suffix = config.auto ? "" : "with bad variable name"; + for (const c of hocComponents) { + codeCases.push({ + name: codeTitle(c.name, suffix), + ...generateCode({ + type: "Variable", + name: config.auto ? "VarComp" : "render", + body: c, + comment, + inlineComment, + }), + }); + } + + return codeCases; +} + +export function assignmentComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, comment } = config; + const codeCases: GeneratedCode[] = []; + + const components = expressionComponents(config); + for (const c of components) { + codeCases.push({ + name: codeTitle(c.name), + ...generateCode({ + type: "Assignment", + name: "AssignComp", + body: c, + comment, + }), + }); + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(baseName, "function component with bad variable name"), + ...generateCode({ + type: "Assignment", + name: "render", + comment, + body: generateCode({ + type: "FuncExpComp", + body: "return
{signal.value}
", + }), + }), + }); + + codeCases.push({ + name: codeTitle(baseName, "arrow function with bad variable name"), + ...generateCode({ + type: "Assignment", + name: "render", + comment, + body: generateCode({ + type: "ArrowComp", + return: "expression", + body: "
{signal.value}
", + }), + }), + }); + } + + // With HoC wrappers, we are testing the logic to find the component name. So + // only generate tests where the function body is correct ("auto" is true) and + // the name is either correct or bad. + const hocComponents = withCallExpWrappers({ + ...config, + auto: true, + }); + const suffix = config.auto ? "" : "with bad variable name"; + for (const c of hocComponents) { + codeCases.push({ + name: codeTitle(c.name, suffix), + ...generateCode({ + type: "Assignment", + name: config.auto ? "AssignComp" : "render", + body: c, + comment, + }), + }); + } + + return codeCases; +} + +export function objAssignComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, comment } = config; + const codeCases: GeneratedCode[] = []; + + const components = expressionComponents(config); + for (const c of components) { + codeCases.push({ + name: codeTitle(c.name), + ...generateCode({ + type: "MemberExpAssign", + property: "Comp", + body: c, + comment, + }), + }); + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(baseName, "function component with bad property name"), + ...generateCode({ + type: "MemberExpAssign", + property: "render", + comment, + body: generateCode({ + type: "FuncExpComp", + body: "return
{signal.value}
", + }), + }), + }); + + codeCases.push({ + name: codeTitle(baseName, "arrow function with bad property name"), + ...generateCode({ + type: "MemberExpAssign", + property: "render", + comment, + body: generateCode({ + type: "ArrowComp", + return: "expression", + body: "
{signal.value}
", + }), + }), + }); + + codeCases.push({ + name: codeTitle( + baseName, + "function component with bad computed property name" + ), + ...generateCode({ + type: "MemberExpAssign", + property: "['render']", + body: generateCode({ + type: "FuncExpComp", + body: "return
{signal.value}
", + }), + comment, + }), + }); + + codeCases.push({ + name: codeTitle( + baseName, + "function component with dynamic computed property name" + ), + ...generateCode({ + type: "MemberExpAssign", + property: "['Comp' + '1']", + body: generateCode({ + type: "FuncExpComp", + body: "return
{signal.value}
", + }), + comment, + }), + }); + } else { + codeCases.push({ + name: codeTitle( + baseName, + "function component with computed property name" + ), + ...generateCode({ + type: "MemberExpAssign", + property: "['Comp']", + body: generateCode({ + type: "FuncExpComp", + body: "return
{signal.value}
", + }), + comment, + }), + }); + } + + // With HoC wrappers, we are testing the logic to find the component name. So + // only generate tests where the function body is correct ("auto" is true) and + // the name is either correct or bad. + const hocComponents = withCallExpWrappers({ + ...config, + auto: true, + }); + const suffix = config.auto ? "" : "with bad variable name"; + for (const c of hocComponents) { + codeCases.push({ + name: codeTitle(c.name, suffix), + ...generateCode({ + type: "MemberExpAssign", + property: config.auto ? "Comp" : "render", + body: c, + comment, + }), + }); + } + + return codeCases; +} + +export function objectPropertyComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, comment } = config; + const codeCases: GeneratedCode[] = []; + + const components = expressionComponents(config); + for (const c of components) { + codeCases.push({ + name: c.name, + ...generateCode({ + type: "ObjectProperty", + name: "ObjComp", + body: c, + comment, + }), + }); + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(baseName, "function component with bad property name"), + ...generateCode({ + type: "ObjectProperty", + name: "render_prop", + comment, + body: generateCode({ + type: "FuncExpComp", + body: "return
{signal.value}
", + }), + }), + }); + + codeCases.push({ + name: codeTitle(baseName, "arrow function with bad property name"), + ...generateCode({ + type: "ObjectProperty", + name: "render_prop", + comment, + body: generateCode({ + type: "ArrowComp", + return: "expression", + body: "
{signal.value}
", + }), + }), + }); + } + + // With HoC wrappers, we are testing the logic to find the component name. So + // only generate tests where the function body is correct ("auto" is true) and + // the name is either correct or bad. + const hocComponents = withCallExpWrappers({ + ...config, + auto: true, + }); + const suffix = config.auto ? "" : "with bad property name"; + for (const c of hocComponents) { + codeCases.push({ + name: codeTitle(c.name, suffix), + ...generateCode({ + type: "ObjectProperty", + name: config.auto ? "ObjComp" : "render_prop", + body: c, + comment, + }), + }); + } + + return codeCases; +} + +export function exportDefaultComp(config: CodeConfig): GeneratedCode[] { + const { comment } = config; + const codeCases: GeneratedCode[] = []; + + const components = [ + ...declarationComp({ ...config, comment: undefined }), + ...expressionComponents(config), + ...withCallExpWrappers(config), + ]; + + for (const c of components) { + codeCases.push({ + name: c.name + " exported as default", + ...generateCode({ + type: "ExportDefault", + body: c, + comment, + }), + }); + } + + return codeCases; +} + +export function exportNamedComp(config: CodeConfig): GeneratedCode[] { + const { comment } = config; + const codeCases: GeneratedCode[] = []; + + // `declarationComp` will put the comment on the function declaration, but in + // this case we want to put it on the export statement. + const funcComponents = declarationComp({ ...config, comment: undefined }); + for (const c of funcComponents) { + codeCases.push({ + name: `function declaration ${c.name}`, + ...generateCode({ + type: "ExportNamed", + body: c, + comment, + }), + }); + } + + // `variableComp` will put the comment on the function declaration, but in + // this case we want to put it on the export statement. + const varComponents = variableComp({ ...config, comment: undefined }); + for (const c of varComponents) { + const name = c.name.replace(" variable ", " exported "); + codeCases.push({ + name: `variable ${name}`, + ...generateCode({ + type: "ExportNamed", + body: c, + comment, + }), + }); + } + + return codeCases; +} + +// Command to use to debug the generated code +// ../../../../node_modules/.bin/tsc --target es2020 --module es2020 --moduleResolution node --esModuleInterop --outDir . helpers.ts; mv helpers.js helpers.mjs; node helpers.mjs +/* eslint-disable no-console */ +// @ts-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function debug() { + // @ts-ignore + const prettier = await import("prettier"); + const format = (code: string) => prettier.format(code, { parser: "babel" }); + console.log("generating..."); + console.time("generated"); + const codeCases: GeneratedCode[] = [ + // ...declarationComponents({ name: "transforms a", auto: true }), + // ...declarationComponents({ name: "does not transform a", auto: false }), + // + // ...expressionComponents({ name: "transforms a", auto: true }), + // ...expressionComponents({ name: "does not transform a", auto: false }), + // + // ...withCallExpWrappers({ name: "transforms a", auto: true }), + // ...withCallExpWrappers({ name: "does not transform a", auto: false }), + // + ...variableComp({ name: "transforms a", auto: true }), + ...variableComp({ name: "does not transform a", auto: false }), + + ...assignmentComp({ name: "transforms a", auto: true }), + ...assignmentComp({ name: "does not transform a", auto: false }), + + ...objectPropertyComp({ name: "transforms a", auto: true }), + ...objectPropertyComp({ name: "does not transform a", auto: false }), + + ...exportDefaultComp({ name: "transforms a", auto: true }), + ...exportDefaultComp({ name: "does not transform a", auto: false }), + + ...exportNamedComp({ name: "transforms a", auto: true }), + ...exportNamedComp({ name: "does not transform a", auto: false }), + ]; + console.timeEnd("generated"); + + for (const code of codeCases) { + console.log("=".repeat(80)); + console.log(code.name); + console.log("input:"); + console.log(await format(code.input)); + console.log("transformed:"); + console.log(await format(code.transformed)); + console.log(); + } +} + +// debug(); diff --git a/packages/react-transform/test/node/index.test.tsx b/packages/react-transform/test/node/index.test.tsx index eae7cd995..f7a522438 100644 --- a/packages/react-transform/test/node/index.test.tsx +++ b/packages/react-transform/test/node/index.test.tsx @@ -1,46 +1,40 @@ import { transform, traverse } from "@babel/core"; import type { Visitor } from "@babel/core"; import type { Scope } from "@babel/traverse"; +import prettier from "prettier"; import signalsTransform, { PluginOptions } from "../../src/index"; - -function dedent(str: string) { - let result = str; - - const lines = str.split("\n"); - let minIndent: number = Number.MAX_SAFE_INTEGER; - lines.forEach(function (l) { - const m = l.match(/^(\s+)\S+/); - if (m) { - const indent = m[1].length; - if (!minIndent) { - // this is the first indented line - minIndent = indent; - } else { - minIndent = Math.min(minIndent, indent); - } - } - }); - - if (minIndent !== null) { - result = lines - .map(function (l) { - return l[0] === " " || l[0] === "\t" ? l.slice(minIndent) : l; - }) - .join("\n"); - } - - return result.trim(); -} - -const toSpaces = (str: string) => str.replace(/\t/g, " "); - -function transformCode(code: string, options?: PluginOptions) { +import { + CommentKind, + GeneratedCode, + assignmentComp, + objAssignComp, + declarationComp, + exportDefaultComp, + exportNamedComp, + objectPropertyComp, + variableComp, + objMethodComp, +} from "./helpers"; + +// To help interactively debug a specific test case, add the test ids of the +// test cases you want to debug to the `debugTestIds` array, e.g. (["258", +// "259"]). Set to true to debug all tests. +const DEBUG_TEST_IDS: string[] | true = true; + +const format = (code: string) => prettier.format(code, { parser: "babel" }); + +function transformCode( + code: string, + options?: PluginOptions, + filename?: string +) { const signalsPluginConfig: any[] = [signalsTransform]; if (options) { signalsPluginConfig.push(options); } const result = transform(code, { + filename, plugins: [signalsPluginConfig, "@babel/plugin-syntax-jsx"], }); @@ -50,106 +44,249 @@ function transformCode(code: string, options?: PluginOptions) { function runTest( input: string, expected: string, - options: PluginOptions = { mode: "auto" } + options: PluginOptions = { mode: "auto" }, + filename?: string ) { - const output = transformCode(input, options); - expect(toSpaces(output)).to.equal(toSpaces(dedent(expected))); + const output = transformCode(input, options, filename); + expect(format(output)).to.equal(format(expected)); } -describe("React Signals Babel Transform", () => { - describe("auto mode transformations", () => { - it("wraps arrow function component with return statement in try/finally", () => { - const inputCode = ` - const MyComponent = () => { - signal.value; - return
Hello World
; - }; - `; +interface TestCaseConfig { + /** Whether to use components whose body contains valid code auto mode would transform (true) or not (false) */ + useValidAutoMode: boolean; + /** Whether to assert that the plugin transforms the code (true) or not (false) */ + expectTransformed: boolean; + /** What kind of opt-in or opt-out to include if any */ + comment?: CommentKind; + /** Options to pass to the babel plugin */ + options: PluginOptions; +} - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - const MyComponent = () => { - var _effect = _useSignals(); - try { - signal.value; - return
Hello World
; - } finally { - _effect.f(); - } - }; - `; +let testCount = 0; +const getTestId = () => (testCount++).toString().padStart(3, "0"); + +function runTestCases(config: TestCaseConfig, testCases: GeneratedCode[]) { + testCases = testCases + .map(t => ({ + ...t, + input: format(t.input), + transformed: format(t.transformed), + })) + .sort((a, b) => (a.name < b.name ? -1 : 1)); + + for (const testCase of testCases) { + let testId = getTestId(); + + // Only run tests in debugTestIds + if ( + Array.isArray(DEBUG_TEST_IDS) && + DEBUG_TEST_IDS.length > 0 && + !DEBUG_TEST_IDS.includes(testId) + ) { + continue; + } - runTest(inputCode, expectedOutput); + it(`(${testId}) ${testCase.name}`, () => { + if (DEBUG_TEST_IDS === true || DEBUG_TEST_IDS.includes(testId)) { + console.log("input :", testCase.input.replace(/\s+/g, " ")); // eslint-disable-line no-console + debugger; // eslint-disable-line no-debugger + } + + const input = testCase.input; + let expected = ""; + if (config.expectTransformed) { + expected += + 'import { useSignals as _useSignals } from "@preact/signals-react/runtime";\n'; + expected += testCase.transformed; + } else { + expected = input; + } + + const filename = config.useValidAutoMode + ? "/path/to/Component.js" + : "C:\\path\\to\\lowercase.js"; + + runTest(input, expected, config.options, filename); }); + } +} - it("wraps arrow function component with inline return in try/finally", () => { - const inputCode = ` - const MyComponent = () =>
{name.value}
; - `; +function runGeneratedTestCases(config: TestCaseConfig) { + const codeConfig = { auto: config.useValidAutoMode, comment: config.comment }; - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - const MyComponent = () => { - var _effect = _useSignals(); - try { - return
{name.value}
; - } finally { - _effect.f(); - } - }; - `; + // e.g. function C() {} + describe("function components", () => { + runTestCases(config, declarationComp(codeConfig)); + }); - runTest(inputCode, expectedOutput); + // e.g. const C = () => {}; + describe("variable declared components", () => { + runTestCases(config, variableComp(codeConfig)); + }); + + if (config.comment !== undefined) { + // e.g. const C = () => {}; + describe("variable declared components (inline comment)", () => { + runTestCases( + config, + variableComp({ + ...codeConfig, + comment: undefined, + inlineComment: config.comment, + }) + ); + }); + } + + describe("object method components", () => { + runTestCases(config, objMethodComp(codeConfig)); + }); + + // e.g. C = () => {}; + describe("assigned to variable components", () => { + runTestCases(config, assignmentComp(codeConfig)); + }); + + // e.g. obj.C = () => {}; + describe("assigned to object property components", () => { + runTestCases(config, objAssignComp(codeConfig)); + }); + + // e.g. const obj = { C: () => {} }; + if (config.comment !== undefined) { + describe("object property components", () => { + runTestCases(config, objectPropertyComp(codeConfig)); + }); + } + + // e.g. export default () => {}; + describe(`default exported components`, () => { + runTestCases(config, exportDefaultComp(codeConfig)); + }); + + // e.g. export function C() {} + describe("named exported components", () => { + runTestCases(config, exportNamedComp(codeConfig)); + }); +} + +describe("React Signals Babel Transform", () => { + describe("auto mode transforms", () => { + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: true, + options: { mode: "auto" }, }); + }); - it("wraps function declaration components with try/finally", () => { + describe("auto mode doesn't transform", () => { + it("useEffect callbacks that use signals", () => { const inputCode = ` - function MyComponent() { - signal.value; + function App() { + useEffect(() => { + signal.value = Hi; + }, []); return
Hello World
; } `; - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + const expectedOutput = inputCode; + runTest(inputCode, expectedOutput); + }); + + runGeneratedTestCases({ + useValidAutoMode: false, + expectTransformed: false, + options: { mode: "auto" }, + }); + }); + + describe("auto mode supports opting out of transforming", () => { + it("opt-out comment overrides opt-in comment", () => { + const inputCode = ` + /** + * @noTrackSignals + * @trackSignals + */ function MyComponent() { - var _effect = _useSignals(); - try { - signal.value; - return
Hello World
; - } finally { - _effect.f(); - } - } + return
{signal.value}
; + }; `; - runTest(inputCode, expectedOutput); + const expectedOutput = inputCode; + + runTest(inputCode, expectedOutput, { mode: "auto" }); + }); + + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: false, + comment: "opt-out", + options: { mode: "auto" }, }); + }); + + describe("auto mode supports opting into transformation", () => { + runGeneratedTestCases({ + useValidAutoMode: false, + expectTransformed: true, + comment: "opt-in", + options: { mode: "auto" }, + }); + }); - it("wraps component function expressions with try/finally", () => { + describe("manual mode doesn't transform anything by default", () => { + it("useEffect callbacks that use signals", () => { const inputCode = ` - const MyComponent = function () { - signal.value; + function App() { + useEffect(() => { + signal.value = Hi; + }, []); return
Hello World
; } `; - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - const MyComponent = function () { - var _effect = _useSignals(); - try { - signal.value; - return
Hello World
; - } finally { - _effect.f(); - } + const expectedOutput = inputCode; + runTest(inputCode, expectedOutput); + }); + + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: false, + options: { mode: "manual" }, + }); + }); + + describe("manual mode opts into transforming", () => { + it("opt-out comment overrides opt-in comment", () => { + const inputCode = ` + /** + * @noTrackSignals + * @trackSignals + */ + function MyComponent() { + return
{signal.value}
; }; `; - runTest(inputCode, expectedOutput); + const expectedOutput = inputCode; + + runTest(inputCode, expectedOutput, { mode: "auto" }); + }); + + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: true, + comment: "opt-in", + options: { mode: "manual" }, }); + }); +}); + +// TODO: migrate hook tests +describe("React Signals Babel Transform", () => { + describe("auto mode transformations", () => { it("transforms custom hook arrow functions with return statement", () => { const inputCode = ` const useCustomHook = () => { @@ -222,401 +359,99 @@ describe("React Signals Babel Transform", () => { }); describe("manual mode opt-in transformations", () => { - it("transforms arrow function component with leading opt-in JSDoc comment before variable declaration", () => { + it("transforms custom hook arrow function with leading opt-in JSDoc comment before variable declaration", () => { const inputCode = ` /** @trackSignals */ - const MyComponent = () => { - return
Hello World
; + const useCustomHook = () => { + return useState(0); }; `; const expectedOutput = ` import { useSignals as _useSignals } from "@preact/signals-react/runtime"; /** @trackSignals */ - const MyComponent = () => { - var _effect = _useSignals(); - try { - return
Hello World
; - } finally { - _effect.f(); - } + const useCustomHook = () => { + _useSignals(); + return useState(0); }; `; runTest(inputCode, expectedOutput, { mode: "manual" }); }); - it("transforms arrow function component with leading opt-in JSDoc comment before arrow function", () => { + it("transforms custom hook exported as default function declaration with leading opt-in JSDoc comment", () => { const inputCode = ` - const MyComponent = /** @trackSignals */() => { - return
Hello World
; - }; + /** @trackSignals */ + export default function useCustomHook() { + return useState(0); + } `; const expectedOutput = ` import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - const MyComponent = /** @trackSignals */() => { - var _effect = _useSignals(); - try { - return
Hello World
; - } finally { - _effect.f(); - } - }; + /** @trackSignals */ + export default function useCustomHook() { + _useSignals(); + return useState(0); + } `; runTest(inputCode, expectedOutput, { mode: "manual" }); }); - it("transforms component function declarations with leading opt-in JSDoc comment", () => { + it("transforms custom hooks exported as named function declaration with leading opt-in JSDoc comment", () => { const inputCode = ` /** @trackSignals */ - function MyComponent() { - return
Hello World
; + export function useCustomHook() { + return useState(0); } `; const expectedOutput = ` import { useSignals as _useSignals } from "@preact/signals-react/runtime"; /** @trackSignals */ - function MyComponent() { - var _effect = _useSignals(); - try { - return
Hello World
; - } finally { - _effect.f(); - } + export function useCustomHook() { + _useSignals(); + return useState(0); } `; runTest(inputCode, expectedOutput, { mode: "manual" }); }); + }); - it("transforms default exported function declaration components with leading opt-in JSDoc comment", () => { + describe("auto mode opt-out transformations", () => { + it("skips transforming custom hook arrow function with leading opt-out JSDoc comment before variable declaration", () => { const inputCode = ` - /** @trackSignals */ - export default function MyComponent() { - return
Hello World
; - } + /** @noTrackSignals */ + const useCustomHook = () => { + return useState(0); + }; `; - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - /** @trackSignals */ - export default function MyComponent() { - var _effect = _useSignals(); - try { - return
Hello World
; - } finally { - _effect.f(); - } - } - `; + const expectedOutput = inputCode; - runTest(inputCode, expectedOutput, { mode: "manual" }); + runTest(inputCode, expectedOutput, { mode: "auto" }); }); - it("transforms default exported arrow function expression component with leading opt-in JSDoc comment", () => { + it("skips transforming custom hooks exported as default function declaration with leading opt-out JSDoc comment", () => { const inputCode = ` - /** @trackSignals */ - export default () => { - return
Hello World
; + /** @noTrackSignals */ + export default function useCustomHook() { + return useState(0); } `; - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - /** @trackSignals */ - export default (() => { - var _effect = _useSignals(); - try { - return
Hello World
; - } finally { - _effect.f(); - } - }); - `; + const expectedOutput = inputCode; - runTest(inputCode, expectedOutput, { mode: "manual" }); + runTest(inputCode, expectedOutput, { mode: "auto" }); }); - it("transforms named exported function declaration components with leading opt-in JSDoc comment", () => { + it("skips transforming custom hooks exported as named function declaration with leading opt-out JSDoc comment", () => { const inputCode = ` - /** @trackSignals */ - export function MyComponent() { - return
Hello World
; - } - `; - - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - /** @trackSignals */ - export function MyComponent() { - var _effect = _useSignals(); - try { - return
Hello World
; - } finally { - _effect.f(); - } - } - `; - - runTest(inputCode, expectedOutput, { mode: "manual" }); - }); - - it("transforms named exported variable declaration components (arrow functions) with leading opt-in JSDoc comment", () => { - const inputCode = ` - /** @trackSignals */ - export const MyComponent = () => { - return
Hello World
; - }; - `; - - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - /** @trackSignals */ - export const MyComponent = () => { - var _effect = _useSignals(); - try { - return
Hello World
; - } finally { - _effect.f(); - } - }; - `; - - runTest(inputCode, expectedOutput, { mode: "manual" }); - }); - - it("transforms named exported variable declaration components (function expression) with leading opt-in JSDoc comment", () => { - const inputCode = ` - /** @trackSignals */ - export const MyComponent = function () { - return
Hello World
; - }; - `; - - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - /** @trackSignals */ - export const MyComponent = function () { - var _effect = _useSignals(); - try { - return
Hello World
; - } finally { - _effect.f(); - } - }; - `; - - runTest(inputCode, expectedOutput, { mode: "manual" }); - }); - - it("transforms arrow function custom hook with leading opt-in JSDoc comment before variable declaration", () => { - const inputCode = ` - /** @trackSignals */ - const useCustomHook = () => { - return useState(0); - }; - `; - - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - /** @trackSignals */ - const useCustomHook = () => { - _useSignals(); - return useState(0); - }; - `; - - runTest(inputCode, expectedOutput, { mode: "manual" }); - }); - - it("transforms default exported function declaration custom hooks with leading opt-in JSDoc comment", () => { - const inputCode = ` - /** @trackSignals */ - export default function useCustomHook() { - return useState(0); - } - `; - - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - /** @trackSignals */ - export default function useCustomHook() { - _useSignals(); - return useState(0); - } - `; - - runTest(inputCode, expectedOutput, { mode: "manual" }); - }); - - it("transforms name exported function declaration custom hooks with leading opt-in JSDoc comment", () => { - const inputCode = ` - /** @trackSignals */ - export function useCustomHook() { - return useState(0); - } - `; - - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - /** @trackSignals */ - export function useCustomHook() { - _useSignals(); - return useState(0); - } - `; - - runTest(inputCode, expectedOutput, { mode: "manual" }); - }); - - it("transforms functions declared as object properties with leading opt-in JSDoc comments", () => { - const inputCode = ` - var obj = { - /** @trackSignals */ - a: () => {}, - /** @trackSignals */ - b: function () {}, - /** @trackSignals */ - c: function c() {}, - }; - `; - - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - var obj = { - /** @trackSignals */ - a: () => { - var _effect = _useSignals(); - try {} finally { - _effect.f(); - } - }, - /** @trackSignals */ - b: function () { - var _effect2 = _useSignals(); - try {} finally { - _effect2.f(); - } - }, - /** @trackSignals */ - c: function c() { - var _effect3 = _useSignals(); - try {} finally { - _effect3.f(); - } - } - }; - `; - - runTest(inputCode, expectedOutput, { mode: "manual" }); - }); - - it("transforms functions assigned to object properties with leading opt-in JSDoc comments", () => { - const inputCode = ` - var obj = {}; - /** @trackSignals */ - obj.a = () => {}; - /** @trackSignals */ - obj.b = function () {}; - /** @trackSignals */ - obj["c"] = function () {}; - `; - - const expectedOutput = ` - import { useSignals as _useSignals } from "@preact/signals-react/runtime"; - var obj = {}; - /** @trackSignals */ - obj.a = () => { - var _effect = _useSignals(); - try {} finally { - _effect.f(); - } - }; - /** @trackSignals */ - obj.b = function () { - var _effect2 = _useSignals(); - try {} finally { - _effect2.f(); - } - }; - /** @trackSignals */ - obj["c"] = function () { - var _effect3 = _useSignals(); - try {} finally { - _effect3.f(); - } - }; - `; - - runTest(inputCode, expectedOutput, { mode: "manual" }); - }); - }); - - describe("auto mode opt-out transformations", () => { - it("opt-out comment overrides opt-in comment", () => { - const inputCode = ` - /** - * @noTrackSignals - * @trackSignals - */ - const MyComponent = () => { - return
{signal.value}
; - }; - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming arrow function component with leading opt-out JSDoc comment before variable declaration", () => { - const inputCode = ` - /** @noTrackSignals */ - const MyComponent = () => { - return
{signal.value}
; - }; - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming arrow function component with leading opt-out JSDoc comment before arrow function", () => { - const inputCode = ` - const MyComponent = /** @noTrackSignals */() => { - return
{signal.value}
; - }; - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { - mode: "auto", - }); - }); - - it("skips transforming function declaration components with leading opt-out JSDoc comment", () => { - const inputCode = ` - /** @noTrackSignals */ - function MyComponent() { - return
{signal.value}
; - } - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming default exported function declaration components with leading opt-out JSDoc comment", () => { - const inputCode = ` - /** @noTrackSignals */ - export default function MyComponent() { - return
{signal.value}
; + /** @noTrackSignals */ + export function useCustomHook() { + return useState(0); } `; @@ -624,180 +459,10 @@ describe("React Signals Babel Transform", () => { runTest(inputCode, expectedOutput, { mode: "auto" }); }); - - it("skips transforming default exported arrow function expression components with leading opt-out JSDoc comment", () => { - const inputCode = ` - /** @noTrackSignals */ - export default (() => { - return
{signal.value}
; - }); - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming named exported function declaration components with leading opt-out JSDoc comment", () => { - const inputCode = ` - /** @noTrackSignals */ - export function MyComponent() { - return
{signal.value}
; - } - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming named exported variable declaration components (arrow functions) with leading opt-out JSDoc comment", () => { - const inputCode = ` - /** @noTrackSignals */ - export const MyComponent = () => { - return
{signal.value}
; - }; - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming named exported variable declaration components (function expression) with leading opt-out JSDoc comment", () => { - const inputCode = ` - /** @noTrackSignals */ - export const MyComponent = function () { - return
{signal.value}
; - }; - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming arrow function custom hook with leading opt-out JSDoc comment before variable declaration", () => { - const inputCode = ` - /** @noTrackSignals */ - const useCustomHook = () => { - return useState(0); - }; - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming default exported function declaration custom hooks with leading opt-out JSDoc comment", () => { - const inputCode = ` - /** @noTrackSignals */ - export default function useCustomHook() { - return useState(0); - } - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming name exported function declaration custom hooks with leading opt-out JSDoc comment", () => { - const inputCode = ` - /** @noTrackSignals */ - export function useCustomHook() { - return useState(0); - } - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming functions declared as object properties with leading opt-out JSDoc comments", () => { - const inputCode = ` - var obj = { - /** @noTrackSignals */ - a: () => {}, - /** @noTrackSignals */ - b: function () {}, - /** @noTrackSignals */ - c: function c() {} - }; - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); - - it("skips transforming functions assigned to object properties with leading opt-out JSDoc comments", () => { - const inputCode = ` - var obj = {}; - /** @noTrackSignals */ - obj.a = () =>
; - /** @noTrackSignals */ - obj.b = function () { - return
; - }; - /** @noTrackSignals */ - obj["c"] = function () { - return
; - }; - `; - - const expectedOutput = inputCode; - - runTest(inputCode, expectedOutput, { mode: "auto" }); - }); }); describe("auto mode no transformations", () => { - it("does not transform arrow function component that does not use signals", () => { - const inputCode = ` - const MyComponent = () => { - return
Hello World
; - }; - `; - - const expectedOutput = inputCode; - runTest(inputCode, expectedOutput); - }); - - it("does not transform arrow function component with inline return that does not use signals", () => { - const inputCode = ` - const MyComponent = () =>
Hello World!
; - `; - - const expectedOutput = inputCode; - runTest(inputCode, expectedOutput); - }); - - it("does not transform function declaration components that don't use signals", () => { - const inputCode = ` - function MyComponent() { - return
Hello World
; - } - `; - - const expectedOutput = inputCode; - runTest(inputCode, expectedOutput); - }); - - it("does not transform function expression components that don't use signals", () => { - const inputCode = ` - const MyComponent = function () { - return
Hello World
; - }; - `; - - const expectedOutput = inputCode; - runTest(inputCode, expectedOutput); - }); - - it("does not transform custom hook function declarations that don't use signals", () => { + it("skips transforming custom hook function declarations that don't use signals", () => { const inputCode = ` function useCustomHook() { return useState(0); @@ -808,7 +473,7 @@ describe("React Signals Babel Transform", () => { runTest(inputCode, expectedOutput); }); - it("does not transform incorrectly named custom hook function declarations", () => { + it("skips transforming custom hook function declarations incorrectly named", () => { const inputCode = ` function usecustomHook() { return signal.value; @@ -818,22 +483,10 @@ describe("React Signals Babel Transform", () => { const expectedOutput = inputCode; runTest(inputCode, expectedOutput); }); - - it("does not transform useEffect callbacks that use signals", () => { - const inputCode = ` - function App() { - useEffect(() => { - signal.value = Hi; - }, []); - return
Hello World
; - } - `; - - const expectedOutput = inputCode; - runTest(inputCode, expectedOutput); - }); }); + // TODO: Figure out what to do with the following + describe("all mode transformations", () => { it("skips transforming arrow function component with leading opt-out JSDoc comment before variable declaration", () => { const inputCode = ` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53b626e09..49e5cd554 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,9 @@ importers: '@types/babel__helper-plugin-utils': specifier: ^7.10.0 version: 7.10.0 + '@types/prettier': + specifier: ^2.7.3 + version: 2.7.3 '@types/react': specifier: ^18.0.18 version: 18.0.18 @@ -301,6 +304,9 @@ importers: path: specifier: ^0.12.7 version: 0.12.7 + prettier: + specifier: ^2.7.1 + version: 2.7.1 react: specifier: ^18.2.0 version: 18.2.0 @@ -2421,6 +2427,10 @@ packages: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: true + /@types/prettier@2.7.3: + resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} + dev: true + /@types/prop-types@15.7.5: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} dev: true