UNPKG

eslint-plugin-react-web-api

Version:

ESLint React's ESLint plugin for interacting with Web APIs

1,409 lines (1,390 loc) • 42 kB
import { DEFAULT_ESLINT_REACT_SETTINGS } from "@eslint-react/shared"; import * as core from "@eslint-react/core"; import birecord from "birecord"; import { P, isMatching, match } from "ts-pattern"; import { ESLintUtils } from "@typescript-eslint/utils"; import { Check, Compare, Extract, Traverse } from "@eslint-react/ast"; import "@eslint-react/eslint"; import { isAssignmentTargetEqual, isInitializedFromReactNative, isValueEqual, resolve, resolveEnclosingAssignmentTarget } from "@eslint-react/var"; import { AST_NODE_TYPES } from "@typescript-eslint/types"; import { getStaticValue } from "@typescript-eslint/utils/ast-utils"; //#region \0rolldown/runtime.js var __defProp = Object.defineProperty; var __exportAll = (all, no_symbols) => { let target = {}; for (var name in all) { __defProp(target, name, { get: all[name], enumerable: true }); } if (!no_symbols) { __defProp(target, Symbol.toStringTag, { value: "Module" }); } return target; }; //#endregion //#region package.json var name$1 = "eslint-plugin-react-web-api"; var version = "5.18.1"; //#endregion //#region src/types/component-phase.ts const ComponentPhaseRelevance = birecord({ setup: "cleanup" }); function getPhaseKindOfFunction(node) { return match(node).when(core.isUseEffectSetupCallback, () => "setup").when(core.isUseEffectCleanupCallback, () => "cleanup").otherwise(() => null); } //#endregion //#region src/utils/create-rule.ts function getDocsUrl(ruleName) { return `https://eslint-react.xyz/docs/rules/web-api-${ruleName}`; } const createRule = ESLintUtils.RuleCreator(getDocsUrl); //#endregion //#region src/rules/no-leaked-event-listener/lib.ts function getSignalValueExpression(context, node) { if (node == null) return null; switch (node.type) { case AST_NODE_TYPES.Identifier: { const resolved = resolve(context, node); const unwrapped = resolved == null ? null : Extract.unwrap(resolved); if (unwrapped != null && Check.isFunction(unwrapped)) return node; return getSignalValueExpression(context, unwrapped); } case AST_NODE_TYPES.MemberExpression: return node; default: return null; } } const defaultOptions = { capture: false, signal: null }; function getOptions(context, node) { function visit(node) { switch (node.type) { case AST_NODE_TYPES.Identifier: { const initNode = resolve(context, node); if (initNode?.type === AST_NODE_TYPES.ObjectExpression) return visit(initNode); return defaultOptions; } case AST_NODE_TYPES.Literal: return { ...defaultOptions, capture: Boolean(node.value) }; case AST_NODE_TYPES.ObjectExpression: { const vCapture = match(Extract.findProperty(node.properties, "capture")).with(P.nullish, () => false).with({ type: AST_NODE_TYPES.Property }, (prop) => { const value = prop.value; switch (value.type) { case AST_NODE_TYPES.Literal: return Boolean(value.value); default: return Boolean(getStaticValue(value, context.sourceCode.getScope(node))?.value); } }).otherwise(() => false); const pSignal = Extract.findProperty(node.properties, "signal"); return { capture: vCapture, signal: pSignal?.type === AST_NODE_TYPES.Property ? getSignalValueExpression(context, pSignal.value) : null }; } default: return defaultOptions; } } return visit(node); } //#endregion //#region src/rules/no-leaked-event-listener/no-leaked-event-listener.ts const RULE_NAME$5 = "no-leaked-event-listener"; function getCallKind$5(node) { const name = Extract.getCalleeName(node); if (name != null && isMatching(P.union("addEventListener", "removeEventListener", "abort"))(name)) return name; return "other"; } function getFunctionKind$2(node) { return getPhaseKindOfFunction(node) ?? "other"; } var no_leaked_event_listener_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'addEventListener' in a component or custom hook has a corresponding 'removeEventListener'." }, messages: { expectedRemoveEventListenerInCleanup: "An 'addEventListener' in '{{effectMethodKind}}' should have a corresponding 'removeEventListener' in its cleanup function.", unexpectedInlineFunction: "A/an '{{eventMethodKind}}' should not have an inline listener function." }, schema: [] }, name: RULE_NAME$5, create: create$5, defaultOptions: [] }); function create$5(context) { if (!context.sourceCode.text.includes("addEventListener")) return {}; if (!/use\w*Effect/u.test(context.sourceCode.text)) return {}; const fEntries = []; const aEntries = []; const rEntries = []; const abortedSignals = []; function isSameObject(a, b) { switch (true) { case a.type === AST_NODE_TYPES.MemberExpression && b.type === AST_NODE_TYPES.MemberExpression: return Compare.isEqual(a.object, b.object); default: return false; } } function isInverseEntry(aEntry, rEntry) { const { type: aType, callee: aCallee, capture: aCapture, listener: aListener, phase: aPhase } = aEntry; const { type: rType, callee: rCallee, capture: rCapture, listener: rListener, phase: rPhase } = rEntry; if (ComponentPhaseRelevance.get(aPhase) !== rPhase) return false; return isSameObject(aCallee, rCallee) && Compare.isEqual(aListener, rListener) && isValueEqual(context, aType, rType) && aCapture === rCapture; } function checkInlineFunction(node, callKind, options) { const listener = node.arguments.at(1); if (!Check.isFunction(listener)) return; if (options.signal != null) return; context.report({ data: { eventMethodKind: callKind }, messageId: "unexpectedInlineFunction", node: listener }); } return { [":function"](node) { const kind = getFunctionKind$2(node); fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { const fKind = fEntries.findLast((x) => x.kind !== "other")?.kind; if (fKind == null) return; if (!ComponentPhaseRelevance.has(fKind)) return; const unwrappedCallee = Extract.unwrap(node.callee); match(getCallKind$5(node)).with("addEventListener", (callKind) => { if (unwrappedCallee.type === AST_NODE_TYPES.MemberExpression && unwrappedCallee.object.type === AST_NODE_TYPES.Identifier && isInitializedFromReactNative(unwrappedCallee.object.name, context.sourceCode.getScope(node))) return; const [type, listener, options] = node.arguments; if (type == null || listener == null) return; const opts = options == null ? defaultOptions : getOptions(context, options); const { callee } = node; checkInlineFunction(node, callKind, opts); aEntries.push({ ...opts, type, callee, listener, method: "addEventListener", node, phase: fKind }); }).with("removeEventListener", (callKind) => { const [type, listener, options] = node.arguments; if (type == null || listener == null) return; const opts = options == null ? defaultOptions : getOptions(context, options); const { callee } = node; checkInlineFunction(node, callKind, opts); rEntries.push({ ...opts, type, callee, listener, method: "removeEventListener", node, phase: fKind }); }).with("abort", () => { abortedSignals.push(node.callee); }).otherwise(() => null); }, ["Program:exit"]() { for (const aEntry of aEntries) { if (aEntry.signal != null) continue; if (rEntries.some((rEntry) => isInverseEntry(aEntry, rEntry))) continue; switch (aEntry.phase) { case "setup": case "cleanup": context.report({ data: { effectMethodKind: "useEffect" }, messageId: "expectedRemoveEventListenerInCleanup", node: aEntry.node }); continue; } } } }; } //#endregion //#region src/rules/no-leaked-fetch/lib.ts function findProperty(node, name) { for (const property of node) { if (property.type === AST_NODE_TYPES.Property && !property.computed && property.key.type === AST_NODE_TYPES.Identifier && property.key.name === name) return property; if (property.type === AST_NODE_TYPES.SpreadElement && property.argument.type === AST_NODE_TYPES.ObjectExpression) { const found = findProperty(property.argument.properties, name); if (found != null) return found; } } return null; } function resolveToObjectExpression(context, node) { node = Extract.unwrap(node); switch (node.type) { case AST_NODE_TYPES.ObjectExpression: return node; case AST_NODE_TYPES.Identifier: { let resolved = resolve(context, node); resolved = resolved == null ? null : Extract.unwrap(resolved); if (resolved?.type === AST_NODE_TYPES.ObjectExpression) return resolved; return null; } default: return null; } } //#endregion //#region src/rules/no-leaked-fetch/no-leaked-fetch.ts const RULE_NAME$4 = "no-leaked-fetch"; function getCallKind$4(node) { switch (Extract.getCalleeName(node)) { case "fetch": return "fetch"; case "abort": return "abort"; default: return "other"; } } function getControllerFromSignal(context, node) { node = Extract.unwrap(node); switch (node.type) { case AST_NODE_TYPES.MemberExpression: return { controller: node.object, isParamSignal: false }; case AST_NODE_TYPES.Identifier: { const resolved = resolve(context, node); const resolvedUnwrapped = resolved == null ? null : Extract.unwrap(resolved); if (resolvedUnwrapped?.type === AST_NODE_TYPES.MemberExpression) return { controller: resolvedUnwrapped.object, isParamSignal: false }; if (resolved != null && Check.isFunction(resolved)) return { controller: node, isParamSignal: true }; return { controller: null, isParamSignal: false }; } default: return { controller: null, isParamSignal: false }; } } function getFetchController(context, node) { const [, optionsArg] = node.arguments; if (optionsArg == null) return { controller: null, isParamSignal: false }; const options = resolveToObjectExpression(context, optionsArg); if (options == null) return { controller: null, isParamSignal: false }; const signalProp = findProperty(options.properties, "signal"); if (signalProp?.type !== AST_NODE_TYPES.Property) return { controller: null, isParamSignal: false }; return getControllerFromSignal(context, signalProp.value); } function getAbortController(node) { const callee = Extract.unwrap(node.callee); if (callee.type === AST_NODE_TYPES.MemberExpression) return callee.object; return null; } var no_leaked_fetch_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'fetch' in a component or custom hook has a corresponding 'AbortController' abort in the cleanup function." }, messages: { expectedAbortController: "A 'fetch' must be provided with an 'AbortController' for proper cleanup.", expectedAbortInCleanup: "A 'fetch' started in effect must be aborted with 'AbortController.abort' in the cleanup function." }, schema: [] }, name: RULE_NAME$4, create: create$4, defaultOptions: [] }); function create$4(context) { if (!context.sourceCode.text.includes("fetch")) return {}; if (!/use\w*Effect/u.test(context.sourceCode.text)) return {}; const fEntries = []; const fetchEntries = []; const abortEntries = []; return { [":function"](node) { const kind = getPhaseKindOfFunction(node) ?? "other"; fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { const fEntry = fEntries.at(-1); if (fEntry == null || !ComponentPhaseRelevance.has(fEntry.kind)) return; switch (getCallKind$4(node)) { case "fetch": { if (fEntry.kind !== "setup") return; const { controller, isParamSignal } = getFetchController(context, node); fetchEntries.push({ controller, isParamSignal, node, phase: fEntry.kind }); break; } case "abort": { if (fEntry.kind !== "cleanup") return; const controller = getAbortController(node); if (controller == null) break; abortEntries.push({ controller, node, phase: fEntry.kind }); break; } } }, ["Program:exit"]() { for (const fEntry of fetchEntries) { const controller = fEntry.controller; if (controller == null) { context.report({ messageId: "expectedAbortController", node: fEntry.node }); continue; } if (fEntry.isParamSignal) continue; if (!abortEntries.some((aEntry) => isAssignmentTargetEqual(context, aEntry.controller, controller))) context.report({ messageId: "expectedAbortInCleanup", node: fEntry.node }); } } }; } //#endregion //#region ../../.pkgs/eff/dist/index.js function or(a, b) { return (data) => a(data) || b(data); } /** * Applies a `pipe` method's variadic arguments to an initial value from left * to right. * * **When to use** * * Use to implement a custom `.pipe(...)` method from JavaScript's `arguments` * object. * * **Details** * * This helper is intended for implementing `Pipeable.pipe` methods that * receive JavaScript's `arguments` object. With no functions it returns the * original value; otherwise it feeds each result into the next function. * * **Example** (Implementing a pipe method) * * ```ts * import { Pipeable } from "effect" * * class NumberBox { * constructor(readonly value: number) {} * * pipe(..._fns: ReadonlyArray<(value: number) => number>): number { * return Pipeable.pipeArguments(this.value, arguments) as number * } * } * * const result = new NumberBox(5).pipe( * (n) => n + 2, * (n) => n * 3 * ) * console.log(result) // 21 * ``` * * @category combinators * @since 2.0.0 */ const pipeArguments = (self, args) => { switch (args.length) { case 0: return self; case 1: return args[0](self); case 2: return args[1](args[0](self)); case 3: return args[2](args[1](args[0](self))); case 4: return args[3](args[2](args[1](args[0](self)))); case 5: return args[4](args[3](args[2](args[1](args[0](self))))); case 6: return args[5](args[4](args[3](args[2](args[1](args[0](self)))))); case 7: return args[6](args[5](args[4](args[3](args[2](args[1](args[0](self))))))); case 8: return args[7](args[6](args[5](args[4](args[3](args[2](args[1](args[0](self)))))))); case 9: return args[8](args[7](args[6](args[5](args[4](args[3](args[2](args[1](args[0](self))))))))); default: { let ret = self; for (let i = 0, len = args.length; i < len; i++) ret = args[i](ret); return ret; } } }; /** * Reusable prototype that implements `Pipeable.pipe`. * * **When to use** * * Use when classes or object prototypes can reuse this value when they need the * standard pipe implementation backed by `pipeArguments`. * * @category prototypes * @since 3.15.0 */ const Prototype = { pipe() { return pipeArguments(this, arguments); } }; /** * Provides a base constructor whose instances implement the standard `Pipeable.pipe` * method. * * **When to use** * * Use when you need to define a class that supports Effect-style method * chaining through `.pipe(...)`. * * @category constructors * @since 3.15.0 */ const Class = (function() { function PipeableBase() {} PipeableBase.prototype = Prototype; return PipeableBase; })(); /** * Provides small helpers for defining and reusing TypeScript functions. * * The main helpers are `pipe` and `flow` for left-to-right composition and * `dual` for APIs that support both direct and pipe-friendly call styles. The * module also contains small identity, constant, tuple, type-level, and * memoization helpers used across the library. * * @since 2.0.0 */ /** * Creates a function that can be called in data-first style or data-last * (`pipe`-friendly) style. * * **When to use** * * Use to expose one implementation through both direct and `pipe`-friendly * call styles. * * **Details** * * Pass either the arity of the uncurried function or a predicate that decides * whether the current call is data-first. Arity is the common case. Use a * predicate when optional arguments make arity ambiguous. * * **Example** (Selecting data-first or data-last style by arity) * * ```ts * import { Function, pipe } from "effect" * * const sum = Function.dual< * (that: number) => (self: number) => number, * (self: number, that: number) => number * >(2, (self, that) => self + that) * * console.log(sum(2, 3)) // 5 * console.log(pipe(2, sum(3))) // 5 * ``` * * **Example** (Defining overloads with call signatures) * * ```ts * import { Function, pipe } from "effect" * * const sum: { * (that: number): (self: number) => number * (self: number, that: number): number * } = Function.dual(2, (self: number, that: number): number => self + that) * * console.log(sum(2, 3)) // 5 * console.log(pipe(2, sum(3))) // 5 * ``` * * **Example** (Selecting data-first or data-last style with a predicate) * * ```ts * import { Function, pipe } from "effect" * * const sum = Function.dual< * (that: number) => (self: number) => number, * (self: number, that: number) => number * >( * (args) => args.length === 2, * (self, that) => self + that * ) * * console.log(sum(2, 3)) // 5 * console.log(pipe(2, sum(3))) // 5 * ``` * * @category combinators * @since 2.0.0 */ const dual = function(arity, body) { if (typeof arity === "function") return function() { return arity(arguments) ? body.apply(this, arguments) : ((self) => body(self, ...arguments)); }; switch (arity) { case 0: case 1: throw new RangeError(`Invalid arity ${arity}`); case 2: return function(a, b) { if (arguments.length >= 2) return body(a, b); return function(self) { return body(self, a); }; }; case 3: return function(a, b, c) { if (arguments.length >= 3) return body(a, b, c); return function(self) { return body(self, a, b); }; }; default: return function() { if (arguments.length >= arity) return body.apply(this, arguments); const args = arguments; return function(self) { return body(self, ...args); }; }; } }; /** * Returns its input argument unchanged. * * **When to use** * * Use to return a value unchanged where a function is required. * * **Example** (Returning the same value) * * ```ts * import { identity } from "effect" * import * as assert from "node:assert" * * assert.deepStrictEqual(identity(5), 5) * ``` * * @category combinators * @since 2.0.0 */ const identity = (a) => a; /** * Returns the input value with a different static type. * * **When to use** * * Use when you need an explicit type-level cast and accept that the value is * returned unchanged at runtime. * * **Gotchas** * * This is a type-level cast only; it performs no runtime validation or * conversion. * * @see {@link satisfies} for checking assignability without changing the resulting type * * @category utility types * @since 4.0.0 */ const cast = identity; /** * Creates a zero-argument function that always returns the provided value. * * **When to use** * * Use when you need a thunk or callback that returns the same value on every * invocation. * * **Example** (Creating a constant thunk) * * ```ts * import { Function } from "effect" * import * as assert from "node:assert" * * const constNull = Function.constant(null) * * assert.deepStrictEqual(constNull(), null) * assert.deepStrictEqual(constNull(), null) * ``` * * @category constructors * @since 2.0.0 */ const constant = (value) => () => value; /** * Returns `true` when called. * * **When to use** * * Use when you need a thunk that returns `true` on every invocation. * * **Example** (Returning true from a thunk) * * ```ts * import { Function } from "effect" * import * as assert from "node:assert" * * assert.deepStrictEqual(Function.constTrue(), true) * ``` * * @category constants * @since 2.0.0 */ const constTrue = constant(true); /** * Returns `false` when called. * * **When to use** * * Use when you need a thunk that returns `false` on every invocation. * * **Example** (Returning false from a thunk) * * ```ts * import { Function } from "effect" * import * as assert from "node:assert" * * assert.deepStrictEqual(Function.constFalse(), false) * ``` * * @category constants * @since 2.0.0 */ const constFalse = constant(false); /** * Returns `null` when called. * * **When to use** * * Use when you need a thunk that returns `null` on every invocation. * * **Example** (Returning null from a thunk) * * ```ts * import { Function } from "effect" * import * as assert from "node:assert" * * assert.deepStrictEqual(Function.constNull(), null) * ``` * * @category constants * @since 2.0.0 */ const constNull = constant(null); /** * Returns `undefined` when called. * * **When to use** * * Use when you need a thunk that returns `undefined` on every invocation. * * **Example** (Returning undefined from a thunk) * * ```ts * import { Function } from "effect" * import * as assert from "node:assert" * * assert.deepStrictEqual(Function.constUndefined(), undefined) * ``` * * @category constants * @since 2.0.0 */ const constUndefined = constant(void 0); /** * Composes two functions, `ab` and `bc` into a single function that takes in an argument `a` of type `A` and returns a result of type `C`. * The result is obtained by first applying the `ab` function to `a` and then applying the `bc` function to the result of `ab`. * * **When to use** * * Use to compose exactly two unary functions into a reusable unary function. * * **Example** (Composing two functions) * * ```ts * import { Function } from "effect" * import * as assert from "node:assert" * * const increment = (n: number) => n + 1 * const square = (n: number) => n * n * * assert.strictEqual(Function.compose(increment, square)(2), 9) * ``` * * @see {@link flow} for composing a left-to-right sequence of functions * @see {@link pipe} for applying a value through a left-to-right sequence immediately * * @category combinators * @since 2.0.0 */ const compose = dual(2, (ab, bc) => (a) => bc(ab(a))); /** * Marks an impossible branch by accepting a `never` value and returning any * type. * * **When to use** * * Use when you need a return value in a branch that exhaustive checks prove * cannot be reached. * * **Gotchas** * * Calling `absurd` throws, because a value of type `never` should be * impossible at runtime. * * **Example** (Handling impossible values) * * ```ts * import { absurd } from "effect" * * const handleNever = (value: never) => { * return absurd(value) // This will throw an error if called * } * ``` * * @category utility types * @since 2.0.0 */ const absurd = (_) => { throw new Error("Called `absurd` function which should be uncallable"); }; /** * Creates a compile-time placeholder for a value of any type. * * **When to use** * * Use as a temporary typed placeholder while developing incomplete code. * * **Gotchas** * * `hole` is intended for temporary development use. If the placeholder is * evaluated at runtime, it throws. * * **Example** (Creating a development placeholder) * * ```ts * import { hole } from "effect" * * // Intentionally not called: `hole` throws if the placeholder is evaluated. * const buildUser = (id: number): { readonly id: number; readonly name: string } => ({ * id, * name: hole<string>() * }) * * console.log(typeof buildUser) // "function" * ``` * * @category utility types * @since 2.0.0 */ const hole = cast(absurd); /** * Drops the longest prefix of elements from an array that satisfy the given predicate. * * Supports both data-first and data-last (`pipe`-friendly) call styles. * * @param pred - The predicate to test each element with. * @returns A new array without the matching prefix. * @example * ```ts * import * as assert from "node:assert" * import { dropWhile, pipe } from "@local/eff" * * // data-first * assert.deepStrictEqual(dropWhile([1, 2, 3, 2, 1], (n: number) => n < 3), [3, 2, 1]) * * // data-last * assert.deepStrictEqual(pipe([1, 2, 3, 2, 1], dropWhile((n: number) => n < 3)), [3, 2, 1]) * ``` * @category array */ const dropWhile = dual(2, (xs, pred) => { const len = xs.length; let idx = 0; while (idx < len && pred(xs[idx])) idx++; return xs.slice(idx); }); /** * Takes the longest prefix of elements from an array that satisfy the given predicate. * * Supports both data-first and data-last (`pipe`-friendly) call styles. * * @param pred - The predicate to test each element with. * @returns A new array containing only the matching prefix. * @example * ```ts * import * as assert from "node:assert" * import { pipe, takeWhile } from "@local/eff" * * // data-first * assert.deepStrictEqual(takeWhile([1, 2, 3, 2, 1], (n: number) => n < 3), [1, 2]) * * // data-last * assert.deepStrictEqual(pipe([1, 2, 3, 2, 1], takeWhile((n: number) => n < 3)), [1, 2]) * ``` * @category array */ const takeWhile = dual(2, (xs, pred) => { const len = xs.length; let idx = 0; while (idx < len && pred(xs[idx])) idx++; return xs.slice(0, idx); }); //#endregion //#region src/rules/no-leaked-intersection-observer/lib.ts function isNewIntersectionObserver(node) { if (node?.type !== AST_NODE_TYPES.NewExpression) return false; const callee = Extract.unwrap(node.callee); return callee.type === AST_NODE_TYPES.Identifier && callee.name === "IntersectionObserver"; } function isFromObserver$1(context, node) { switch (true) { case node.type === AST_NODE_TYPES.Identifier: { const initNode = resolve(context, node); return isNewIntersectionObserver(initNode == null ? null : Extract.unwrap(initNode)); } case node.type === AST_NODE_TYPES.MemberExpression: return isFromObserver$1(context, node.object); default: return false; } } //#endregion //#region src/rules/no-leaked-intersection-observer/no-leaked-intersection-observer.ts const RULE_NAME$3 = "no-leaked-intersection-observer"; function getCallKind$3(context, node) { const callee = Extract.unwrap(node.callee); if (callee.type !== AST_NODE_TYPES.Identifier && callee.type !== AST_NODE_TYPES.MemberExpression) return "other"; const name = Extract.getCalleeName(node); if (name != null && isMatching(P.union("observe", "unobserve", "disconnect"))(name) && isFromObserver$1(context, callee)) return name; return "other"; } function getFunctionKind$1(node) { return getPhaseKindOfFunction(node) ?? "other"; } var no_leaked_intersection_observer_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'IntersectionObserver' created in a component or custom hook has a corresponding 'IntersectionObserver.disconnect()'." }, messages: { expectedDisconnectInControlFlow: "Dynamically added 'IntersectionObserver.observe' should be cleared all at once using 'IntersectionObserver.disconnect' in the cleanup function.", expectedDisconnectOrUnobserveInCleanup: "An 'IntersectionObserver' instance created in 'useEffect' must be disconnected in the cleanup function.", unexpectedFloatingInstance: "An 'IntersectionObserver' instance created in component or custom hook must be assigned to a variable for proper cleanup." }, schema: [] }, name: RULE_NAME$3, create: create$3, defaultOptions: [] }); function create$3(context) { if (!context.sourceCode.text.includes("IntersectionObserver")) return {}; const fEntries = []; const observers = []; const oEntries = []; const uEntries = []; const dEntries = []; return { [":function"](node) { const kind = getFunctionKind$1(node); fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { const unwrappedCallee = Extract.unwrap(node.callee); if (unwrappedCallee.type !== AST_NODE_TYPES.MemberExpression) return; const fKind = fEntries.findLast((x) => x.kind !== "other")?.kind; if (fKind == null || !ComponentPhaseRelevance.has(fKind)) return; const { object } = unwrappedCallee; match(getCallKind$3(context, node)).with("disconnect", () => { dEntries.push({ kind: "IntersectionObserver", callee: node.callee, method: "disconnect", node, observer: object, phase: fKind }); }).with("observe", () => { const [element] = node.arguments; if (element == null) return; oEntries.push({ kind: "IntersectionObserver", callee: node.callee, element, method: "observe", node, observer: object, phase: fKind }); }).with("unobserve", () => { const [element] = node.arguments; if (element == null) return; uEntries.push({ kind: "IntersectionObserver", callee: node.callee, element, method: "unobserve", node, observer: object, phase: fKind }); }).otherwise(() => null); }, ["NewExpression"](node) { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) return; if (!ComponentPhaseRelevance.has(fEntry.kind)) return; if (!isNewIntersectionObserver(node)) return; const id = resolveEnclosingAssignmentTarget(node); if (id == null) { context.report({ messageId: "unexpectedFloatingInstance", node }); return; } observers.push({ id, node, phase: fEntry.kind, phaseNode: fEntry.node }); }, ["Program:exit"]() { for (const { id, node, phaseNode } of observers) { const isInsideObserverCallback = (e) => Traverse.findParent(e.node, (n) => n === node) != null; if (dEntries.some((e) => !isInsideObserverCallback(e) && isAssignmentTargetEqual(context, e.observer, id))) continue; const oentries = oEntries.filter((e) => isAssignmentTargetEqual(context, e.observer, id)); const uentries = uEntries.filter((e) => isAssignmentTargetEqual(context, e.observer, id)); const isDynamic = (node) => node?.type === AST_NODE_TYPES.CallExpression || Check.isConditional(node); const isPhaseNode = (node) => node === phaseNode; if (oentries.some((e) => !isPhaseNode(Traverse.findParent(e.node, or(isDynamic, isPhaseNode))))) { context.report({ messageId: "expectedDisconnectInControlFlow", node }); continue; } for (const oEntry of oentries) { if (uentries.some((uEntry) => isAssignmentTargetEqual(context, uEntry.element, oEntry.element))) continue; context.report({ messageId: "expectedDisconnectOrUnobserveInCleanup", node: oEntry.node }); } } } }; } //#endregion //#region src/rules/no-leaked-interval/no-leaked-interval.ts const RULE_NAME$2 = "no-leaked-interval"; function getCallKind$2(node) { const name = Extract.getCalleeName(node); if (name != null && isMatching(P.union("setInterval", "clearInterval"))(name)) return name; return "other"; } var no_leaked_interval_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'setInterval' in a component or custom hook has a corresponding 'clearInterval'." }, messages: { expectedClearIntervalInCleanup: "A 'setInterval' created in '{{ kind }}' must be cleared with 'clearInterval' in the cleanup function.", expectedIntervalId: "A 'setInterval' must be assigned to a variable for proper cleanup." }, schema: [] }, name: RULE_NAME$2, create: create$2, defaultOptions: [] }); function create$2(context) { if (!context.sourceCode.text.includes("setInterval")) return {}; const fEntries = []; const sEntries = []; const cEntries = []; function isInverseEntry(a, b) { return isAssignmentTargetEqual(context, a.timerId, b.timerId); } return { [":function"](node) { const kind = getPhaseKindOfFunction(node) ?? "other"; fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { switch (getCallKind$2(node)) { case "setInterval": { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) break; if (!ComponentPhaseRelevance.has(fEntry.kind)) break; const intervalIdNode = resolveEnclosingAssignmentTarget(node); if (intervalIdNode == null) { context.report({ messageId: "expectedIntervalId", node }); break; } sEntries.push({ kind: "interval", callee: node.callee, node, phase: fEntry.kind, timerId: intervalIdNode }); break; } case "clearInterval": { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) break; if (!ComponentPhaseRelevance.has(fEntry.kind)) break; const [intervalIdNode] = node.arguments; if (intervalIdNode == null) break; cEntries.push({ kind: "interval", callee: node.callee, node, phase: fEntry.kind, timerId: intervalIdNode }); break; } } }, ["Program:exit"]() { for (const sEntry of sEntries) { if (cEntries.some((cEntry) => isInverseEntry(sEntry, cEntry))) continue; switch (sEntry.phase) { case "setup": case "cleanup": context.report({ data: { kind: "useEffect" }, messageId: "expectedClearIntervalInCleanup", node: sEntry.node }); continue; } } } }; } //#endregion //#region src/rules/no-leaked-resize-observer/lib.ts function isNewResizeObserver(node) { if (node?.type !== AST_NODE_TYPES.NewExpression) return false; const callee = Extract.unwrap(node.callee); return callee.type === AST_NODE_TYPES.Identifier && callee.name === "ResizeObserver"; } function isFromObserver(context, node) { switch (true) { case node.type === AST_NODE_TYPES.Identifier: { const initNode = resolve(context, node); return isNewResizeObserver(initNode == null ? null : Extract.unwrap(initNode)); } case node.type === AST_NODE_TYPES.MemberExpression: return isFromObserver(context, node.object); default: return false; } } //#endregion //#region src/rules/no-leaked-resize-observer/no-leaked-resize-observer.ts const RULE_NAME$1 = "no-leaked-resize-observer"; function getCallKind$1(context, node) { const callee = Extract.unwrap(node.callee); if (callee.type !== AST_NODE_TYPES.Identifier && callee.type !== AST_NODE_TYPES.MemberExpression) return "other"; const name = Extract.getCalleeName(node); if (name != null && isMatching(P.union("observe", "unobserve", "disconnect"))(name) && isFromObserver(context, callee)) return name; return "other"; } function getFunctionKind(node) { return getPhaseKindOfFunction(node) ?? "other"; } var no_leaked_resize_observer_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'ResizeObserver' created in a component or custom hook has a corresponding 'ResizeObserver.disconnect()'." }, messages: { expectedDisconnectInControlFlow: "Dynamically added 'ResizeObserver.observe' should be cleared all at once using 'ResizeObserver.disconnect' in the cleanup function.", expectedDisconnectOrUnobserveInCleanup: "A 'ResizeObserver' instance created in 'useEffect' must be disconnected in the cleanup function.", unexpectedFloatingInstance: "A 'ResizeObserver' instance created in component or custom hook must be assigned to a variable for proper cleanup." }, schema: [] }, name: RULE_NAME$1, create: create$1, defaultOptions: [] }); function create$1(context) { if (!context.sourceCode.text.includes("ResizeObserver")) return {}; const fEntries = []; const observers = []; const oEntries = []; const uEntries = []; const dEntries = []; return { [":function"](node) { const kind = getFunctionKind(node); fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { const unwrappedCallee = Extract.unwrap(node.callee); if (unwrappedCallee.type !== AST_NODE_TYPES.MemberExpression) return; const fKind = fEntries.findLast((x) => x.kind !== "other")?.kind; if (fKind == null || !ComponentPhaseRelevance.has(fKind)) return; const { object } = unwrappedCallee; match(getCallKind$1(context, node)).with("disconnect", () => { dEntries.push({ kind: "ResizeObserver", callee: node.callee, method: "disconnect", node, observer: object, phase: fKind }); }).with("observe", () => { const [element] = node.arguments; if (element == null) return; oEntries.push({ kind: "ResizeObserver", callee: node.callee, element, method: "observe", node, observer: object, phase: fKind }); }).with("unobserve", () => { const [element] = node.arguments; if (element == null) return; uEntries.push({ kind: "ResizeObserver", callee: node.callee, element, method: "unobserve", node, observer: object, phase: fKind }); }).otherwise(() => null); }, ["NewExpression"](node) { const fEntry = fEntries.findLast((x) => x.kind !== "other"); if (fEntry == null) return; if (!ComponentPhaseRelevance.has(fEntry.kind)) return; if (!isNewResizeObserver(node)) return; const id = resolveEnclosingAssignmentTarget(node); if (id == null) { context.report({ messageId: "unexpectedFloatingInstance", node }); return; } observers.push({ id, node, phase: fEntry.kind, phaseNode: fEntry.node }); }, ["Program:exit"]() { for (const { id, node, phaseNode } of observers) { const isInsideObserverCallback = (e) => Traverse.findParent(e.node, (n) => n === node) != null; if (dEntries.some((e) => !isInsideObserverCallback(e) && isAssignmentTargetEqual(context, e.observer, id))) continue; const oentries = oEntries.filter((e) => isAssignmentTargetEqual(context, e.observer, id)); const uentries = uEntries.filter((e) => isAssignmentTargetEqual(context, e.observer, id)); const isDynamic = (node) => node?.type === AST_NODE_TYPES.CallExpression || Check.isConditional(node); const isPhaseNode = (node) => node === phaseNode; if (oentries.some((e) => !isPhaseNode(Traverse.findParent(e.node, or(isDynamic, isPhaseNode))))) { context.report({ messageId: "expectedDisconnectInControlFlow", node }); continue; } for (const oEntry of oentries) { if (uentries.some((uEntry) => isAssignmentTargetEqual(context, uEntry.element, oEntry.element))) continue; context.report({ messageId: "expectedDisconnectOrUnobserveInCleanup", node: oEntry.node }); } } } }; } //#endregion //#region src/rules/no-leaked-timeout/no-leaked-timeout.ts const RULE_NAME = "no-leaked-timeout"; function getCallKind(node) { const name = Extract.getCalleeName(node); if (name != null && isMatching(P.union("setTimeout", "clearTimeout"))(name)) return name; return "other"; } var no_leaked_timeout_default = createRule({ meta: { type: "problem", docs: { description: "Enforces that every 'setTimeout' in a component or custom hook has a corresponding 'clearTimeout'." }, messages: { expectedClearTimeoutInCleanup: "A 'setTimeout' created in '{{ kind }}' must be cleared with 'clearTimeout' in the cleanup function.", expectedTimeoutId: "A 'setTimeout' must be assigned to a variable for proper cleanup." }, schema: [] }, name: RULE_NAME, create, defaultOptions: [] }); function create(context) { if (!context.sourceCode.text.includes("setTimeout")) return {}; const fEntries = []; const sEntries = []; const rEntries = []; function isInverseEntry(a, b) { return isAssignmentTargetEqual(context, a.timerId, b.timerId); } return { [":function"](node) { const kind = getPhaseKindOfFunction(node) ?? "other"; fEntries.push({ kind, node }); }, [":function:exit"]() { fEntries.pop(); }, ["CallExpression"](node) { const fEntry = fEntries.findLast((f) => f.kind !== "other"); if (!ComponentPhaseRelevance.has(fEntry?.kind)) return; switch (getCallKind(node)) { case "setTimeout": { const timeoutIdNode = resolveEnclosingAssignmentTarget(node); if (timeoutIdNode == null) { context.report({ messageId: "expectedTimeoutId", node }); break; } sEntries.push({ kind: "timeout", callee: node.callee, node, phase: fEntry.kind, timerId: timeoutIdNode }); break; } case "clearTimeout": { const [timeoutIdNode] = node.arguments; if (timeoutIdNode == null) break; rEntries.push({ kind: "timeout", callee: node.callee, node, phase: fEntry.kind, timerId: timeoutIdNode }); break; } } }, ["Program:exit"]() { for (const sEntry of sEntries) { if (rEntries.some((rEntry) => isInverseEntry(sEntry, rEntry))) continue; switch (sEntry.phase) { case "setup": case "cleanup": context.report({ data: { kind: "useEffect" }, messageId: "expectedClearTimeoutInCleanup", node: sEntry.node }); continue; } } } }; } //#endregion //#region src/plugin.ts const plugin = { meta: { name: name$1, version }, rules: { "no-leaked-event-listener": no_leaked_event_listener_default, "no-leaked-fetch": no_leaked_fetch_default, "no-leaked-intersection-observer": no_leaked_intersection_observer_default, "no-leaked-interval": no_leaked_interval_default, "no-leaked-resize-observer": no_leaked_resize_observer_default, "no-leaked-timeout": no_leaked_timeout_default } }; //#endregion //#region src/configs/recommended.ts var recommended_exports = /* @__PURE__ */ __exportAll({ name: () => name, plugins: () => plugins, rules: () => rules, settings: () => settings }); const name = "react-web-api/recommended"; const rules = { "react-web-api/no-leaked-event-listener": "warn", "react-web-api/no-leaked-fetch": "warn", "react-web-api/no-leaked-intersection-observer": "warn", "react-web-api/no-leaked-interval": "warn", "react-web-api/no-leaked-resize-observer": "warn", "react-web-api/no-leaked-timeout": "warn" }; const plugins = { "react-web-api": plugin }; const settings = { "react-x": DEFAULT_ESLINT_REACT_SETTINGS }; //#endregion //#region src/index.ts const finalPlugin = { ...plugin, configs: { ["recommended"]: recommended_exports } }; //#endregion export { finalPlugin as default };