eslint-plugin-react-server-components
Version:
Rules for React server components
398 lines (393 loc) • 11.5 kB
JavaScript
// src/rules/use-client.ts
import globals from "globals";
// src/rules/react-events.ts
var reactEvents = [
"onCopy",
"onCopyCapture",
"onCut",
"onCutCapture",
"onPaste",
"onPasteCapture",
"onCompositionEnd",
"onCompositionEndCapture",
"onCompositionStart",
"onCompositionStartCapture",
"onCompositionUpdate",
"onCompositionUpdateCapture",
"onFocus",
"onFocusCapture",
"onBlur",
"onBlurCapture",
"onChange",
"onChangeCapture",
"onBeforeInput",
"onBeforeInputCapture",
"onInput",
"onInputCapture",
"onReset",
"onResetCapture",
"onSubmit",
"onSubmitCapture",
"onInvalid",
"onInvalidCapture",
"onLoad",
"onLoadCapture",
"onKeyDown",
"onKeyDownCapture",
"onKeyPress",
"onKeyPressCapture",
"onKeyUp",
"onKeyUpCapture",
"onAbort",
"onAbortCapture",
"onCanPlay",
"onCanPlayCapture",
"onCanPlayThrough",
"onCanPlayThroughCapture",
"onDurationChange",
"onDurationChangeCapture",
"onEmptied",
"onEmptiedCapture",
"onEncrypted",
"onEncryptedCapture",
"onEnded",
"onEndedCapture",
"onLoadedData",
"onLoadedDataCapture",
"onLoadedMetadata",
"onLoadedMetadataCapture",
"onLoadStart",
"onLoadStartCapture",
"onPause",
"onPauseCapture",
"onPlay",
"onPlayCapture",
"onPlaying",
"onPlayingCapture",
"onProgress",
"onProgressCapture",
"onRateChange",
"onRateChangeCapture",
"onResize",
"onResizeCapture",
"onSeeked",
"onSeekedCapture",
"onSeeking",
"onSeekingCapture",
"onStalled",
"onStalledCapture",
"onSuspend",
"onSuspendCapture",
"onTimeUpdate",
"onTimeUpdateCapture",
"onVolumeChange",
"onVolumeChangeCapture",
"onWaiting",
"onWaitingCapture",
"onAuxClick",
"onAuxClickCapture",
"onClick",
"onClickCapture",
"onContextMenu",
"onContextMenuCapture",
"onDoubleClick",
"onDoubleClickCapture",
"onDrag",
"onDragCapture",
"onDragEnd",
"onDragEndCapture",
"onDragEnter",
"onDragEnterCapture",
"onDragExit",
"onDragExitCapture",
"onDragLeave",
"onDragLeaveCapture",
"onDragOver",
"onDragOverCapture",
"onDragStart",
"onDragStartCapture",
"onDrop",
"onDropCapture",
"onMouseDown",
"onMouseDownCapture",
"onMouseEnter",
"onMouseLeave",
"onMouseMove",
"onMouseMoveCapture",
"onMouseOut",
"onMouseOutCapture",
"onMouseOver",
"onMouseOverCapture",
"onMouseUp",
"onMouseUpCapture",
"onSelect",
"onSelectCapture",
"onTouchCancel",
"onTouchCancelCapture",
"onTouchEnd",
"onTouchEndCapture",
"onTouchMove",
"onTouchMoveCapture",
"onTouchStart",
"onTouchStartCapture",
"onPointerDown",
"onPointerDownCapture",
"onPointerMove",
"onPointerMoveCapture",
"onPointerUp",
"onPointerUpCapture",
"onPointerCancel",
"onPointerCancelCapture",
"onPointerEnter",
"onPointerEnterCapture",
"onPointerLeave",
"onPointerLeaveCapture",
"onPointerOver",
"onPointerOverCapture",
"onPointerOut",
"onPointerOutCapture",
"onGotPointerCapture",
"onGotPointerCaptureCapture",
"onLostPointerCapture",
"onLostPointerCaptureCapture",
"onScroll",
"onScrollCapture",
"onWheel",
"onWheelCapture",
"onAnimationStart",
"onAnimationStartCapture",
"onAnimationEnd",
"onAnimationEndCapture",
"onAnimationIteration",
"onAnimationIterationCapture",
"onTransitionEnd",
"onTransitionEndCapture"
];
// src/rules/use-client.ts
import Components from "eslint-plugin-react/lib/util/Components";
import componentUtil from "eslint-plugin-react/lib/util/componentUtil";
var useClientRegex = /^('|")use client('|")/;
var browserOnlyGlobals = Object.keys(globals.browser).reduce((acc, curr) => {
if (curr in globals.browser && !(curr in globals.node)) {
acc.add(curr);
}
return acc;
}, /* @__PURE__ */ new Set());
var meta = {
docs: {
description: "Enforce components are appropriately labeled with 'use client'.",
recommended: true
},
type: "problem",
hasSuggestions: true,
fixable: "code",
schema: [
{
type: "object",
properties: {
allowedServerHooks: { type: "array", items: { type: "string" } }
},
additionalProperties: false
}
],
messages: {
addUseClientHooks: '{{hook}} only works in Client Components. Add the "use client" directive at the top of the file to use it.',
addUseClientBrowserAPI: 'Browser APIs only work in Client Components. Add the "use client" directive at the top of the file to use it.',
addUseClientCallbacks: 'Functions can only be passed as props to Client Components. Add the "use client" directive at the top of the file to use it.',
addUseClientClassComponent: 'React Class Components can only be used in Client Components. Add the "use client" directive at the top of the file.',
removeUseClient: "This file does not require the 'use client' directive, and it should be removed."
}
};
var create = Components.detect(
(context, _, util) => {
let hasReported = false;
const instances = [];
let isClientComponent = false;
const sourceCode = context.getSourceCode();
const options = context.options?.[0] || {};
let parentNode;
function isClientOnlyHook(name) {
return (
// `useId` is the only hook that's allowed in server components
name !== "useId" && !(options.allowedServerHooks || []).includes(name) && /^use[A-Z]/.test(name)
);
}
function reportMissingDirective(messageId, expression, data) {
if (isClientComponent || hasReported) {
return;
}
hasReported = true;
context.report({
node: expression,
messageId,
data,
*fix(fixer) {
const firstToken = sourceCode.getFirstToken(parentNode.body[0]);
if (firstToken) {
const isFirstLine = firstToken.loc.start.line === 1;
yield fixer.insertTextBefore(
firstToken,
`${isFirstLine ? "" : "\n"}'use client';
`
);
}
}
});
}
const reactImports = {
namespace: []
};
const undeclaredReferences = /* @__PURE__ */ new Set();
return {
Program(node) {
for (const block of node.body) {
if (block.type === "ExpressionStatement" && block.expression.type === "Literal" && block.expression.value === "use client") {
isClientComponent = true;
}
}
parentNode = node;
const scope = context.getScope();
scope.through.forEach((reference) => {
undeclaredReferences.add(reference.identifier.name);
});
},
ImportDeclaration(node) {
if (node.source.value === "react") {
node.specifiers.filter((spec) => spec.type === "ImportSpecifier").forEach((spac) => {
const spec = spac;
reactImports[spec.local.name] = spec.imported.name;
});
const namespace = node.specifiers.find(
(spec) => spec.type === "ImportDefaultSpecifier" || spec.type === "ImportNamespaceSpecifier"
);
if (namespace) {
reactImports.namespace = [
...reactImports.namespace,
namespace.local.name
];
}
}
},
NewExpression(node) {
const name = node.callee.name;
if (undeclaredReferences.has(name) && browserOnlyGlobals.has(name)) {
instances.push(name);
reportMissingDirective("addUseClientBrowserAPI", node);
}
},
CallExpression(expression) {
let name = "";
if (expression.callee.type === "Identifier" && "name" in expression.callee) {
name = expression.callee.name;
} else if (expression.callee.type === "MemberExpression" && "name" in expression.callee.property) {
name = expression.callee.property.name;
}
if (isClientOnlyHook(name) && // Is in a function...
context.getScope().type === "function" && // But only if that function is a component
Boolean(util.getParentComponent(expression))) {
instances.push(name);
reportMissingDirective("addUseClientHooks", expression.callee, {
hook: name
});
}
},
MemberExpression(node) {
const name = node.object.name;
const scopeType = context.getScope().type;
if (undeclaredReferences.has(name) && browserOnlyGlobals.has(name) && (scopeType === "module" || !!util.getParentComponent(node))) {
instances.push(name);
reportMissingDirective("addUseClientBrowserAPI", node.object);
}
},
ExpressionStatement(node) {
const expression = node.expression;
if (!expression.callee) {
return;
}
if (expression.callee && isClientOnlyHook(expression.callee.name) && Boolean(util.getParentComponent(expression))) {
instances.push(expression.callee.name);
reportMissingDirective("addUseClientHooks", expression.callee, {
hook: expression.callee.name
});
}
},
// @ts-expect-error
JSXOpeningElement(node) {
const scope = context.getScope();
const fnsInScope = [];
scope.variables.forEach((variable) => {
variable.defs.forEach((def) => {
if (isFunction(def)) {
fnsInScope.push(variable.name);
}
});
});
scope.upper?.set.forEach((variable) => {
variable.defs.forEach((def) => {
if (isFunction(def)) {
fnsInScope.push(variable.name);
}
});
});
for (const attribute of node.attributes) {
if (attribute.type === "JSXSpreadAttribute" || attribute.value?.type !== "JSXExpressionContainer") {
continue;
}
if (reactEvents.includes(attribute.name.name)) {
reportMissingDirective("addUseClientCallbacks", attribute.name);
}
if (attribute.value?.expression.type === "ArrowFunctionExpression" || attribute.value?.expression.type === "FunctionExpression" || attribute.value.expression.type === "Identifier" && fnsInScope.includes(attribute.value.expression.name)) {
reportMissingDirective("addUseClientCallbacks", attribute);
}
}
},
ClassDeclaration(node) {
if (componentUtil.isES6Component(node, context)) {
instances.push(node.id?.name);
reportMissingDirective("addUseClientClassComponent", node);
}
},
"ExpressionStatement:exit"(node) {
const value = "value" in node.expression ? node.expression.value : "";
if (typeof value !== "string" || !useClientRegex.test(value)) {
return;
}
if (instances.length === 0 && isClientComponent) {
context.report({
node,
messageId: "removeUseClient",
fix(fixer) {
return fixer.remove(node);
}
});
}
}
};
}
);
function isFunction(def) {
if (def.type === "FunctionName") {
return true;
}
if (def.node.init && def.node.init.type === "ArrowFunctionExpression") {
return true;
}
return false;
}
var ClientComponents = { meta, create };
// src/index.ts
var configs = {
recommended: {
rules: {
"react-server-components/use-client": "error"
},
plugins: ["react-server-components"]
}
};
var rules = {
"use-client": ClientComponents
};
export {
configs,
rules
};