eslint-plugin-creatr
Version:
Custom ESLint rules for Next.js projects by Creatr
176 lines (159 loc) • 4.9 kB
JavaScript
// lib/rules/require-use-client.js
;
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Require "use client" directive when using client-side libraries or hooks',
category: 'Best Practices',
recommended: true,
url: 'https://github.com/yourusername/eslint-plugin-creatr/blob/main/docs/rules/require-use-client.md',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
libraries: {
type: 'array',
items: { type: 'string' },
default: [],
},
hooks: {
type: 'array',
items: { type: 'string' },
default: [],
},
checkEventHandlers: {
type: 'boolean',
default: true,
},
},
additionalProperties: false,
},
],
messages: {
missingUseClient: 'This file uses client-side features ({{ reason }}) and requires the "use client" directive.',
},
},
create(context) {
const options = context.options[0] || {};
// Default client-side libraries
const defaultLibraries = [
'framer-motion',
'react-spring',
'react-use',
'react-intersection-observer',
'react-hook-form',
'@tanstack/react-query',
'swr',
'react-hot-toast',
'react-beautiful-dnd',
'@dnd-kit/core',
'react-dnd',
'recharts',
'react-chartjs-2',
'lottie-react',
'react-player',
'@react-three/fiber',
'@react-three/drei',
];
// Default client-side hooks
const defaultHooks = [
'useState',
'useEffect',
'useLayoutEffect',
'useRef',
'useCallback',
'useMemo',
'useReducer',
'useContext',
'useImperativeHandle',
'useDebugValue',
'useDeferredValue',
'useTransition',
'useId',
'useSyncExternalStore',
'useInsertionEffect',
];
const clientLibraries = [...defaultLibraries, ...(options.libraries || [])];
const clientHooks = [...defaultHooks, ...(options.hooks || [])];
const checkEventHandlers = options.checkEventHandlers !== false;
let hasUseClientDirective = false;
let violations = [];
return {
Program(node) {
hasUseClientDirective = false;
violations = [];
// Check if file has "use client" at the beginning
if (node.body.length > 0) {
const firstStatement = node.body[0];
if (
firstStatement.type === 'ExpressionStatement' &&
firstStatement.expression.type === 'Literal' &&
(firstStatement.expression.value === 'use client' ||
firstStatement.expression.value === 'use client;')
) {
hasUseClientDirective = true;
}
}
},
// Check imports
ImportDeclaration(node) {
const importedLibrary = node.source.value;
// Check if this is a client-side library
if (clientLibraries.some(lib => importedLibrary.startsWith(lib))) {
violations.push({
node,
reason: `imports "${importedLibrary}"`,
});
}
},
// Check for React hooks usage
CallExpression(node) {
if (node.callee.type === 'Identifier') {
const functionName = node.callee.name;
if (clientHooks.includes(functionName)) {
violations.push({
node,
reason: `uses React hook "${functionName}"`,
});
}
}
},
// Check for event handlers (onClick, onChange, etc.)
JSXAttribute(node) {
if (!checkEventHandlers) return;
if (node.name.type === 'JSXIdentifier') {
const attrName = node.name.name;
if (attrName.startsWith('on') && /^on[A-Z]/.test(attrName)) {
violations.push({
node,
reason: `uses event handler "${attrName}"`,
});
}
}
},
'Program:exit'(node) {
if (violations.length > 0 && !hasUseClientDirective) {
// Report only the first violation to avoid too many errors
const firstViolation = violations[0];
context.report({
node: firstViolation.node,
messageId: 'missingUseClient',
data: { reason: firstViolation.reason },
fix(fixer) {
// If there are any nodes, insert before the first one
if (node.body.length > 0) {
const firstNode = node.body[0];
return fixer.insertTextBefore(firstNode, '"use client";\n\n');
}
// Empty file, just add at the beginning
return fixer.insertTextAfterRange([0, 0], '"use client";\n\n');
},
});
}
},
};
},
};