diff --git a/.changeset/rich-jars-change.md b/.changeset/rich-jars-change.md new file mode 100644 index 00000000..1efbabc6 --- /dev/null +++ b/.changeset/rich-jars-change.md @@ -0,0 +1,5 @@ +--- +"@preact-signals/safe-react": minor +--- + +Now can transform commonjs modules diff --git a/package.json b/package.json index e2b1d6b0..86b83613 100644 --- a/package.json +++ b/package.json @@ -22,5 +22,8 @@ "lint": "turbo run lint", "changeset": "changeset", "release": "pnpm build && changeset publish" + }, + "dependencies": { + "prettier": "^3.1.0" } } diff --git a/packages/react/package.json b/packages/react/package.json index e1eac210..e14d5e8a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -97,17 +97,18 @@ "lint": "check-export-map" }, "dependencies": { - "@preact/signals-core": "^1.5.0", - "use-sync-external-store": "^1.2.0", "@babel/helper-module-imports": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.3.4" + "@preact/signals-core": "^1.5.0", + "debug": "^4.3.4", + "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "react": "^16.14.0 || 17.x || 18.x" }, "devDependencies": { + "@babel/traverse": "^7.23.4", "@rollup/plugin-replace": "^5.0.5", "@rollup/plugin-typescript": "^11.1.5", "@types/babel__core": "^7.20.4", diff --git a/packages/react/src/babel.ts b/packages/react/src/babel.ts index 387db0cb..bc90c245 100644 --- a/packages/react/src/babel.ts +++ b/packages/react/src/babel.ts @@ -8,7 +8,7 @@ import { NodePath, template, } from "@babel/core"; -import { isModule, addNamed } from "@babel/helper-module-imports"; +import { isModule, addNamed, addNamespace } from "@babel/helper-module-imports"; import type { VisitNodeObject } from "@babel/traverse"; import debug from "debug"; @@ -69,19 +69,30 @@ function basename(filename: string | undefined): string | undefined { const DefaultExportSymbol = Symbol("DefaultExportSymbol"); +function getObjectPropertyKey( + node: BabelTypes.ObjectProperty | BabelTypes.ObjectMethod +): string | null { + if (node.key.type === "Identifier") { + return node.key.name; + } else if (node.key.type === "StringLiteral") { + return node.key.value; + } + + return null; +} /** * 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) { + if ( + (path.node.type === "FunctionDeclaration" || + path.node.type === "FunctionExpression") && + 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 getObjectPropertyKey(path.node); } return null; @@ -124,6 +135,8 @@ function getFunctionNameFromParent( } else { return null; } + } else if (parentPath.node.type === "ObjectProperty") { + return getObjectPropertyKey(parentPath.node); } else if (parentPath.node.type === "ExportDefaultDeclaration") { return DefaultExportSymbol; } else if ( @@ -152,7 +165,7 @@ function getFunctionName( return getFunctionNameFromParent(path.parentPath); } -function fnNameStartsWithCapital(name: string | null): boolean { +function isComponentName(name: string | null): boolean { return name?.match(/^[A-Z]/) != null ?? false; } @@ -229,7 +242,7 @@ function isComponentFunction( ): boolean { return ( getData(path.scope, containsJSX) === true && // Function contains JSX - fnNameStartsWithCapital(functionName) // Function name indicates it's a component + isComponentName(functionName) // Function name indicates it's a component ); } @@ -328,50 +341,80 @@ function transformFunction( } function createImportLazily( - types: typeof BabelTypes, + t: typeof BabelTypes, pass: PluginPass, path: NodePath, importName: string, source: string -): () => BabelTypes.Identifier { +): () => BabelTypes.Identifier | BabelTypes.MemberExpression { return () => { - if (!isModule(path)) { - throw new Error( - `Cannot import ${importName} outside of an ESM module file` - ); - } - - let reference: BabelTypes.Identifier = get(pass, `imports/${importName}`); - if (reference) return types.cloneNode(reference); - reference = addNamed(path, importName, source, { - importedInterop: "uncompiled", - importPosition: "after", - }); - set(pass, `imports/${importName}`, reference); - - /** Helper function to determine if an import declaration's specifier matches the given importName */ - const matchesImportName = ( - s: BabelTypes.ImportDeclaration["specifiers"][0] - ) => { - if (s.type !== "ImportSpecifier") return false; - return ( - (s.imported.type === "Identifier" && s.imported.name === importName) || - (s.imported.type === "StringLiteral" && s.imported.value === importName) - ); - }; - - for (let statement of path.get("body")) { - if ( - statement.isImportDeclaration() && - statement.node.source.value === source && - statement.node.specifiers.some(matchesImportName) - ) { - path.scope.registerDeclaration(statement); - break; + if (isModule(path)) { + let reference: BabelTypes.Identifier = get(pass, `imports/${importName}`); + if (reference) return t.cloneNode(reference); + reference = addNamed(path, importName, source, { + importedInterop: "uncompiled", + importPosition: "after", + }); + set(pass, `imports/${importName}`, reference); + + const matchesImportName = ( + s: BabelTypes.ImportDeclaration["specifiers"][0] + ) => { + if (s.type !== "ImportSpecifier") return false; + return ( + (s.imported.type === "Identifier" && + s.imported.name === importName) || + (s.imported.type === "StringLiteral" && + s.imported.value === importName) + ); + }; + + for (let statement of path.get("body")) { + if ( + statement.isImportDeclaration() && + statement.node.source.value === source && + statement.node.specifiers.some(matchesImportName) + ) { + path.scope.registerDeclaration(statement); + break; + } } + return reference; + } else { + let reference = get(pass, `requires/${importName}`); + if (reference) { + reference = t.cloneNode(reference); + } else { + reference = addNamespace(path, source, { + importedInterop: "uncompiled", + }); + set(pass, `requires/${importName}`, reference); + } + + return t.memberExpression(reference, t.identifier(importName)); } - return reference; + /** Helper function to determine if an import declaration's specifier matches the given importName */ + // const matchesImportName = ( + // s: BabelTypes.ImportDeclaration["specifiers"][0] + // ) => { + // if (s.type !== "ImportSpecifier") return false; + // return ( + // (s.imported.type === "Identifier" && s.imported.name === importName) || + // (s.imported.type === "StringLiteral" && s.imported.value === importName) + // ); + // }; + + // for (let statement of path.get("body")) { + // if ( + // statement.isImportDeclaration() && + // statement.node.source.value === source && + // statement.node.specifiers.some(matchesImportName) + // ) { + // path.scope.registerDeclaration(statement); + // break; + // } + // } }; } @@ -423,9 +466,7 @@ function isComponentLike( path: NodePath, functionName: string | null ): boolean { - return ( - !getData(path, alreadyTransformed) && fnNameStartsWithCapital(functionName) - ); + return !getData(path, alreadyTransformed) && isComponentName(functionName); } export default function signalsTransform( diff --git a/packages/react/test/babel/helpers.ts b/packages/react/test/babel/helpers.ts new file mode 100644 index 00000000..44e043f8 --- /dev/null +++ b/packages/react/test/babel/helpers.ts @@ -0,0 +1,1030 @@ +/** + * 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, + properInlineName?: boolean +): GeneratedCode[] { + const { name: baseName, params } = config; + + let components: GeneratedCode[]; + if (config.auto) { + components = [ + { + name: codeTitle(baseName, "as function without inline name"), + ...generateCode({ + type: "FuncExpComp", + 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 { + components = [ + { + 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, + }), + }, + ]; + } + + if ( + (properInlineName != null && properInlineName === false) || + config.auto === false + ) { + components.push({ + name: codeTitle(baseName, "as function with bad inline name"), + ...generateCode({ + type: "FuncExpComp", + name: "app", + body: "return
{signal.value}
", + params, + }), + }); + } else { + components.push({ + name: codeTitle(baseName, "as function with proper inline name"), + ...generateCode({ + type: "FuncExpComp", + name: "App", + body: "return
{signal.value}
", + params, + }), + }); + } + + return components; +} + +function withCallExpWrappers( + config: CodeConfig, + properInlineName?: boolean +): GeneratedCode[] { + const codeCases: GeneratedCode[] = []; + + // Simulate a component wrapped memo + const memoedComponents = expressionComponents( + { ...config, params: 1 }, + properInlineName + ); + 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 }, + properInlineName + ); + 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, + }, + config.auto + ); + 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, + }, + config.auto + ); + 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, + }, + config.auto + ); + 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, + }, + config.auto + ); + 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/test/babel/index.test.tsx b/packages/react/test/babel/index.test.tsx new file mode 100644 index 00000000..2f8a8709 --- /dev/null +++ b/packages/react/test/babel/index.test.tsx @@ -0,0 +1,829 @@ +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/babel"; +import { + CommentKind, + GeneratedCode, + assignmentComp, + objAssignComp, + declarationComp, + exportDefaultComp, + exportNamedComp, + objectPropertyComp, + variableComp, + objMethodComp, +} from "./helpers"; +import { expect, it, describe } from "vitest"; + +// 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 = []; + +const format = (code: string) => prettier.format(code, { parser: "babel" }); + +function transformCode( + code: string, + options?: PluginOptions, + filename?: string, + isCJS?: boolean +) { + const signalsPluginConfig: any[] = [signalsTransform]; + if (options) { + signalsPluginConfig.push(options); + } + + const result = transform(code, { + filename, + plugins: [signalsPluginConfig, "@babel/plugin-syntax-jsx"], + sourceType: isCJS ? "script" : "module", + }); + + return result?.code || ""; +} + +async function runTest( + input: string, + expected: string, + options: PluginOptions = { mode: "auto" }, + filename?: string, + isCJS?: boolean +) { + const output = transformCode(input, options, filename, isCJS); + expect(await format(output)).to.equal(await format(expected)); +} + +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; +} + +let testCount = 0; +const getTestId = () => (testCount++).toString().padStart(3, "0"); + +async function runTestCases( + config: TestCaseConfig, + testCases: GeneratedCode[] +) { + testCases = ( + await Promise.all( + testCases.map(async (t) => ({ + ...t, + input: await format(t.input), + transformed: await 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; + } + + it(`(${testId}) ${testCase.name}`, async () => { + 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/safe-react/tracking";\n'; + expected += testCase.transformed; + } else { + expected = input; + } + + const filename = config.useValidAutoMode + ? "/path/to/Component.js" + : "C:\\path\\to\\lowercase.js"; + + await runTest(input, expected, config.options, filename); + }); + } +} + +function runGeneratedTestCases(config: TestCaseConfig) { + const codeConfig = { auto: config.useValidAutoMode, comment: config.comment }; + + // e.g. function C() {} + describe("function components", async () => { + await runTestCases(config, declarationComp(codeConfig)); + }); + + // e.g. const C = () => {}; + describe("variable declared components", async () => { + await runTestCases(config, variableComp(codeConfig)); + }); + + if (config.comment !== undefined) { + // e.g. const C = () => {}; + describe("variable declared components (inline comment)", async () => { + await runTestCases( + config, + variableComp({ + ...codeConfig, + comment: undefined, + inlineComment: config.comment, + }) + ); + }); + } + + describe("object method components", async () => { + await runTestCases(config, objMethodComp(codeConfig)); + }); + + // e.g. C = () => {}; + describe("assigned to variable components", async () => { + await runTestCases(config, assignmentComp(codeConfig)); + }); + + // e.g. obj.C = () => {}; + describe("assigned to object property components", async () => { + await runTestCases(config, objAssignComp(codeConfig)); + }); + + // e.g. const obj = { C: () => {} }; + describe("object property components", async () => { + await runTestCases(config, objectPropertyComp(codeConfig)); + }); + + // e.g. export default () => {}; + describe(`default exported components`, async () => { + await runTestCases(config, exportDefaultComp(codeConfig)); + }); + + // e.g. export function C() {} + describe("named exported components", async () => { + await runTestCases(config, exportNamedComp(codeConfig)); + }); +} + +describe("React Signals Babel Transform", () => { + describe("auto mode transforms", () => { + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: true, + options: { mode: "auto" }, + }); + }); + + describe("auto mode doesn't transform", () => { + it("useEffect callbacks that use signals", async () => { + const inputCode = ` + function App() { + useEffect(() => { + signal.value = Hi; + }, []); + return
Hello World
; + } + `; + + const expectedOutput = inputCode; + await 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", async () => { + const inputCode = ` + /** + * @noTrackSignals + * @trackSignals + */ + function MyComponent() { + return
{signal.value}
; + }; + `; + + const expectedOutput = inputCode; + + await 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" }, + }); + }); + + describe("manual mode doesn't transform anything by default", () => { + it("useEffect callbacks that use signals", async () => { + const inputCode = ` + function App() { + useEffect(() => { + signal.value = Hi; + }, []); + return
Hello World
; + } + `; + + const expectedOutput = inputCode; + await 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", async () => { + const inputCode = ` + /** + * @noTrackSignals + * @trackSignals + */ + function MyComponent() { + return
{signal.value}
; + }; + `; + + const expectedOutput = inputCode; + + await 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", async () => { + // const inputCode = ` + // const useCustomHook = () => { + // return signal.value; + // }; + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // const useCustomHook = () => { + // _useSignals(); + // return signal.value; + // }; + // `; + + // await runTest(inputCode, expectedOutput); + // }); + + // it("transforms custom hook arrow functions with inline return statement", async () => { + // const inputCode = ` + // const useCustomHook = () => name.value; + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // const useCustomHook = () => { + // _useSignals(); + // return name.value; + // }; + // `; + + // await runTest(inputCode, expectedOutput); + // }); + + // it("transforms custom hook function declarations", async () => { + // const inputCode = ` + // function useCustomHook() { + // return signal.value; + // } + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // function useCustomHook() { + // _useSignals(); + // return signal.value; + // } + // `; + + // await runTest(inputCode, expectedOutput); + // }); + + // it("transforms custom hook function expressions", async () => { + // const inputCode = ` + // const useCustomHook = function () { + // return signal.value; + // } + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // const useCustomHook = function () { + // _useSignals(); + // return signal.value; + // }; + // `; + + // await runTest(inputCode, expectedOutput); + // }); + // }); + + // describe("manual mode opt-in transformations", () => { + // it("transforms custom hook arrow function with leading opt-in JSDoc comment before variable declaration", async () => { + // const inputCode = ` + // /** @trackSignals */ + // const useCustomHook = () => { + // return useState(0); + // }; + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // /** @trackSignals */ + // const useCustomHook = () => { + // _useSignals(); + // return useState(0); + // }; + // `; + + // await runTest(inputCode, expectedOutput, { mode: "manual" }); + // }); + + // it("transforms custom hook exported as default function declaration with leading opt-in JSDoc comment", async () => { + // const inputCode = ` + // /** @trackSignals */ + // export default function useCustomHook() { + // return useState(0); + // } + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // /** @trackSignals */ + // export default function useCustomHook() { + // _useSignals(); + // return useState(0); + // } + // `; + + // await runTest(inputCode, expectedOutput, { mode: "manual" }); + // }); + + // it("transforms custom hooks exported as named function declaration with leading opt-in JSDoc comment", async () => { + // const inputCode = ` + // /** @trackSignals */ + // export function useCustomHook() { + // return useState(0); + // } + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // /** @trackSignals */ + // export function useCustomHook() { + // _useSignals(); + // return useState(0); + // } + // `; + + // await runTest(inputCode, expectedOutput, { mode: "manual" }); + // }); + // }); + + // describe("auto mode opt-out transformations", () => { + // it("skips transforming custom hook arrow function with leading opt-out JSDoc comment before variable declaration", async () => { + // const inputCode = ` + // /** @noTrackSignals */ + // const useCustomHook = () => { + // return useState(0); + // }; + // `; + + // const expectedOutput = inputCode; + + // await runTest(inputCode, expectedOutput, { mode: "auto" }); + // }); + + // it("skips transforming custom hooks exported as default function declaration with leading opt-out JSDoc comment", async () => { + // const inputCode = ` + // /** @noTrackSignals */ + // export default function useCustomHook() { + // return useState(0); + // } + // `; + + // const expectedOutput = inputCode; + + // await runTest(inputCode, expectedOutput, { mode: "auto" }); + // }); + + // it("skips transforming custom hooks exported as named function declaration with leading opt-out JSDoc comment", async () => { + // const inputCode = ` + // /** @noTrackSignals */ + // export function useCustomHook() { + // return useState(0); + // } + // `; + + // const expectedOutput = inputCode; + + // await runTest(inputCode, expectedOutput, { mode: "auto" }); + // }); + // }); + + // describe("auto mode no transformations", () => { + // it("skips transforming custom hook function declarations that don't use signals", async () => { + // const inputCode = ` + // function useCustomHook() { + // return useState(0); + // } + // `; + + // const expectedOutput = inputCode; + // await runTest(inputCode, expectedOutput); + // }); + + // it("skips transforming custom hook function declarations incorrectly named", async () => { + // const inputCode = ` + // function usecustomHook() { + // return signal.value; + // } + // `; + + // const expectedOutput = inputCode; + // await 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", async () => { + const inputCode = ` + /** @noTrackSignals */ + const MyComponent = () => { + return
{signal.value}
; + }; + `; + + const expectedOutput = inputCode; + + await runTest(inputCode, expectedOutput, { mode: "all" }); + }); + + it("skips transforming function declaration components with leading opt-out JSDoc comment", async () => { + const inputCode = ` + /** @noTrackSignals */ + function MyComponent() { + return
{signal.value}
; + } + `; + + const expectedOutput = inputCode; + + await runTest(inputCode, expectedOutput, { mode: "all" }); + }); + + it("transforms function declaration component that doesn't use signals", async () => { + const inputCode = ` + function MyComponent() { + return
Hello World
; + } + `; + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + function MyComponent() { + var _effect = _useSignals(); + try { + return
Hello World
; + } finally { + _effect.f(); + } + } + `; + + await runTest(inputCode, expectedOutput, { mode: "all" }); + }); + + it("transforms arrow function component with return statement that doesn't use signals", async () => { + const inputCode = ` + const MyComponent = () => { + return
Hello World
; + }; + `; + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + const MyComponent = () => { + var _effect = _useSignals(); + try { + return
Hello World
; + } finally { + _effect.f(); + } + }; + `; + + await runTest(inputCode, expectedOutput, { mode: "all" }); + }); + + it("transforms function declaration component that uses signals", async () => { + const inputCode = ` + function MyComponent() { + signal.value; + return
Hello World
; + } + `; + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + function MyComponent() { + var _effect = _useSignals(); + try { + signal.value; + return
Hello World
; + } finally { + _effect.f(); + } + } + `; + + await runTest(inputCode, expectedOutput, { mode: "all" }); + }); + + it("transforms arrow function component with return statement that uses signals", async () => { + const inputCode = ` + const MyComponent = () => { + signal.value; + return
Hello World
; + }; + `; + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + const MyComponent = () => { + var _effect = _useSignals(); + try { + signal.value; + return
Hello World
; + } finally { + _effect.f(); + } + }; + `; + + await runTest(inputCode, expectedOutput, { mode: "all" }); + }); + + it("transforms commonjs module exports", async () => { + const inputCode = ` + require('preact'); + const MyComponent = () => { + signal.value; + return
Hello World
; + } + `; + + const expectedOutput = ` + var _preactSignalsSafeReactTracking = require("@preact-signals/safe-react/tracking") + require('preact'); + const MyComponent = () => { + var _effect = _preactSignalsSafeReactTracking.useSignals(); + try { + signal.value; + return
Hello World
; + } finally { + _effect.f(); + } + }; + `; + + await runTest(inputCode, expectedOutput, { mode: "all" }, "", true); + }); + }); + + // describe("noTryFinally option", () => { + // it("prepends arrow function component with useSignals call", async () => { + // const inputCode = ` + // const MyComponent = () => { + // signal.value; + // return
Hello World
; + // }; + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // const MyComponent = () => { + // _useSignals(); + // signal.value; + // return
Hello World
; + // }; + // `; + + // await runTest(inputCode, expectedOutput, { + // experimental: { noTryFinally: true }, + // }); + // }); + + // it("prepends arrow function component with useSignals call", async () => { + // const inputCode = ` + // const MyComponent = () =>
{name.value}
; + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // const MyComponent = () => { + // _useSignals(); + // return
{name.value}
; + // }; + // `; + + // await runTest(inputCode, expectedOutput, { + // experimental: { noTryFinally: true }, + // }); + // }); + + // it("prepends function declaration components with useSignals call", async () => { + // const inputCode = ` + // function MyComponent() { + // signal.value; + // return
Hello World
; + // } + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // function MyComponent() { + // _useSignals(); + // signal.value; + // return
Hello World
; + // } + // `; + + // await runTest(inputCode, expectedOutput, { + // experimental: { noTryFinally: true }, + // }); + // }); + + // it("prepends function expression components with useSignals call", async () => { + // const inputCode = ` + // const MyComponent = function () { + // signal.value; + // return
Hello World
; + // } + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // const MyComponent = function () { + // _useSignals(); + // signal.value; + // return
Hello World
; + // }; + // `; + + // await runTest(inputCode, expectedOutput, { + // experimental: { noTryFinally: true }, + // }); + // }); + + // it("prepends custom hook function declarations with useSignals call", async () => { + // const inputCode = ` + // function useCustomHook() { + // signal.value; + // return useState(0); + // } + // `; + + // const expectedOutput = ` + // import { useSignals as _useSignals } from "@preact-signals/safe-react/tracking"; + // function useCustomHook() { + // _useSignals(); + // signal.value; + // return useState(0); + // } + // `; + + // await runTest(inputCode, expectedOutput, { + // experimental: { noTryFinally: true }, + // }); + // }); + // }); + + describe("importSource option", () => { + it("imports useSignals from custom source", async () => { + const inputCode = ` + const MyComponent = () => { + signal.value; + return
Hello World
; + }; + `; + + const expectedOutput = ` + import { useSignals as _useSignals } from "custom-source"; + const MyComponent = () => { + var _effect = _useSignals(); + try { + signal.value; + return
Hello World
; + } finally { + _effect.f(); + } + }; + `; + + await runTest(inputCode, expectedOutput, { + importSource: "custom-source", + }); + }); + }); + + describe("scope tracking", () => { + interface VisitorState { + programScope?: Scope; + } + + const programScopeVisitor: Visitor = { + Program: { + exit(path, state) { + state.programScope = path.scope; + }, + }, + }; + + function getRootScope(code: string) { + const signalsPluginConfig: any[] = [signalsTransform]; + const result = transform(code, { + ast: true, + plugins: [signalsPluginConfig, "@babel/plugin-syntax-jsx"], + }); + console.log(result?.code); + if (!result) { + throw new Error("Could not transform code"); + } + + const state: VisitorState = {}; + traverse(result.ast, programScopeVisitor, undefined, state); + + const scope = state.programScope; + if (!scope) { + throw new Error("Could not find program scope"); + } + + return scope; + } + + it("adds newly inserted import declarations and usages to program scope", () => { + const scope = getRootScope(` + const MyComponent = () => { + signal.value; + return
Hello World
; + }; + `); + + console.log(scope.bindings); + const signalsBinding = scope.bindings["_useSignals"]; + expect(signalsBinding).to.exist; + expect(signalsBinding.kind).toEqual("module"); + expect(signalsBinding.referenced).toBeTruthy(); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc57b6b..bedd86b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + prettier: + specifier: ^3.1.0 + version: 3.1.0 devDependencies: '@changesets/cli': specifier: ^2.26.2 @@ -169,7 +173,7 @@ importers: dependencies: '@preact-signals/safe-react': specifier: '*' - version: 0.0.3(@babel/core@7.23.3)(react@18.2.0) + version: link:../react '@preact-signals/utils': specifier: workspace:* version: link:../utils @@ -212,7 +216,7 @@ importers: dependencies: '@preact-signals/safe-react': specifier: '*' - version: 0.0.3(@babel/core@7.23.3)(react@18.2.0) + version: link:../react '@preact-signals/unified-signals': specifier: workspace:* version: link:../unified-signals @@ -305,6 +309,9 @@ importers: specifier: ^1.2.0 version: 1.2.0(react@18.2.0) devDependencies: + '@babel/traverse': + specifier: ^7.23.4 + version: 7.23.4 '@rollup/plugin-replace': specifier: ^5.0.5 version: 5.0.5(rollup@3.29.4) @@ -401,7 +408,7 @@ importers: dependencies: '@preact-signals/safe-react': specifier: '*' - version: 0.0.3(@babel/core@7.23.3)(react@18.2.0) + version: link:../react '@preact/signals': specifier: '>=1.1.0' version: 1.1.4(preact@10.19.1) @@ -423,7 +430,7 @@ importers: dependencies: '@preact-signals/safe-react': specifier: '*' - version: 0.0.3(@babel/core@7.23.3)(react@18.2.0) + version: link:../react '@preact-signals/unified-signals': specifier: workspace:* version: link:../unified-signals @@ -520,6 +527,13 @@ packages: '@babel/highlight': 7.22.13 chalk: 2.4.2 + /@babel/code-frame@7.23.4: + resolution: {integrity: sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.23.4 + chalk: 2.4.2 + /@babel/compat-data@7.22.9: resolution: {integrity: sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==} engines: {node: '>=6.9.0'} @@ -536,7 +550,7 @@ packages: '@babel/helpers': 7.23.2 '@babel/parser': 7.23.3 '@babel/template': 7.22.15 - '@babel/traverse': 7.23.3 + '@babel/traverse': 7.23.4 '@babel/types': 7.23.3 convert-source-map: 2.0.0 debug: 4.3.4 @@ -555,6 +569,15 @@ packages: '@jridgewell/trace-mapping': 0.3.18 jsesc: 2.5.2 + /@babel/generator@7.23.4: + resolution: {integrity: sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.18 + jsesc: 2.5.2 + /@babel/helper-annotate-as-pure@7.22.5: resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} engines: {node: '>=6.9.0'} @@ -581,13 +604,13 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.22.15 - '@babel/types': 7.23.3 + '@babel/types': 7.23.4 /@babel/helper-hoist-variables@7.22.5: resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.23.3 + '@babel/types': 7.23.4 /@babel/helper-module-imports@7.22.15: resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==} @@ -622,12 +645,16 @@ packages: resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.23.3 + '@babel/types': 7.23.4 /@babel/helper-string-parser@7.22.5: resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} engines: {node: '>=6.9.0'} + /@babel/helper-string-parser@7.23.4: + resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} + engines: {node: '>=6.9.0'} + /@babel/helper-validator-identifier@7.22.20: resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} @@ -641,7 +668,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.22.15 - '@babel/traverse': 7.23.3 + '@babel/traverse': 7.23.4 '@babel/types': 7.23.3 transitivePeerDependencies: - supports-color @@ -654,6 +681,14 @@ packages: chalk: 2.4.2 js-tokens: 4.0.0 + /@babel/highlight@7.23.4: + resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.22.20 + chalk: 2.4.2 + js-tokens: 4.0.0 + /@babel/parser@7.23.3: resolution: {integrity: sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==} engines: {node: '>=6.0.0'} @@ -661,6 +696,13 @@ packages: dependencies: '@babel/types': 7.23.3 + /@babel/parser@7.23.4: + resolution: {integrity: sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.23.4 + /@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.23.3): resolution: {integrity: sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==} engines: {node: '>=6.9.0'} @@ -729,18 +771,18 @@ packages: '@babel/parser': 7.23.3 '@babel/types': 7.23.3 - /@babel/traverse@7.23.3: - resolution: {integrity: sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==} + /@babel/traverse@7.23.4: + resolution: {integrity: sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.22.13 - '@babel/generator': 7.23.3 + '@babel/code-frame': 7.23.4 + '@babel/generator': 7.23.4 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-function-name': 7.23.0 '@babel/helper-hoist-variables': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - '@babel/parser': 7.23.3 - '@babel/types': 7.23.3 + '@babel/parser': 7.23.4 + '@babel/types': 7.23.4 debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: @@ -754,6 +796,14 @@ packages: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 + /@babel/types@7.23.4: + resolution: {integrity: sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.23.4 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + /@changesets/apply-release-plan@6.1.4: resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==} dependencies: @@ -1738,23 +1788,6 @@ packages: resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} dev: true - /@preact-signals/safe-react@0.0.3(@babel/core@7.23.3)(react@18.2.0): - resolution: {integrity: sha512-5hRw0a8hmzlHVlJNjqzDVDJNuZgVU4fQbURlEe5z2nKt8EGqEnliEow3t7KzrfGeEV3lYyStNjSDAq8bUKGkSw==} - peerDependencies: - '@babel/core': ^7.0.0 - react: ^16.14.0 || 17.x || 18.x - dependencies: - '@babel/core': 7.23.3 - '@babel/helper-module-imports': 7.22.15 - '@babel/helper-plugin-utils': 7.22.5 - '@preact/signals-core': 1.5.0 - debug: 4.3.4 - react: 18.2.0 - use-sync-external-store: 1.2.0(react@18.2.0) - transitivePeerDependencies: - - supports-color - dev: false - /@preact/preset-vite@2.6.0(@babel/core@7.23.3)(preact@10.19.1)(vite@4.5.0): resolution: {integrity: sha512-5nztNzXbCpqyVum/K94nB2YQ5PTnvWdz4u7/X0jc8+kLyskSSpkNUxLQJeI90zfGSFIX1Ibj2G2JIS/mySHWYQ==} peerDependencies: @@ -6142,6 +6175,12 @@ packages: hasBin: true dev: true + /prettier@3.1.0: + resolution: {integrity: sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==} + engines: {node: '>=14'} + hasBin: true + dev: false + /pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}