diff --git a/packages/eslint-plugin-calcite-components/README.md b/packages/eslint-plugin-calcite-components/README.md index c705038e4ae..55fa7dcec36 100644 --- a/packages/eslint-plugin-calcite-components/README.md +++ b/packages/eslint-plugin-calcite-components/README.md @@ -35,7 +35,7 @@ Add a new `lint` script to `package.json`: Then you can run the linter: -``` +```shell npm run lint ``` @@ -49,6 +49,10 @@ This rule helps prevent usage of specific events and allows suggesting alternati This rule catches props/attributes that should be in the encapsulated HTML structure and not on the host element. +- [`@esri/calcite-components/enforce-ref-last-prop`](./docs/enforce-ref-last-prop.md) + +This rule ensures this is a workaround for a [Stencil bug](https://github.com/ionic-team/stencil/issues/4074) where ref is called in the specified order and not after initializing element with all its attributes/properties. This can cause attributes/properties to be outdated by the time the callback is invoked. This rule ensures the `ref` attribute is ordered last in a JSXElement to keep it up-to-date. + - [`@esri/calcite-components/require-event-emitter-type`](./docs/require-event-emitter-type.md) This rule helps enforce the payload type to EventEmitters to avoid misleading `any` type on the CustomEvent detail object. @@ -62,6 +66,7 @@ This rule catches boolean props that are initialized in a way that does not conf ```json { "@esri/calcite-components/ban-props-on-host": "error", + "@esri/calcite-components/enforce-ref-last-prop": "error", "@esri/calcite-components/require-event-emitter-type": "error", "@esri/calcite-components/strict-boolean-attributes": "error" } @@ -83,4 +88,4 @@ See use restrictions at For additional information, contact: Environmental Systems Research Institute, Inc. Attn: Contracts and Legal Services Department 380 New York Street Redlands, California, USA 92373 USA -email: contracts@esri.com +email: diff --git a/packages/eslint-plugin-calcite-components/docs/enforce-ref-last-prop.md b/packages/eslint-plugin-calcite-components/docs/enforce-ref-last-prop.md new file mode 100644 index 00000000000..88cd6a1508d --- /dev/null +++ b/packages/eslint-plugin-calcite-components/docs/enforce-ref-last-prop.md @@ -0,0 +1,15 @@ +# enforce-ref-last-prop + +This updates `ref` usage to work around a Stencil bug where ref is called in the specified order and not after initializing element with all its attributes/properties. This can cause attributes/properties to be outdated by the time the callback is invoked. + +This rule ensures the `ref` attribute is ordered last in a JSXElement to keep it up-to-date. + +## Config + +No config is needed + +## Usage + +```json +{ "@esri/calcite-components/enforce-ref-last-prop": "error" } +``` diff --git a/packages/eslint-plugin-calcite-components/src/configs/base.ts b/packages/eslint-plugin-calcite-components/src/configs/base.ts index b0d55b78aa1..2cf871f9739 100644 --- a/packages/eslint-plugin-calcite-components/src/configs/base.ts +++ b/packages/eslint-plugin-calcite-components/src/configs/base.ts @@ -7,19 +7,20 @@ export default { ecmaVersion: 2018, sourceType: "module", ecmaFeatures: { - jsx: true - } + jsx: true, + }, }, env: { es2020: true, - browser: true + browser: true, }, plugins: ["@esri/calcite-components"], rules: { "@esri/calcite-components/ban-props-on-host": 2, + "@esri/calcite-components/enforce-ref-last-prop": 2, "@esri/calcite-components/require-event-emitter-type": 2, - "@esri/calcite-components/strict-boolean-attributes": 2 - } - } - ] + "@esri/calcite-components/strict-boolean-attributes": 2, + }, + }, + ], }; diff --git a/packages/eslint-plugin-calcite-components/src/configs/recommended.ts b/packages/eslint-plugin-calcite-components/src/configs/recommended.ts index 7b3b0fe4563..65364ff1bea 100644 --- a/packages/eslint-plugin-calcite-components/src/configs/recommended.ts +++ b/packages/eslint-plugin-calcite-components/src/configs/recommended.ts @@ -2,7 +2,8 @@ export default { extends: ["plugin:@esri/calcite-components/base"], rules: { "@esri/calcite-components/ban-props-on-host": 2, + "@esri/calcite-components/enforce-ref-last-prop": 2, "@esri/calcite-components/require-event-emitter-type": 2, - "@esri/calcite-components/strict-boolean-attributes": 2 - } + "@esri/calcite-components/strict-boolean-attributes": 2, + }, }; diff --git a/packages/eslint-plugin-calcite-components/src/rules/enforce-ref-last-prop.ts b/packages/eslint-plugin-calcite-components/src/rules/enforce-ref-last-prop.ts new file mode 100644 index 00000000000..cf1eafe6f99 --- /dev/null +++ b/packages/eslint-plugin-calcite-components/src/rules/enforce-ref-last-prop.ts @@ -0,0 +1,42 @@ +import { Rule } from "eslint"; +import type { JSXAttribute, JSXSpreadAttribute, JSXOpeningElement } from "@babel/types"; + +const rule: Rule.RuleModule = { + meta: { + docs: { + description: `This updates ref usage to work around a Stencil bug where ref is called in the specified order and not after initializing element with all its attributes/properties. This can cause attributes/properties to be outdated by the time the callback is invoked. This rule ensures the ref attribute is ordered last in a JSXElement to keep it up-to-date.`, + recommended: true, + }, + fixable: "code", + schema: [], + type: "problem", + }, + + create(context): Rule.RuleListener { + return { + JSXIdentifier(node) { + const openingElement = node.parent as JSXOpeningElement; + if (openingElement.type === "JSXOpeningElement") { + const attributes: string[] = []; + + openingElement.attributes.forEach((attr: JSXAttribute | JSXSpreadAttribute) => { + if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier") { + attributes.push(attr.name.name); + } + }); + + const refAttribute = attributes.find((attr: string) => attr === "ref"); + + if (refAttribute && attributes.indexOf(refAttribute) !== attributes.length - 1) { + context.report({ + node, + message: `Attribute "ref" should be placed last in a JSXElement so node attrs/props are in sync. If it's called in the specified order, attributes/properties can be outdated by the time the callback is invoked.`, + }); + } + } + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin-calcite-components/src/rules/index.ts b/packages/eslint-plugin-calcite-components/src/rules/index.ts index b6b386f8f61..239a3ce77e0 100644 --- a/packages/eslint-plugin-calcite-components/src/rules/index.ts +++ b/packages/eslint-plugin-calcite-components/src/rules/index.ts @@ -1,11 +1,13 @@ import banEvents from "./ban-events"; import banPropsOnHost from "./ban-props-on-host"; +import enforceRefLastProp from "./enforce-ref-last-prop"; import requireEventEmitterType from "./require-event-emitter-type"; import strictBooleanAttributes from "./strict-boolean-attributes"; export default { "ban-events": banEvents, "ban-props-on-host": banPropsOnHost, + "enforce-ref-last-prop": enforceRefLastProp, "require-event-emitter-type": requireEventEmitterType, - "strict-boolean-attributes": strictBooleanAttributes + "strict-boolean-attributes": strictBooleanAttributes, }; diff --git a/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.good.tsx b/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.good.tsx new file mode 100644 index 00000000000..9cee3384a97 --- /dev/null +++ b/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.good.tsx @@ -0,0 +1,23 @@ +// @ts-nocheck +@Component({ tag: "sample-tag" }) +export class SampleTag { + render() { + return ( + +
{ + /* click! */ + }} + tabIndex={0} + ref={(el: HTMLDivElement): void => { + /* refEl */ + }} + > + test +
+
+ ); + } +} diff --git a/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.spec.ts b/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.spec.ts new file mode 100644 index 00000000000..aa6dbe1d1b4 --- /dev/null +++ b/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.spec.ts @@ -0,0 +1,29 @@ +import rule from "../../../../src/rules/enforce-ref-last-prop"; +import { ruleTester } from "stencil-eslint-core"; +import * as path from "path"; +import * as fs from "fs"; + +const projectPath = path.resolve(__dirname, "../../../tsconfig.json"); + +describe("enforce-ref-last-prop rule", () => { + const files = { + good: path.resolve(__dirname, "enforce-ref-last-prop.good.tsx"), + wrong: path.resolve(__dirname, "enforce-ref-last-prop.wrong.tsx"), + }; + ruleTester(projectPath).run("enforce-ref-last-prop", rule, { + valid: [ + { + code: fs.readFileSync(files.good, "utf8"), + filename: files.good, + }, + ], + + invalid: [ + { + code: fs.readFileSync(files.wrong, "utf8"), + filename: files.wrong, + errors: 1, + }, + ], + }); +}); diff --git a/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.wrong.tsx b/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.wrong.tsx new file mode 100644 index 00000000000..b827978da3d --- /dev/null +++ b/packages/eslint-plugin-calcite-components/tests/lib/rules/enforce-ref-last-prop/enforce-ref-last-prop.wrong.tsx @@ -0,0 +1,23 @@ +// @ts-nocheck +@Component({ tag: "sample-tag" }) +export class SampleTag { + render() { + return ( + +
{ + /* refEl */ + }} + class="some-class" + id={`${guid}-element`} + onClick={() => { + /* click! */ + }} + tabIndex={0} + > + test +
+
+ ); + } +}