@preact-signals/safe-react
Version:
Manage state with style in React
1,055 lines (975 loc) • 29 kB
text/typescript
import { memo } from "radash";
/**
* 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<InputOutput>;
}
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 "/* @noUseSignals */\n";
if (comment === "opt-in") return "/* @useSignals */\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<string | undefined>) =>
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 <div>{signal.value}</div>",
params,
}),
},
{
name: codeTitle(baseName, "as arrow function with statement body"),
...generateCode({
type: "ArrowComp",
return: "statement",
body: "return <div>{signal.value}</div>",
params,
}),
},
{
name: codeTitle(baseName, "as arrow function with expression body"),
...generateCode({
type: "ArrowComp",
return: "expression",
body: "<div>{signal.value}</div>",
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 <div>Hello World</div>",
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: "<div>Hello World</div>",
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 <div>{signal.value}</div>",
params,
}),
});
} else {
components.push({
name: codeTitle(baseName, "as function with proper inline name"),
...generateCode({
type: "FuncExpComp",
name: "App",
body: "return <div>{signal.value}</div>",
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;
}
const memoCreator = <T extends (...args: any) => any>(fn: T) =>
memo(fn, {
ttl: 20_000,
});
export const declarationComp = memoCreator(
(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 <div>{signal.value}</div>",
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 <div>Hello World</div>",
params,
comment,
}),
},
];
}
}
);
export const objMethodComp = memoCreator(
(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 <div>{signal.value}</div>",
params,
comment,
}),
},
{
name: codeTitle(baseName, "with dynamic name"),
...generateCode({
type: "ObjectMethodComp",
name: "['App' + '1']",
body: "return <div>{signal.value}</div>",
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 <div>Hello World</div>",
params,
comment,
}),
},
];
}
}
);
export const variableComp = memoCreator(
(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 <div>{signal.value}</div>",
}),
}),
});
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: "<div>{signal.value}</div>",
}),
}),
});
}
// 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 const assignmentComp = memoCreator(
(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 <div>{signal.value}</div>",
}),
}),
});
codeCases.push({
name: codeTitle(baseName, "arrow function with bad variable name"),
...generateCode({
type: "Assignment",
name: "render",
comment,
body: generateCode({
type: "ArrowComp",
return: "expression",
body: "<div>{signal.value}</div>",
}),
}),
});
}
// 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 const objAssignComp = memoCreator(
(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 <div>{signal.value}</div>",
}),
}),
});
codeCases.push({
name: codeTitle(baseName, "arrow function with bad property name"),
...generateCode({
type: "MemberExpAssign",
property: "render",
comment,
body: generateCode({
type: "ArrowComp",
return: "expression",
body: "<div>{signal.value}</div>",
}),
}),
});
codeCases.push({
name: codeTitle(
baseName,
"function component with bad computed property name"
),
...generateCode({
type: "MemberExpAssign",
property: "['render']",
body: generateCode({
type: "FuncExpComp",
body: "return <div>{signal.value}</div>",
}),
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 <div>{signal.value}</div>",
}),
comment,
}),
});
} else {
codeCases.push({
name: codeTitle(
baseName,
"function component with computed property name"
),
...generateCode({
type: "MemberExpAssign",
property: "['Comp']",
body: generateCode({
type: "FuncExpComp",
body: "return <div>{signal.value}</div>",
}),
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 const objectPropertyComp = memoCreator(
(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 <div>{signal.value}</div>",
}),
}),
});
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: "<div>{signal.value}</div>",
}),
}),
});
}
// 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 const exportDefaultComp = memoCreator(
(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 const exportNamedComp = memoCreator(
(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();