diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d22fe0..f16d9eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Add rule GCI2001 "Avoid polling without checking page visibility"
- [#84](https://github.com/green-code-initiative/creedengo-javascript/pull/84) Add rule GCI535 "No imported number format library"
## [3.1.0] - 2026-05-10
diff --git a/eslint-plugin/lib/rules/no-polling-without-visibility-check.js b/eslint-plugin/lib/rules/no-polling-without-visibility-check.js
new file mode 100644
index 0000000..836069c
--- /dev/null
+++ b/eslint-plugin/lib/rules/no-polling-without-visibility-check.js
@@ -0,0 +1,120 @@
+/*
+ * creedengo JavaScript plugin - Provides rules to reduce the environmental footprint of your JavaScript programs
+ * Copyright © 2023 Green Code Initiative (https://green-code-initiative.org)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+"use strict";
+
+/** @type {import("eslint").Rule.RuleModule} */
+module.exports = {
+ meta: {
+ type: "suggestion",
+ docs: {
+ description: "Avoid polling without checking page visibility",
+ category: "eco-design",
+ recommended: "warn",
+ },
+ messages: {
+ AvoidPollingWithoutVisibilityCheck:
+ "Add a visibilitychange event listener to pause polling when the page is hidden.",
+ },
+ schema: [],
+ },
+ create: function (context) {
+ /** @type {import("eslint").Rule.Node[]} */
+ const pollingCalls = [];
+ let hasVisibilityChangeListener = false;
+
+ /**
+ * Stack of enclosing named function names (null for anonymous functions).
+ * Used to detect recursive setTimeout patterns.
+ * @type {(string|null)[]}
+ */
+ const functionNameStack = [];
+
+ function isVisibilityChangeListener(node) {
+ return (
+ node.callee.type === "MemberExpression" &&
+ node.callee.property.name === "addEventListener" &&
+ node.arguments.length >= 1 &&
+ node.arguments[0].type === "Literal" &&
+ node.arguments[0].value === "visibilitychange"
+ );
+ }
+
+ function isSetInterval(node) {
+ return (
+ node.callee.type === "Identifier" &&
+ node.callee.name === "setInterval"
+ );
+ }
+
+ /**
+ * Detects `setTimeout(fnName, delay)` where `fnName` is an enclosing
+ * function — i.e. a recursive polling pattern.
+ */
+ function isRecursiveSetTimeout(node) {
+ if (
+ node.callee.type !== "Identifier" ||
+ node.callee.name !== "setTimeout" ||
+ node.arguments.length < 1
+ ) {
+ return false;
+ }
+ const firstArg = node.arguments[0];
+ return (
+ firstArg.type === "Identifier" &&
+ functionNameStack.includes(firstArg.name)
+ );
+ }
+
+ return {
+ FunctionDeclaration(node) {
+ functionNameStack.push(node.id ? node.id.name : null);
+ },
+ "FunctionDeclaration:exit"() {
+ functionNameStack.pop();
+ },
+ FunctionExpression(node) {
+ functionNameStack.push(node.id ? node.id.name : null);
+ },
+ "FunctionExpression:exit"() {
+ functionNameStack.pop();
+ },
+
+ CallExpression(node) {
+ if (isVisibilityChangeListener(node)) {
+ hasVisibilityChangeListener = true;
+ return;
+ }
+ if (isSetInterval(node) || isRecursiveSetTimeout(node)) {
+ pollingCalls.push(node);
+ }
+ },
+
+ "Program:exit"() {
+ if (!hasVisibilityChangeListener) {
+ for (const node of pollingCalls) {
+ context.report({
+ node,
+ messageId: "AvoidPollingWithoutVisibilityCheck",
+ });
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/eslint-plugin/tests/lib/rules/no-polling-without-visibility-check.test.js b/eslint-plugin/tests/lib/rules/no-polling-without-visibility-check.test.js
new file mode 100644
index 0000000..9bc1c17
--- /dev/null
+++ b/eslint-plugin/tests/lib/rules/no-polling-without-visibility-check.test.js
@@ -0,0 +1,139 @@
+/*
+ * creedengo JavaScript plugin - Provides rules to reduce the environmental footprint of your JavaScript programs
+ * Copyright © 2023 Green Code Initiative (https://green-code-initiative.org)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+"use strict";
+
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require("../../../lib/rules/no-polling-without-visibility-check");
+const { RuleTester } = require("eslint");
+const { describe, it } = require("node:test");
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: "module",
+ },
+});
+
+const expectedError = {
+ messageId: "AvoidPollingWithoutVisibilityCheck",
+};
+
+const tests = {
+ valid: [
+ // setInterval with visibilitychange listener
+ `
+ let intervalId;
+
+ function startPolling() {
+ intervalId = setInterval(() => fetchData(), 5000);
+ }
+
+ function stopPolling() {
+ clearInterval(intervalId);
+ }
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ startPolling();
+ } else {
+ stopPolling();
+ }
+ });
+
+ startPolling();
+ `,
+
+ // Recursive setTimeout with visibilitychange listener
+ `
+ let timeoutId;
+
+ function poll() {
+ fetchData();
+ timeoutId = setTimeout(poll, 5000);
+ }
+
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') {
+ poll();
+ } else {
+ clearTimeout(timeoutId);
+ }
+ });
+
+ poll();
+ `,
+
+ // setTimeout that is NOT recursive — should not be flagged
+ `
+ setTimeout(() => doSomethingOnce(), 1000);
+ `,
+
+ // No polling at all
+ `
+ function fetchOnce() {
+ fetch('/api/data').then(r => r.json()).then(console.log);
+ }
+ fetchOnce();
+ `,
+ ],
+
+ invalid: [
+ // setInterval without visibilitychange listener
+ {
+ code: `
+ setInterval(() => fetchData(), 5000);
+ `,
+ errors: [expectedError],
+ },
+
+ // Recursive setTimeout without visibilitychange listener
+ {
+ code: `
+ function poll() {
+ fetchData();
+ setTimeout(poll, 5000);
+ }
+ poll();
+ `,
+ errors: [expectedError],
+ },
+
+ // Multiple setInterval calls — each should be reported
+ {
+ code: `
+ setInterval(() => syncData(), 3000);
+ setInterval(() => refreshUI(), 10000);
+ `,
+ errors: [expectedError, expectedError],
+ },
+ ],
+};
+
+describe("no-polling-without-visibility-check", () => {
+ it("no-polling-without-visibility-check", () => {
+ ruleTester.run("no-polling-without-visibility-check", rule, tests);
+ });
+});
diff --git a/sonar-plugin/pom.xml b/sonar-plugin/pom.xml
index 712eec7..9205d29 100644
--- a/sonar-plugin/pom.xml
+++ b/sonar-plugin/pom.xml
@@ -48,7 +48,7 @@
${encoding}
${encoding}
- 3.0.0
+ feature-new-rule-no-polling-without-visibility-check-SNAPSHOT
13.0.0.3026
11.8.0.37897
1.25.1.3002
diff --git a/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/CheckList.java b/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/CheckList.java
index 820272a..506167f 100644
--- a/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/CheckList.java
+++ b/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/CheckList.java
@@ -45,6 +45,7 @@ public static List> getAllHooks() {
NoImportedNumberFormatLibrary.class,
NoMultipleAccessDomElement.class,
NoMultipleStyleChanges.class,
+ NoPollingWithoutVisibilityCheck.class,
NoTorch.class,
PreferCollectionsWithPagination.class,
PreferLighterFormatsForImageFiles.class,
diff --git a/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/checks/NoPollingWithoutVisibilityCheck.java b/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/checks/NoPollingWithoutVisibilityCheck.java
new file mode 100644
index 0000000..8a68c04
--- /dev/null
+++ b/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/checks/NoPollingWithoutVisibilityCheck.java
@@ -0,0 +1,37 @@
+/*
+ * Creedengo JavaScript plugin - Provides rules to reduce the environmental footprint of your JavaScript programs
+ * Copyright © 2023 Green Code Initiative (https://green-code-initiative.org)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.greencodeinitiative.creedengo.javascript.checks;
+
+import org.sonar.check.Rule;
+import org.sonar.plugins.javascript.api.EslintHook;
+import org.sonar.plugins.javascript.api.JavaScriptRule;
+import org.sonar.plugins.javascript.api.TypeScriptRule;
+
+@JavaScriptRule
+@TypeScriptRule
+@Rule(key = NoPollingWithoutVisibilityCheck.RULE_KEY)
+public class NoPollingWithoutVisibilityCheck implements EslintHook {
+
+ public static final String RULE_KEY = "GCI2001";
+
+ @Override
+ public String eslintKey() {
+ return "@creedengo/no-polling-without-visibility-check";
+ }
+
+}
diff --git a/test-project/sonar-project.properties b/test-project/sonar-project.properties
index 764219c..58c877b 100644
--- a/test-project/sonar-project.properties
+++ b/test-project/sonar-project.properties
@@ -1,3 +1,5 @@
sonar.exclusions=node_modules
sonar.sources=src
sonar.projectKey=creedengo-javascript-test-project
+sonar.host.url=http://localhost:9000
+sonar.token=sqa_31a11e64c69be98c50572f3bcdb3811f0255dfcd
diff --git a/test-project/src/no-polling-without-visibility-check.js b/test-project/src/no-polling-without-visibility-check.js
new file mode 100644
index 0000000..c0a8368
--- /dev/null
+++ b/test-project/src/no-polling-without-visibility-check.js
@@ -0,0 +1,19 @@
+// Non compliant: setInterval without visibilitychange listener
+setInterval(() => {
+ fetch("/api/data").then((res) => res.json()).then(console.log);
+}, 3000);
+
+// Non compliant: second setInterval without visibilitychange listener
+setInterval(() => {
+ console.log("Ping server...");
+}, 5000);
+
+// Non compliant: recursive setTimeout without visibilitychange listener
+function poll() {
+ fetch("/api/status")
+ .then((res) => res.json())
+ .then((data) => console.log(data));
+ setTimeout(poll, 4000);
+}
+
+poll();
diff --git a/test-project/yarn.lock b/test-project/yarn.lock
index 4cd76f6..1a956da 100644
--- a/test-project/yarn.lock
+++ b/test-project/yarn.lock
@@ -21,10 +21,10 @@ __metadata:
"@creedengo/eslint-plugin@file:../eslint-plugin::locator=creedengo-javascript-test-project%40workspace%3A.":
version: 3.1.0
- resolution: "@creedengo/eslint-plugin@file:../eslint-plugin#../eslint-plugin::hash=30e64e&locator=creedengo-javascript-test-project%40workspace%3A."
+ resolution: "@creedengo/eslint-plugin@file:../eslint-plugin#../eslint-plugin::hash=a3cc81&locator=creedengo-javascript-test-project%40workspace%3A."
peerDependencies:
eslint: ^9.0.0 || ^10.0.0
- checksum: 10c0/35357906578cb26b758f44fb8fdb12656de3a6d3297d97002f3a1c755066b81e1321badd1f4a01a9e9d156b0545b5611774c2ed83e80600168c134f0dc0846fd
+ checksum: 10c0/672076df8ce6a5d9a44bde94b519c8a00eb3082c7e5ea4cc6384fefec7c8d4c8f697dcb5ec58e0b603f4b3f485e4e3b123db711c06cd2957d0853263f0442c1d
languageName: node
linkType: hard