From c7e94441f8cc263935e60a6c920dd9673af9b8c0 Mon Sep 17 00:00:00 2001 From: JC Franco Date: Fri, 26 Jan 2024 15:03:42 -0800 Subject: [PATCH] feat: add `no-dynamic-createelement` rule (#8656) **Related Issue:** #8651 ## Summary This adds a custom ESLint rule to ensure Stencil bundles and auto-defines child components created via `document.createElement`. --- packages/calcite-components/.eslintrc.cjs | 6 +++ .../docs/no-dynamic-createelement.md | 13 ++++++ .../src/configs/base.ts | 1 + .../src/rules/index.ts | 2 + .../src/rules/no-dynamic-createelement.ts | 46 +++++++++++++++++++ .../no-dynamic-createelement.good.tsx | 19 ++++++++ .../no-dynamic-createelement.spec.ts | 29 ++++++++++++ .../no-dynamic-createelement.wrong.tsx | 16 +++++++ 8 files changed, 132 insertions(+) create mode 100644 packages/eslint-plugin-calcite-components/docs/no-dynamic-createelement.md create mode 100644 packages/eslint-plugin-calcite-components/src/rules/no-dynamic-createelement.ts create mode 100644 packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.good.tsx create mode 100644 packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.spec.ts create mode 100644 packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.wrong.tsx diff --git a/packages/calcite-components/.eslintrc.cjs b/packages/calcite-components/.eslintrc.cjs index 8d7ea161412..ecdf79c1b67 100644 --- a/packages/calcite-components/.eslintrc.cjs +++ b/packages/calcite-components/.eslintrc.cjs @@ -157,4 +157,10 @@ module.exports = { ignorePrivate: true, }, }, + overrides: [{ + files: ["**/*.e2e.ts", "src/tests/**/*"], + rules: { + "@esri/calcite-components/no-dynamic-createelement": "off", + } + }] }; diff --git a/packages/eslint-plugin-calcite-components/docs/no-dynamic-createelement.md b/packages/eslint-plugin-calcite-components/docs/no-dynamic-createelement.md new file mode 100644 index 00000000000..0da5b24a62d --- /dev/null +++ b/packages/eslint-plugin-calcite-components/docs/no-dynamic-createelement.md @@ -0,0 +1,13 @@ +# no-dynamic-createelement + +Helps prevent usage of dynamic element tags used with `createElement`. This ensures the component is properly bundled and auto-defined in Stencil's `components` output target. + +## Config + +No config is needed + +## Usage + +```json +{ "@esri/calcite-components/no-dynamic-createelement": "error" } +``` diff --git a/packages/eslint-plugin-calcite-components/src/configs/base.ts b/packages/eslint-plugin-calcite-components/src/configs/base.ts index 2cf871f9739..89ff13683e7 100644 --- a/packages/eslint-plugin-calcite-components/src/configs/base.ts +++ b/packages/eslint-plugin-calcite-components/src/configs/base.ts @@ -18,6 +18,7 @@ export default { rules: { "@esri/calcite-components/ban-props-on-host": 2, "@esri/calcite-components/enforce-ref-last-prop": 2, + "@esri/calcite-components/no-dynamic-createelement": 2, "@esri/calcite-components/require-event-emitter-type": 2, "@esri/calcite-components/strict-boolean-attributes": 2, }, diff --git a/packages/eslint-plugin-calcite-components/src/rules/index.ts b/packages/eslint-plugin-calcite-components/src/rules/index.ts index 239a3ce77e0..4499f42a295 100644 --- a/packages/eslint-plugin-calcite-components/src/rules/index.ts +++ b/packages/eslint-plugin-calcite-components/src/rules/index.ts @@ -1,6 +1,7 @@ import banEvents from "./ban-events"; import banPropsOnHost from "./ban-props-on-host"; import enforceRefLastProp from "./enforce-ref-last-prop"; +import noDynamicCreateelement from "./no-dynamic-createelement"; import requireEventEmitterType from "./require-event-emitter-type"; import strictBooleanAttributes from "./strict-boolean-attributes"; @@ -8,6 +9,7 @@ export default { "ban-events": banEvents, "ban-props-on-host": banPropsOnHost, "enforce-ref-last-prop": enforceRefLastProp, + "no-dynamic-createelement": noDynamicCreateelement, "require-event-emitter-type": requireEventEmitterType, "strict-boolean-attributes": strictBooleanAttributes, }; diff --git a/packages/eslint-plugin-calcite-components/src/rules/no-dynamic-createelement.ts b/packages/eslint-plugin-calcite-components/src/rules/no-dynamic-createelement.ts new file mode 100644 index 00000000000..83217ccf7c4 --- /dev/null +++ b/packages/eslint-plugin-calcite-components/src/rules/no-dynamic-createelement.ts @@ -0,0 +1,46 @@ +import { Rule } from "eslint"; + +function isCreateElement(node) { + return ( + node?.callee?.type === "MemberExpression" && + node?.callee?.object?.name === "document" && + node?.callee?.property?.name === "createElement" && + node.arguments.length >= 1 + ); +} + +function isStaticValue(arg) { + return arg.type === "Literal" || (arg.type === "TemplateLiteral" && arg.expressions.length === 0); +} + +const rule: Rule.RuleModule = { + meta: { + docs: { + description: + "This ensures supporting components created with `document.createElement()` are auto-defined in Stencil's `components` output target.", + recommended: true, + }, + fixable: "code", + schema: [], + type: "problem", + }, + + create(context) { + return { + CallExpression(node) { + if (!node.arguments[0] || isStaticValue(node.arguments[0])) { + return; + } + + if (isCreateElement(node)) { + return context.report({ + node, + message: "Calls to document.createElement() should use string literals", + }); + } + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.good.tsx b/packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.good.tsx new file mode 100644 index 00000000000..9291ab12dc9 --- /dev/null +++ b/packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.good.tsx @@ -0,0 +1,19 @@ +// @ts-nocheck +@Component({ tag: "sample-tag" }) +export class SampleTag { + @Prop() + type: "one" | "two" = "one"; + + connectedCallback() { + const child = + this.type === "one" + ? document.createElement("my-component-1") + : document.createElement("my-component-2"); + this.el.append(child); + this.internalEl = child; + } + + disconnectedCallback() { + this.internalEl.remove(); + } +} diff --git a/packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.spec.ts b/packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.spec.ts new file mode 100644 index 00000000000..197aabd331e --- /dev/null +++ b/packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.spec.ts @@ -0,0 +1,29 @@ +import rule from "../../../../src/rules/no-dynamic-createelement"; +import { ruleTester } from "stencil-eslint-core"; +import * as path from "path"; +import * as fs from "fs"; + +const projectPath = path.resolve(__dirname, "../../../tsconfig.json"); + +describe("no-dynamic-createelement rule", () => { + const files = { + good: path.resolve(__dirname, "no-dynamic-createelement.good.tsx"), + wrong: path.resolve(__dirname, "no-dynamic-createelement.wrong.tsx"), + }; + ruleTester(projectPath).run("no-dynamic-createelement", 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/no-dynamic-createelement/no-dynamic-createelement.wrong.tsx b/packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.wrong.tsx new file mode 100644 index 00000000000..459da27cf0e --- /dev/null +++ b/packages/eslint-plugin-calcite-components/tests/lib/rules/no-dynamic-createelement/no-dynamic-createelement.wrong.tsx @@ -0,0 +1,16 @@ +// @ts-nocheck +@Component({ tag: "sample-tag" }) +export class SampleTag { + @Prop() + type: "one" | "two" = "one"; + + connectedCallback() { + const child = document.createElement(this.type === "one" ? "my-component-1" : "my-component-2"); + this.el.append(child); + this.internalEl = child; + } + + disconnectedCallback() { + this.internalEl.remove(); + } +}