Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,47 @@ import testingLibrary from "eslint-plugin-testing-library";
import noRelativeImportPaths from "eslint-plugin-no-relative-import-paths";
import unusedImports from "eslint-plugin-unused-imports";

import requireFutureBlockCapture from "./eslint/rules/require-future-block-capture.js";

@tokland tokland Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is the first time we do this and we are defining best practices, idea: could we write the rule in TypeScript? .nvmrc pins Node 24 (which has native type stripping), so renaming the rule to .ts and pointing this import at it should just work -> verified with yarn lint, the rule still fires:

src/lint-demo-3.ts
  2:27  error  Use `await $(...)` inside `Future.block`  local/require-future-block-capture

That would let it use ESLintUtils.RuleCreator from @typescript-eslint/utils (already a dependency) instead of untyped context/node, and drop the @ts-expect-error in the spec.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think writing the rule in TypeScript wold be better. The only downside is that usually downstream projects do not have node updated to latest LTS versions. In those cases, compiling the rules to CommonJS would be an option, but the tradeoff is that linting would have a build prerequisite.

If we continue to add custom rules (or even a preset of existing rules to enforce coding guidelines), the ideal might be having a separate package like @eyeseetea/eslint-plugin, and inside that package have all custom rules written in TypeScript, but publishing the JavaScript compilation. Then the clients would include something like:

import eyeseetea from "@eyeseetea/eslint-plugin";

export default [
    eyeseetea.configs.recommended,
];

Please let me know what do you think, if it is better to move it to TypeSript now (and maybe include a build config for exporting to other projects where node does not support TypeScript), or leave it for a follow-up if we do more extensive work in eslint changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd keep it simple: Here in the skeleton -> TS. Whenever we want to target a <24 Node -> JS.


// ESLint 9 flat config, converted from .eslintrc.json. Same rule set: the file
// is longer because flat config spells out what `extends` and `env` used to imply.
export default tseslint.config(
{ ignores: ["build/**", "src/locales/**", "src/**/snapshots/*.ts", "**/*.d.ts"] },
{
ignores: [
"dist/**",
"build/**",
"node_modules/**",
"coverage/**",
"eslint.config.*",
"src/locales/**",
"src/**/snapshots/*.ts",
"**/*.d.ts",
],
},

js.configs.recommended,
...tseslint.configs.recommended,
react.configs.flat.recommended,
react.configs.flat["jsx-runtime"],
reactHooks.configs.flat.recommended,

{
languageOptions: {
parserOptions: { project: "./tsconfig.json" },
parserOptions: {
ecmaVersion: "latest",
project: "./tsconfig.json",
sourceType: "module",
},
},
settings: {
react: { pragma: "React", version: "16.6.0" },
react: { version: "detect" },
},
plugins: {
local: {
rules: {
"require-future-block-capture": requireFutureBlockCapture,
},
},
"react-hooks": reactHooks,
"no-relative-import-paths": noRelativeImportPaths,
"unused-imports": unusedImports,
Expand Down Expand Up @@ -77,11 +101,14 @@ export default tseslint.config(

"react-hooks/rules-of-hooks": "warn",
"react-hooks/exhaustive-deps": "warn",
// TODO: Enable `react-hooks/set-state-in-effect`.
"react-hooks/set-state-in-effect": "off",

"no-relative-import-paths/no-relative-import-paths": [
"error",
{ allowSameFolder: true, rootDir: "src", prefix: "$" },
],
"local/require-future-block-capture": "error",
},
},

Expand Down
99 changes: 99 additions & 0 deletions eslint/rules/require-future-block-capture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
function isIdentifier(node, name) {
return node?.type === "Identifier" && node.name === name;
}

function isFutureBlockCall(node) {
return (
node?.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
isIdentifier(node.callee.object, "Future") &&
isIdentifier(node.callee.property, "block")
);
}

function isFutureBlockFactoryCall(node) {
return (
node?.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
isIdentifier(node.callee.object, "Future") &&
isIdentifier(node.callee.property, "block_")
);
}

function getBlockCaptureParam(functionNode) {
const [firstParam] = functionNode.params;
return firstParam?.type === "Identifier" ? firstParam : null;
}

function getFunctionAncestor(sourceCode, node) {
const ancestors = sourceCode.getAncestors(node);
for (let idx = ancestors.length - 1; idx >= 0; idx -= 1) {
const ancestor = ancestors[idx];
if (ancestor.type === "ArrowFunctionExpression" || ancestor.type === "FunctionExpression") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: FunctionDeclaration is not in this check, so an await inside a nested async function is attributed to the outer Future.block callback. The same code as an arrow function passes.

Future.block(async $ => {
    async function helper() { await fetch(url); }          // FunctionDeclaration
    return $(Future.fromPromise(helper()));
});

Future.block(async $ => {
    const helper = async () => { await fetch(url); };      // ArrowFunctionExpression
    return $(Future.fromPromise(helper()));
});

yarn lint:

src/lint-demo-1.ts
  6:31  error  Use `await $(...)` inside `Future.block`  local/require-future-block-capture

✖ 1 problem (1 error, 0 warnings)

Suggest adding ancestor.type === "FunctionDeclaration" here.

return ancestor;
}
}

return null;
}

function isFutureBlockCallback(functionNode) {
const parent = functionNode.parent;
if (parent?.type !== "CallExpression") return false;

if (isFutureBlockCall(parent) && parent.arguments[0] === functionNode) {
return true;
}

if (parent.arguments[0] !== functionNode) return false;

const callee = parent.callee;
if (callee.type !== "CallExpression") return false;

return isFutureBlockFactoryCall(callee);
}

module.exports = {
meta: {
type: "problem",
docs: {
description:
"Require await calls inside Future.block callbacks to go through the capture function",
},
schema: [],
messages: {
wrapAwait: "Use `await {{capture}}(...)` inside `Future.block`.",
},
},
create(context) {
const sourceCode = context.sourceCode;

return {
AwaitExpression(node) {
const functionNode = getFunctionAncestor(sourceCode, node);
if (!functionNode) return;
if (!isFutureBlockCallback(functionNode)) return;

const captureParam = getBlockCaptureParam(functionNode);
if (!captureParam) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False negative: when the callback has no capture param or a destructured one the rule skips it, so every await in it goes unreported, and in that case no await can be safe.

Future.block(async () => {
    await fetch(url); // unsafe, but no diagnostic: no capture param
});

Future.block(async ({ length }) => {
    await fetch(url); // unsafe, but no diagnostic: destructured param
    return length;
});

yarn lint: no output, exit 0.

Suggest reporting every AwaitExpression when captureParam is null (message can fall back to $).


const awaited = node.argument;
if (
awaited.type === "CallExpression" &&
awaited.callee.type === "Identifier" &&
awaited.callee.name === captureParam.name
) {
return;
}

context.report({
node,
messageId: "wrapAwait",
data: { capture: captureParam.name },
});
},
};
},
};
37 changes: 37 additions & 0 deletions eslint/rules/require-future-block-capture.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Linter } from "eslint";
import { describe, expect, test } from "vitest";

// @ts-expect-error Local CommonJS rule has no TypeScript declaration.
import requireFutureBlockCapture from "./require-future-block-capture.js";

function lint(source: string) {
const linter = new Linter({ configType: "eslintrc" });

@tokland tokland Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configType: "eslintrc" and linter.defineRule are deprecated in ESLint 9 and removed in 10, so the first bump will make this spec throw at construction time. Against eslint@10.11.0:

TypeError: The 'configType' option value must be 'flat'. The value 'eslintrc' is not supported.
    at new Linter (node_modules/eslint/lib/linter/linter.js:790:10)

Flat-config equivalent, mirroring how eslint.config.mjs loads the rule (verified: 3/3 tests pass on 9.39.5, and the same config runs on 10.11.0):

const linter = new Linter();

return linter.verifyAndFix(source, {
    languageOptions: { ecmaVersion: 2022, sourceType: "module" },
    plugins: { local: { rules: { "require-future-block-capture": requireFutureBlockCapture } } },
    rules: { "local/require-future-block-capture": "error" },
});

linter.defineRule("require-future-block-capture", requireFutureBlockCapture);

return linter.verifyAndFix(source, {
parserOptions: { ecmaVersion: 2022, sourceType: "module" },
rules: { "require-future-block-capture": "error" },
});
}

describe("require-future-block-capture", () => {
test("allows Futures captured in Future.block", () => {
const result = lint("Future.block(async $ => await $(Future.success(1))); ");

expect(result.messages).toEqual([]);
});

test("allows Futures captured in Future.block_", () => {
const result = lint("Future.block_()(async capture => await capture(Future.success(1))); ");

expect(result.messages).toEqual([]);
});

test("reports but does not rewrite a native Promise", () => {
const source = "Future.block(async $ => await Promise.resolve(1));";
const result = lint(source);

expect(result.output).toBe(source);
expect(result.messages).toMatchObject([{ messageId: "wrapAwait" }]);
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"eslint": "^9.39.5",
"eslint-plugin-no-relative-import-paths": "^1.5.3",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-unused-imports": "^4",
"expect-type": "^0",
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@
"$/*": ["./*"]
}
},
"include": ["src"],
"include": ["src", "eslint/**/*.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
73 changes: 66 additions & 7 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ __metadata:
languageName: node
linkType: hard

"@babel/core@npm:^7.29.0":
"@babel/core@npm:^7.24.4, @babel/core@npm:^7.29.0":
version: 7.29.7
resolution: "@babel/core@npm:7.29.7"
dependencies:
Expand Down Expand Up @@ -164,6 +164,17 @@ __metadata:
languageName: node
linkType: hard

"@babel/parser@npm:^7.24.4":
version: 7.29.8
resolution: "@babel/parser@npm:7.29.8"
dependencies:
"@babel/types": "npm:^7.29.8"
bin:
parser: ./bin/babel-parser.js
checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28
languageName: node
linkType: hard

"@babel/plugin-transform-react-jsx-self@npm:^7.27.1":
version: 7.29.7
resolution: "@babel/plugin-transform-react-jsx-self@npm:7.29.7"
Expand Down Expand Up @@ -229,6 +240,16 @@ __metadata:
languageName: node
linkType: hard

"@babel/types@npm:^7.29.8":
version: 7.29.8
resolution: "@babel/types@npm:7.29.8"
dependencies:
"@babel/helper-string-parser": "npm:^7.29.7"
"@babel/helper-validator-identifier": "npm:^7.29.7"
checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6
languageName: node
linkType: hard

"@date-io/core@npm:1.3.6":
version: 1.3.6
resolution: "@date-io/core@npm:1.3.6"
Expand Down Expand Up @@ -5677,7 +5698,7 @@ __metadata:
eslint: "npm:^9.39.5"
eslint-plugin-no-relative-import-paths: "npm:^1.5.3"
eslint-plugin-react: "npm:^7.37.5"
eslint-plugin-react-hooks: "npm:^5.2.0"
eslint-plugin-react-hooks: "npm:^7.1.1"
eslint-plugin-testing-library: "npm:^7.16.2"
eslint-plugin-unused-imports: "npm:^4"
expect-type: "npm:^0"
Expand Down Expand Up @@ -6260,12 +6281,18 @@ __metadata:
languageName: node
linkType: hard

"eslint-plugin-react-hooks@npm:^5.2.0":
version: 5.2.0
resolution: "eslint-plugin-react-hooks@npm:5.2.0"
"eslint-plugin-react-hooks@npm:^7.1.1":
version: 7.1.1
resolution: "eslint-plugin-react-hooks@npm:7.1.1"
dependencies:
"@babel/core": "npm:^7.24.4"
"@babel/parser": "npm:^7.24.4"
hermes-parser: "npm:^0.25.1"
zod: "npm:^3.25.0 || ^4.0.0"
zod-validation-error: "npm:^3.5.0 || ^4.0.0"
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
checksum: 10c0/1c8d50fa5984c6dea32470651807d2922cc3934cf3425e78f84a24c2dfd972e7f019bee84aefb27e0cf2c13fea0ac1d4473267727408feeb1c56333ca1489385
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
checksum: 10c0/cee8454915d71ac5d70a0d8f4f260e76eaf45fcd4162747dd4282b792ee5616d187351dabe6cdcff9040c79d0cec625635c4fd0777276be119efa88ebe058525
languageName: node
linkType: hard

Expand Down Expand Up @@ -7130,6 +7157,22 @@ __metadata:
languageName: node
linkType: hard

"hermes-estree@npm:0.25.1":
version: 0.25.1
resolution: "hermes-estree@npm:0.25.1"
checksum: 10c0/48be3b2fa37a0cbc77a112a89096fa212f25d06de92781b163d67853d210a8a5c3784fac23d7d48335058f7ed283115c87b4332c2a2abaaccc76d0ead1a282ac
languageName: node
linkType: hard

"hermes-parser@npm:^0.25.1":
version: 0.25.1
resolution: "hermes-parser@npm:0.25.1"
dependencies:
hermes-estree: "npm:0.25.1"
checksum: 10c0/3abaa4c6f1bcc25273f267297a89a4904963ea29af19b8e4f6eabe04f1c2c7e9abd7bfc4730ddb1d58f2ea04b6fee74053d8bddb5656ec6ebf6c79cc8d14202c
languageName: node
linkType: hard

"history@npm:^4.9.0":
version: 4.10.1
resolution: "history@npm:4.10.1"
Expand Down Expand Up @@ -11734,3 +11777,19 @@ __metadata:
checksum: 10c0/b9de0ac816d454e801a62179719df2a27b5731cd1b213bbcdbf5e47c43ec6c42baccbf32ee976026c0420a90f651131e4893e697e4aeeab1b14cc52f4668635e
languageName: node
linkType: hard

"zod-validation-error@npm:^3.5.0 || ^4.0.0":
version: 4.0.2
resolution: "zod-validation-error@npm:4.0.2"
peerDependencies:
zod: ^3.25.0 || ^4.0.0
checksum: 10c0/0ccfec48c46de1be440b719cd02044d4abb89ed0e14c13e637cd55bf29102f67ccdba373f25def0fc7130e5f15025be4d557a7edcc95d5a3811599aade689e1b
languageName: node
linkType: hard

"zod@npm:^3.25.0 || ^4.0.0":
version: 4.5.4
resolution: "zod@npm:4.5.4"
checksum: 10c0/511a2a4d1a6f875dfdd70a1586989a8b14ef126776cd6218a2c760b9f65f14ef389ec20d92ee1aa4fcb4566d4ff3fa012956044f9dc503510d82e4c756a8ba92
languageName: node
linkType: hard
Loading