From f3e3fac697d6176c7e1a1b3b4a9603f5251cfe95 Mon Sep 17 00:00:00 2001 From: Nisarg Patel Date: Fri, 5 Jun 2026 19:16:58 -0700 Subject: [PATCH] feat(architecture): add no-prop-drilling rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags a prop forwarded untouched through 3+ same-file components — each a pure pass-through that never reads it — before it's finally consumed, and recommends lifting the value into a Context/Provider (or composing with `children`). Sourced from the vercel-labs composition-patterns (compound-components) skill; this drilling angle had no prior coverage. The detector is scope-aware (uses context.scopes): it resolves each JSX tag to a same-file component and each forwarded attribute value to a prop parameter binding, so shadowed names, transformed values (user.name, fn(user)), conditionals, {...spread}, and hand-offs to DOM or imported components do not count as untouched forwarding. The chain is only counted when it terminates in a real consumer, so pure-forwarding cycles never produce a phantom diagnostic. Conservative v1 (threshold 3, same-file only); 16 adversarial unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/no-prop-drilling.md | 5 + .../src/plugin/constants/thresholds.ts | 9 + .../src/plugin/rule-registry.ts | 12 + .../architecture/no-prop-drilling.test.ts | 297 +++++++++++++++ .../rules/architecture/no-prop-drilling.ts | 352 ++++++++++++++++++ 5 files changed, 675 insertions(+) create mode 100644 .changeset/no-prop-drilling.md create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.ts diff --git a/.changeset/no-prop-drilling.md b/.changeset/no-prop-drilling.md new file mode 100644 index 000000000..b04f4427a --- /dev/null +++ b/.changeset/no-prop-drilling.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": minor +--- + +Add `no-prop-drilling` (Architecture / Maintainability): flags a prop forwarded untouched through 3+ same-file components — each a pure pass-through that never reads it — before it's finally used, and recommends lifting the value into a Context/Provider (or composing with `children`). The detector is scope-aware: it resolves each JSX tag to a same-file component and each forwarded attribute value to a prop parameter binding, so shadowed names, transformed values (`user.name`, `fn(user)`), conditionals, `{...spread}`, and hand-offs to DOM or imported components don't count as untouched forwarding. Sourced from the vercel-labs `composition-patterns` (compound-components) skill. diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts index b9cbf30b1..2d6b1b63f 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts @@ -7,6 +7,15 @@ export const SEQUENTIAL_AWAIT_THRESHOLD = 3; export const PROPERTY_ACCESS_REPEAT_THRESHOLD = 3; export const BOOLEAN_PROP_THRESHOLD = 4; export const RENDER_PROP_PROLIFERATION_THRESHOLD = 3; +// A prop forwarded untouched through this many same-file components — +// each one a pure pass-through that never reads the prop itself — before +// it's finally used is the prop-drilling smell `no-prop-drilling` flags; +// lift the value into a Context/Provider instead. Counts only same-file +// component chains: handing the prop to an imported component or a DOM +// element counts as consuming it, so the chain ends there. Deliberately +// conservative (a 3-deep same-file forward chain is rare and unambiguous) +// — eval data can lower it later. +export const PROP_DRILL_CHAIN_THRESHOLD = 3; export const GET_HANDLER_BINDING_RESOLUTION_DEPTH = 3; // Chains rooted in a literal array `[a, b, c].map(...).filter(...)` at // or below this length are skipped by the iteration-combination rules diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts index 083f7311f..ce8e7c243 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts @@ -194,6 +194,7 @@ import { noPermanentWillChange } from "./rules/performance/no-permanent-will-cha import { noPolymorphicChildren } from "./rules/correctness/no-polymorphic-children.js"; import { noPreventDefault } from "./rules/correctness/no-prevent-default.js"; import { noPropCallbackInEffect } from "./rules/state-and-effects/no-prop-callback-in-effect.js"; +import { noPropDrilling } from "./rules/architecture/no-prop-drilling.js"; import { noPropTypes } from "./rules/architecture/no-prop-types.js"; import { noPureBlackBackground } from "./rules/design/no-pure-black-background.js"; import { noRandomKey } from "./rules/correctness/no-random-key.js"; @@ -2392,6 +2393,17 @@ export const reactDoctorRules = [ category: "Bugs", }, }, + { + key: "react-doctor/no-prop-drilling", + id: "no-prop-drilling", + source: "react-doctor", + originallyExternal: false, + rule: { + ...noPropDrilling, + framework: "global", + category: "Maintainability", + }, + }, { key: "react-doctor/no-prop-types", id: "no-prop-types", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.test.ts new file mode 100644 index 000000000..b44b935fe --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { noPropDrilling } from "./no-prop-drilling.js"; + +const expectDiagnosticCount = ( + code: string, + expectedDiagnosticCount: number, + filename = "fixture.tsx", +): void => { + const result = runRule(noPropDrilling, code, { filename }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(expectedDiagnosticCount); +}; + +describe("architecture/no-prop-drilling — fail cases", () => { + it("flags a prop forwarded untouched through three function components", () => { + expectDiagnosticCount( + `function Page({ user }) { + return ; +} +function Sidebar({ user }) { + return ; +} +function Profile({ user }) { + return ; +} +function Avatar({ user }) { + return {user.name}; +}`, + 1, + ); + }); + + it("flags a renamed pass-through chain across arrow components", () => { + expectDiagnosticCount( + `const Layout = ({ theme }) => ; +const Body = ({ palette }) => ; +const Panel = ({ scheme }) => ; +const Swatch = ({ value }) =>
;`, + 1, + ); + }); + + it("treats a TypeScript cast hop as untouched forwarding", () => { + expectDiagnosticCount( + `function A({ payload }) { + return ; +} +function B({ payload }) { + return ; +} +function C({ payload }) { + return ; +} +function D({ payload }) { + return {payload}; +}`, + 1, + ); + }); + + it("reports once at the origin when a prop is forwarded down two branches", () => { + expectDiagnosticCount( + `function Page({ user }) { + return ( +
+ +
+
+ ); +} +function Sidebar({ user }) { + return ; +} +function Profile({ user }) { + return ; +} +function Avatar({ user }) { + return {user.name}; +} +function Footer({ user }) { + return {user.name}; +}`, + 1, + ); + }); + + it("names the drilled prop and the chain in the message", () => { + const result = runRule( + noPropDrilling, + `function Page({ user }) { + return ; +} +function Sidebar({ user }) { + return ; +} +function Profile({ user }) { + return ; +} +function Avatar({ user }) { + return {user.name}; +}`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0].message).toContain('"user"'); + expect(result.diagnostics[0].message).toContain("Page → Sidebar → Profile"); + }); +}); + +describe("architecture/no-prop-drilling — pass cases", () => { + it("stays quiet below the depth threshold (two pass-through layers)", () => { + expectDiagnosticCount( + `function Page({ user }) { + return ; +} +function Sidebar({ user }) { + return ; +} +function Profile({ user }) { + return {user.name}; +}`, + 0, + ); + }); + + it("does not chain through a component that also reads the prop", () => { + expectDiagnosticCount( + `function Page({ user }) { + return ; +} +function Sidebar({ user }) { + console.log(user); + return ; +} +function Profile({ user }) { + return ; +} +function Avatar({ user }) { + return {user.name}; +}`, + 0, + ); + }); + + it("does not treat a transformed value as untouched forwarding", () => { + expectDiagnosticCount( + `function A({ user }) { + return ; +} +function B({ name }) { + return ; +} +function C({ name }) { + return ; +} +function D({ name }) { + return {name}; +}`, + 0, + ); + }); + + it("does not treat a conditionally-selected value as untouched forwarding", () => { + expectDiagnosticCount( + `function A({ user }) { + return ; +} +function B({ user }) { + return ; +} +function C({ user }) { + return ; +} +function D({ user }) { + return {user}; +}`, + 0, + ); + }); + + it("resolves bindings by scope, not by name (shadowed map param)", () => { + expectDiagnosticCount( + `function Root({ value }) { + return ; +} +function Mid({ value }) { + return ; +} +function Inner({ value }) { + return
    {list.map((value) => )}
; +} +function Leaf({ value }) { + return {value}; +}`, + 0, + ); + }); + + it("stops at a component that re-sources the value locally", () => { + expectDiagnosticCount( + `function A({ data }) { + return ; +} +function B({ data }) { + return ; +} +function C(props) { + const data = useData(); + return ; +} +function D({ data }) { + return {data}; +}`, + 0, + ); + }); + + it("does not track spread forwarding (v1 non-goal)", () => { + expectDiagnosticCount( + `function A(props) { + return ; +} +function B(props) { + return ; +} +function C(props) { + return ; +} +function D({ user }) { + return {user.name}; +}`, + 0, + ); + }); + + it("ends the chain when the prop is handed to an imported component", () => { + expectDiagnosticCount( + `import { Avatar } from "./avatar"; + +function Page({ user }) { + return ; +} +function Sidebar({ user }) { + return ; +} +function Profile({ user }) { + return ; +}`, + 0, + ); + }); + + it("terminates on self-recursive components without infinite recursion", () => { + expectDiagnosticCount( + `function App({ node }) { + return ; +} +function Tree({ node }) { + return ; +}`, + 0, + ); + }); + + // Regression: a pure-forwarding cycle never consumes the prop, so the + // cycle-closing hop must not be counted as a terminus (would otherwise + // manufacture a phantom depth-3 chain from `A → B → C`). + it("does not report a forwarding cycle that never consumes the prop", () => { + expectDiagnosticCount( + `function A({ x }) { + return ; +} +function B({ x }) { + return ; +} +function C({ x }) { + return ; +}`, + 0, + ); + }); + + it("does not report a chain that dead-ends in an infinite self-render", () => { + expectDiagnosticCount( + `function A({ x }) { + return ; +} +function B({ x }) { + return ; +} +function Loop({ x }) { + return ; +}`, + 0, + ); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.ts new file mode 100644 index 000000000..30caa775e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/no-prop-drilling.ts @@ -0,0 +1,352 @@ +import { PROP_DRILL_CHAIN_THRESHOLD } from "../../constants/thresholds.js"; +import { defineRule } from "../../utils/define-rule.js"; +import { isComponentAssignment } from "../../utils/is-component-assignment.js"; +import { isComponentDeclaration } from "../../utils/is-component-declaration.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; +import type { Rule } from "../../utils/rule.js"; +import type { RuleContext } from "../../utils/rule-context.js"; +import type { + ScopeAnalysis, + ScopeDescriptor, + SymbolDescriptor, +} from "../../semantic/scope-analysis.js"; + +type FunctionLikeNode = + | EsTreeNodeOfType<"ArrowFunctionExpression"> + | EsTreeNodeOfType<"FunctionDeclaration"> + | EsTreeNodeOfType<"FunctionExpression">; + +// A same-file component we can reason about: we can see its body, so we +// know exactly which of its props it forwards untouched vs. actually +// reads. Imported components are deliberately NOT modeled — we can't see +// their body, so a prop handed to one counts as consumed (the chain ends +// there). That keeps the rule conservative: we only ever claim "drilled" +// when every hop in the chain is provably a pure pass-through. +interface LocalComponent { + readonly symbol: SymbolDescriptor; + readonly displayName: string; + // external prop name (how a parent names the attribute) → the local + // parameter binding symbol the destructure introduces. Renamed + // destructures (`{ user: u }`) map "user" → the `u` binding. + readonly propBindingByExternalName: Map; + readonly propBindingSymbols: ReadonlySet; +} + +// One untouched forward of a single prop to a same-file child component: +// `} />`. +interface PropForwardEdge { + readonly attribute: EsTreeNode; + readonly childComponent: LocalComponent; + readonly childPropExternalName: string; +} + +interface PropUsage { + // True only when EVERY reference to the prop is a bare forward to a + // same-file child AND there is at least one such forward. A single + // read, render, transform, spread, or hand-off to a DOM/imported + // element flips this to false (the component genuinely consumes it). + readonly isPureForwarder: boolean; + readonly forwardEdges: ReadonlyArray; +} + +// The deepest pure-forward chain rooted at one (component, prop): how +// many consecutive pass-through components the prop crosses, the names +// along that chain, and the JSX attribute at the root hop to report on. +// `terminates` is true only when the chain actually ends at a CONSUMER +// (a component that uses the prop). It separates a real terminus from a +// cycle dead-end: a prop that loops between pure forwarders forever is +// never "used", so a cycle-broken branch must not be counted toward +// depth — otherwise the loop-closing hop is scored like a consumption +// and manufactures a phantom drill. +interface DrillChain { + readonly depth: number; + readonly path: ReadonlyArray; + readonly reportAttribute: EsTreeNode | null; + readonly terminates: boolean; +} + +// Mirror of `stripParenExpression`'s wrapper set, but applied UPWARD: +// `` / `` still forward +// `user` untouched, so we treat these transparent wrappers as part of +// the same expression when deciding whether the reference IS the whole +// attribute value. +const TRANSPARENT_EXPRESSION_WRAPPERS = new Set([ + "ParenthesizedExpression", + "TSAsExpression", + "TSSatisfiesExpression", + "TSTypeAssertion", + "TSNonNullExpression", + "TSInstantiationExpression", + "ChainExpression", +]); + +const collectAllScopeSymbols = (rootScope: ScopeDescriptor): SymbolDescriptor[] => { + const symbols: SymbolDescriptor[] = []; + const visit = (scope: ScopeDescriptor): void => { + for (const symbol of scope.symbols) symbols.push(symbol); + for (const child of scope.children) visit(child); + }; + visit(rootScope); + return symbols; +}; + +const getComponentFunctionNode = (symbol: SymbolDescriptor): FunctionLikeNode | null => { + const declaration = symbol.declarationNode; + if (isNodeOfType(declaration, "FunctionDeclaration")) { + return isComponentDeclaration(declaration) ? declaration : null; + } + if (isNodeOfType(declaration, "VariableDeclarator")) { + if (!isComponentAssignment(declaration)) return null; + const initializer = declaration.init; + if ( + initializer && + (isNodeOfType(initializer, "ArrowFunctionExpression") || + isNodeOfType(initializer, "FunctionExpression")) + ) { + return initializer; + } + } + return null; +}; + +// v1 models only top-level destructured named props (`function C({ user })`). +// `function C(props)` (member access), `{ ...rest }`, and nested +// destructures (`{ user: { id } }`) are intentionally skipped — the prop +// is either consumed or its identity can't be threaded through a chain +// precisely, so those components act as opaque consumers. +const collectDestructuredPropBindings = ( + functionNode: FunctionLikeNode, + scopes: ScopeAnalysis, +): { byExternalName: Map; symbols: Set } => { + const byExternalName = new Map(); + const symbols = new Set(); + const firstParam = functionNode.params?.[0]; + if (!firstParam || !isNodeOfType(firstParam, "ObjectPattern")) return { byExternalName, symbols }; + for (const property of firstParam.properties ?? []) { + if (!isNodeOfType(property, "Property")) continue; + if (property.computed) continue; + if (!isNodeOfType(property.key, "Identifier")) continue; + // `{ user = fallback }` → AssignmentPattern; the binding is the left + // identifier and the default never mutates a forwarded value. + let valueNode: EsTreeNode = property.value; + if (isNodeOfType(valueNode, "AssignmentPattern")) valueNode = valueNode.left; + if (!isNodeOfType(valueNode, "Identifier")) continue; + const symbol = scopes.symbolFor(valueNode); + if (!symbol) continue; + byExternalName.set(property.key.name, symbol); + symbols.add(symbol); + } + return { byExternalName, symbols }; +}; + +// Climbs out of transparent wrappers so the caller can ask "is THIS +// reference the entire value of an attribute?" without parens / casts +// hiding the relationship. +const outermostWrappedExpression = (node: EsTreeNode): EsTreeNode => { + let current = node; + while (current.parent && TRANSPARENT_EXPRESSION_WRAPPERS.has(current.parent.type)) { + const wrapper = current.parent as EsTreeNode & { expression?: EsTreeNode }; + if (wrapper.expression !== current) break; + current = wrapper; + } + return current; +}; + +const getJsxAttributeName = (attribute: EsTreeNodeOfType<"JSXAttribute">): string | null => + isNodeOfType(attribute.name, "JSXIdentifier") ? attribute.name.name : null; + +export const noPropDrilling = defineRule({ + id: "no-prop-drilling", + title: "Prop drilled through too many components", + severity: "warn", + tags: ["test-noise", "react-jsx-only"], + recommendation: + "Lift the value into a Context/Provider (or compose with `children`) so intermediate components don't forward a prop they never use.", + create: (context: RuleContext) => { + const scopes = context.scopes; + + // Resolves `` to a same-file component, or null for DOM + // elements (lowercase), imported components, and `` — + // all of which terminate a chain. + const resolveLocalComponent = ( + tagName: EsTreeNode, + componentBySymbol: Map, + ): LocalComponent | null => { + if (!isNodeOfType(tagName, "JSXIdentifier")) return null; + const symbol = scopes.symbolFor(tagName); + if (!symbol) return null; + return componentBySymbol.get(symbol) ?? null; + }; + + // A reference is a "bare forward" when it IS the whole value of a + // `childProp={}` attribute on a same-file component. Anything + // else (a read, `user.name`, `fn(user)`, `{...user}`, a DOM sink, an + // imported child) is a real use. + const forwardEdgeForReference = ( + referenceIdentifier: EsTreeNode, + componentBySymbol: Map, + ): PropForwardEdge | null => { + const outer = outermostWrappedExpression(referenceIdentifier); + const container = outer.parent; + if (!container || !isNodeOfType(container, "JSXExpressionContainer")) return null; + if (container.expression !== outer) return null; + const attribute = container.parent; + if (!attribute || !isNodeOfType(attribute, "JSXAttribute")) return null; + if (attribute.value !== container) return null; + const childPropExternalName = getJsxAttributeName(attribute); + if (!childPropExternalName) return null; + const openingElement = attribute.parent; + if (!openingElement || !isNodeOfType(openingElement, "JSXOpeningElement")) return null; + const childComponent = resolveLocalComponent(openingElement.name, componentBySymbol); + if (!childComponent) return null; + return { attribute, childComponent, childPropExternalName }; + }; + + const classifyPropUsage = ( + propSymbol: SymbolDescriptor, + componentBySymbol: Map, + cache: Map, + ): PropUsage => { + const cached = cache.get(propSymbol); + if (cached) return cached; + const forwardEdges: PropForwardEdge[] = []; + let hasRealUse = false; + for (const reference of propSymbol.references) { + const edge = forwardEdgeForReference(reference.identifier, componentBySymbol); + if (edge) forwardEdges.push(edge); + else hasRealUse = true; + } + const usage: PropUsage = { + isPureForwarder: forwardEdges.length > 0 && !hasRealUse, + forwardEdges, + }; + cache.set(propSymbol, usage); + return usage; + }; + + // Walks the deepest pure-forward chain from one (component, prop), + // counting only consecutive pass-through components. `visiting` + // breaks self/mutual recursion (``) so the walk + // always terminates. + const deepestDrillChain = ( + component: LocalComponent, + propSymbol: SymbolDescriptor, + componentBySymbol: Map, + usageCache: Map, + visiting: Set, + ): DrillChain => { + // Re-entering a binding already on the stack is a cycle, not a + // terminus — the prop never gets consumed down this path. + if (visiting.has(propSymbol)) { + return { depth: 0, path: [], reportAttribute: null, terminates: false }; + } + const usage = classifyPropUsage(propSymbol, componentBySymbol, usageCache); + // A component that doesn't purely forward the prop consumes it: the + // valid end of a drilling chain. + if (!usage.isPureForwarder) { + return { depth: 0, path: [], reportAttribute: null, terminates: true }; + } + visiting.add(propSymbol); + let best: DrillChain = { depth: 0, path: [], reportAttribute: null, terminates: false }; + for (const edge of usage.forwardEdges) { + const childBinding = edge.childComponent.propBindingByExternalName.get( + edge.childPropExternalName, + ); + // A local child that doesn't expose this attribute as a named + // destructured prop (it uses `props`, spreads, or nested- + // destructures) consumes the value opaquely — a valid terminus. + const childChain: DrillChain = childBinding + ? deepestDrillChain( + edge.childComponent, + childBinding, + componentBySymbol, + usageCache, + visiting, + ) + : { depth: 0, path: [], reportAttribute: null, terminates: true }; + // Only count a hop whose downstream actually reaches a consumer. + if (!childChain.terminates) continue; + const candidateDepth = 1 + childChain.depth; + if (candidateDepth > best.depth) { + best = { + depth: candidateDepth, + path: [component.displayName, ...childChain.path], + reportAttribute: edge.attribute, + terminates: true, + }; + } + } + visiting.delete(propSymbol); + return best; + }; + + return { + "Program:exit"() { + const components: LocalComponent[] = []; + const componentBySymbol = new Map(); + for (const symbol of collectAllScopeSymbols(scopes.rootScope)) { + const functionNode = getComponentFunctionNode(symbol); + if (!functionNode) continue; + const { byExternalName, symbols } = collectDestructuredPropBindings(functionNode, scopes); + const component: LocalComponent = { + symbol, + displayName: symbol.name, + propBindingByExternalName: byExternalName, + propBindingSymbols: symbols, + }; + components.push(component); + componentBySymbol.set(symbol, component); + } + // A chain needs a forwarder plus something it forwards into. + if (components.length < 2) return; + + const usageCache = new Map(); + + // Any prop binding that another pure forwarder hands a value to + // is mid-chain, not a chain origin. Reporting only at origins + // gives one diagnostic per drilled prop instead of one per hop. + const forwardedIntoBindings = new Set(); + for (const component of components) { + for (const propSymbol of component.propBindingSymbols) { + const usage = classifyPropUsage(propSymbol, componentBySymbol, usageCache); + if (!usage.isPureForwarder) continue; + for (const edge of usage.forwardEdges) { + const childBinding = edge.childComponent.propBindingByExternalName.get( + edge.childPropExternalName, + ); + if (childBinding) forwardedIntoBindings.add(childBinding); + } + } + } + + const reportedAttributes = new Set(); + for (const component of components) { + for (const propSymbol of component.propBindingSymbols) { + if (forwardedIntoBindings.has(propSymbol)) continue; + const usage = classifyPropUsage(propSymbol, componentBySymbol, usageCache); + if (!usage.isPureForwarder) continue; + const chain = deepestDrillChain( + component, + propSymbol, + componentBySymbol, + usageCache, + new Set(), + ); + if (chain.depth < PROP_DRILL_CHAIN_THRESHOLD || !chain.reportAttribute) continue; + if (reportedAttributes.has(chain.reportAttribute)) continue; + reportedAttributes.add(chain.reportAttribute); + const propName = isNodeOfType(chain.reportAttribute, "JSXAttribute") + ? getJsxAttributeName(chain.reportAttribute) + : null; + context.report({ + node: chain.reportAttribute, + message: `Prop ${propName ? `"${propName}"` : "value"} is forwarded untouched through ${chain.depth} components (${chain.path.join(" → ")}) before it's used. Lift it into a Context/Provider (or compose with \`children\`) so these middle components don't have to pass it down.`, + }); + } + } + }, + }; + }, +});