UNPKG

eslint-plugin-react-refresh

Version:

Validate that your components can safely be updated with Fast Refresh

336 lines (334 loc) 13.3 kB
// src/only-export-components.ts var reactComponentNameRE = /^[A-Z][a-zA-Z0-9_]*$/u; var onlyExportComponents = { meta: { messages: { exportAll: "This rule can't verify that `export *` only exports components.", namedExport: "Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.", anonymousExport: "Fast refresh can't handle anonymous components. Add a name to your export.", localComponents: "Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option.", noExport: "Fast refresh only works when a file has exports. Move your component(s) to a separate file.", reactContext: "Fast refresh only works when a file only exports components. Move your React context(s) to a separate file." }, type: "problem", schema: [ { type: "object", properties: { extraHOCs: { type: "array", items: { type: "string" } }, allowExportNames: { type: "array", items: { type: "string" } }, allowConstantExport: { type: "boolean" }, checkJS: { type: "boolean" } }, additionalProperties: false } ] }, defaultOptions: [], create: (context) => { const { extraHOCs = [], allowExportNames, allowConstantExport = false, checkJS = false } = context.options[0] ?? {}; const filename = context.filename; if (filename.includes(".test.") || filename.includes(".spec.") || filename.includes(".cy.") || filename.includes(".stories.")) { return {}; } const shouldScan = filename.endsWith(".jsx") || filename.endsWith(".tsx") || checkJS && filename.endsWith(".js"); if (!shouldScan) return {}; const allowExportNamesSet = allowExportNames ? new Set(allowExportNames) : void 0; const validHOCs = ["memo", "forwardRef", "lazy", ...extraHOCs]; const getHocName = (node) => { const callee = node.type === "CallExpression" ? node.callee : node.tag; if (callee.type === "CallExpression") { return getHocName(callee); } if (callee.type === "MemberExpression") { if (callee.property.type === "Identifier" && validHOCs.includes(callee.property.name)) { return callee.property.name; } if (callee.object.type === "Identifier" && validHOCs.includes(callee.object.name)) { return callee.object.name; } if (callee.object.type === "CallExpression") { return getHocName(callee.object); } } if (callee.type === "Identifier") { return callee.name; } return void 0; }; const isCallExpressionReactComponent = (node) => { const hocName = getHocName(node); if (!hocName || !validHOCs.includes(hocName)) return false; const validateArgument = hocName === "memo" || hocName === "forwardRef"; if (!validateArgument) return true; if (node.arguments.length === 0) return false; const arg = skipTSWrapper(node.arguments[0]); switch (arg.type) { case "Identifier": return reactComponentNameRE.test(arg.name); case "FunctionExpression": case "ArrowFunctionExpression": if (!arg.id) return "needName"; return reactComponentNameRE.test(arg.id.name); case "CallExpression": return isCallExpressionReactComponent(arg); default: return false; } }; const isExpressionReactComponent = (expressionParam) => { const exp = skipTSWrapper(expressionParam); if (exp.type === "Identifier") { return reactComponentNameRE.test(exp.name); } if (exp.type === "ArrowFunctionExpression" || exp.type === "FunctionExpression") { if (exp.params.length > 2) return false; if (!exp.id?.name) return "needName"; return reactComponentNameRE.test(exp.id.name); } if (exp.type === "ConditionalExpression") { const consequent = isExpressionReactComponent(exp.consequent); const alternate = isExpressionReactComponent(exp.alternate); if (consequent === false || alternate === false) return false; if (consequent === "needName" || alternate === "needName") { return "needName"; } return true; } if (exp.type === "CallExpression") { return isCallExpressionReactComponent(exp); } if (exp.type === "TaggedTemplateExpression") { const hocName = getHocName(exp); if (!hocName || !validHOCs.includes(hocName)) return false; return "needName"; } return false; }; return { Program(program) { let hasExports = false; let hasReactExport = false; let reactIsInScope = false; const localComponents = []; const nonComponentExports = []; const reactContextExports = []; const handleExportIdentifier = (identifierNode, initParam) => { if (identifierNode.type !== "Identifier") { nonComponentExports.push(identifierNode); return; } if (allowExportNamesSet?.has(identifierNode.name)) return; if (!initParam) { if (reactComponentNameRE.test(identifierNode.name)) { hasReactExport = true; } else { nonComponentExports.push(identifierNode); } return; } const init = skipTSWrapper(initParam); if (allowConstantExport && constantExportExpressions.has(init.type)) { return; } if (init.type === "CallExpression" && (init.callee.type === "Identifier" && init.callee.name === "createContext" || init.callee.type === "MemberExpression" && init.callee.property.type === "Identifier" && init.callee.property.name === "createContext")) { reactContextExports.push(identifierNode); return; } const isReactComponent = reactComponentNameRE.test(identifierNode.name) && isExpressionReactComponent(init); if (isReactComponent === false) { nonComponentExports.push(identifierNode); } else { hasReactExport = true; } }; const handleExportDeclaration = (node) => { if (node.type === "VariableDeclaration") { for (const variable of node.declarations) { if (variable.init === null) { nonComponentExports.push(variable.id); continue; } handleExportIdentifier(variable.id, variable.init); } } else if (node.type === "FunctionDeclaration") { if (node.id === null) { context.report({ messageId: "anonymousExport", node }); } else { handleExportIdentifier(node.id); } } else if (node.type === "ClassDeclaration") { if (node.id === null) { context.report({ messageId: "anonymousExport", node }); } else if (reactComponentNameRE.test(node.id.name) && node.superClass !== null && node.body.body.some( (item) => item.type === "MethodDefinition" && item.key.type === "Identifier" && item.key.name === "render" )) { hasReactExport = true; } else { nonComponentExports.push(node.id); } } else if (node.type === "CallExpression") { const result = isCallExpressionReactComponent(node); if (result === false) { nonComponentExports.push(node); } else if (result === "needName") { context.report({ messageId: "anonymousExport", node }); } else { hasReactExport = true; } } else { nonComponentExports.push(node); } }; for (const node of program.body) { if (node.type === "ExportAllDeclaration") { if (node.exportKind === "type") continue; hasExports = true; context.report({ messageId: "exportAll", node }); } else if (node.type === "ExportDefaultDeclaration") { hasExports = true; const declaration = skipTSWrapper(node.declaration); if (declaration.type === "VariableDeclaration" || declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration" || declaration.type === "CallExpression") { handleExportDeclaration(declaration); } if (declaration.type === "Identifier") { handleExportIdentifier(declaration); } if (declaration.type === "ArrowFunctionExpression") { context.report({ messageId: "anonymousExport", node }); } } else if (node.type === "ExportNamedDeclaration") { if (node.exportKind === "type") continue; const declaration = node.declaration ? skipTSWrapper(node.declaration) : null; if (declaration?.type === "TSDeclareFunction") continue; hasExports = true; if (declaration) handleExportDeclaration(declaration); for (const specifier of node.specifiers) { handleExportIdentifier( specifier.exported.type === "Identifier" && specifier.exported.name === "default" ? specifier.local : specifier.exported ); } } else if (node.type === "VariableDeclaration") { for (const variable of node.declarations) { if (variable.id.type === "Identifier" && reactComponentNameRE.test(variable.id.name) && variable.init !== null && isExpressionReactComponent(variable.init) !== false) { localComponents.push(variable.id); } } } else if (node.type === "FunctionDeclaration") { if (reactComponentNameRE.test(node.id.name)) { localComponents.push(node.id); } } else if (node.type === "ImportDeclaration" && node.source.value === "react") { reactIsInScope = true; } } if (checkJS && !reactIsInScope) return; if (hasExports) { if (hasReactExport) { for (const node of nonComponentExports) { context.report({ messageId: "namedExport", node }); } for (const node of reactContextExports) { context.report({ messageId: "reactContext", node }); } } else if (localComponents.length) { for (const node of localComponents) { context.report({ messageId: "localComponents", node }); } } } else if (localComponents.length) { for (const node of localComponents) { context.report({ messageId: "noExport", node }); } } } }; } }; var skipTSWrapper = (node) => { if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "TSInstantiationExpression") { return node.expression; } return node; }; var constantExportExpressions = /* @__PURE__ */ new Set([ "Literal", // 1, "foo" "UnaryExpression", // -1 "TemplateLiteral", // `Some ${template}` "BinaryExpression" // 24 * 60 ]); // src/index.ts var rules = { "only-export-components": onlyExportComponents }; var plugin = { rules }; var buildConfig = ({ name, baseOptions }) => (options) => ({ name: `react-refresh/${name}`, plugins: { "react-refresh": plugin }, rules: { "react-refresh/only-export-components": [ "error", { ...baseOptions, ...options } ] } }); var configs = { recommended: buildConfig({ name: "recommended", baseOptions: {} }), vite: buildConfig({ name: "vite", baseOptions: { allowConstantExport: true } }), next: buildConfig({ name: "next", baseOptions: { allowExportNames: [ // https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config "experimental_ppr", "dynamic", "dynamicParams", "revalidate", "fetchCache", "runtime", "preferredRegion", "maxDuration", // https://nextjs.org/docs/app/api-reference/functions/generate-metadata "metadata", "generateMetadata", // https://nextjs.org/docs/app/api-reference/functions/generate-viewport "viewport", "generateViewport", // https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata "generateImageMetadata", // https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps "generateSitemaps", // https://nextjs.org/docs/app/api-reference/functions/generate-static-params "generateStaticParams" ] } }) }; var reactRefresh = { plugin, configs }; var index_default = { rules, configs: { recommended: configs.recommended(), vite: configs.vite(), next: configs.next() } }; export { index_default as default, reactRefresh };