Skip to content

Commit 9bb8b2e

Browse files
authored
Merge pull request #8549 from VedantMadane/feat/eslint-no-await-navigate-in-use-task-6993
feat(eslint-plugin-qwik): add no-await-navigate-in-use-task rule
2 parents 90252d5 + 737029b commit 9bb8b2e

7 files changed

Lines changed: 254 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-qwik': patch
3+
---
4+
5+
Add `no-await-navigate-in-use-task` ESLint rule to catch awaiting `useNavigate()` inside blocking `useTask$` callbacks.

packages/eslint-plugin-qwik/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useMethodUsage } from './src/useMethodUsage';
1212
import { validLexicalScope } from './src/validLexicalScope';
1313
import { serializerSignalUsage } from './src/serializerSignalUsage';
1414
import { noAsyncPreventDefault } from './src/noAsyncPreventDefault';
15+
import { noAwaitNavigateInUseTask } from './src/noAwaitNavigateInUseTask';
1516
import pkg from './package.json';
1617
import { scopeUseTask } from './src/scope-use-task';
1718
import { asyncComputedTop } from './src/useAsyncTop';
@@ -34,6 +35,7 @@ const rules = {
3435
'scope-use-task': scopeUseTask,
3536
'use-async-top': asyncComputedTop,
3637
'no-async-prevent-default': noAsyncPreventDefault,
38+
'no-await-navigate-in-use-task': noAwaitNavigateInUseTask,
3739
} satisfies Rules;
3840

3941
const recommendedRulesLevels = {
@@ -52,6 +54,7 @@ const recommendedRulesLevels = {
5254
'qwik/scope-use-task': 'error',
5355
'qwik/use-async-top': 'warn',
5456
'qwik/no-async-prevent-default': 'warn',
57+
'qwik/no-await-navigate-in-use-task': 'warn',
5558
} satisfies TSESLint.FlatConfig.Rules;
5659

5760
const strictRulesLevels = {
@@ -70,6 +73,7 @@ const strictRulesLevels = {
7073
'qwik/scope-use-task': 'error',
7174
'qwik/use-async-top': 'warn',
7275
'qwik/no-async-prevent-default': 'warn',
76+
'qwik/no-await-navigate-in-use-task': 'warn',
7377
} satisfies TSESLint.FlatConfig.Rules;
7478

7579
const configs = {
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import type { Rule } from 'eslint';
2+
import type {
3+
ArrowFunctionExpression,
4+
CallExpression,
5+
Expression,
6+
FunctionExpression,
7+
Node,
8+
Pattern,
9+
} from 'estree';
10+
11+
const USE_TASK_CALLEES = new Set(['useTask$', 'useTaskQrl']);
12+
13+
function isUseTaskCall(node: CallExpression): boolean {
14+
return node.callee.type === 'Identifier' && USE_TASK_CALLEES.has(node.callee.name);
15+
}
16+
17+
function getTaskCallback(
18+
node: CallExpression
19+
): ArrowFunctionExpression | FunctionExpression | null {
20+
const arg0 = node.arguments[0];
21+
if (arg0?.type === 'ArrowFunctionExpression' || arg0?.type === 'FunctionExpression') {
22+
return arg0;
23+
}
24+
return null;
25+
}
26+
27+
function isDeferUpdatesFalse(node: Expression | Pattern | undefined): boolean {
28+
if (!node) {
29+
return false;
30+
}
31+
if (node.type === 'AssignmentPattern') {
32+
return isDeferUpdatesFalse(node.right);
33+
}
34+
if (node.type === 'Literal' && node.value === false) {
35+
return true;
36+
}
37+
if (node.type === 'TSAsExpression') {
38+
return isDeferUpdatesFalse(node.expression as Expression);
39+
}
40+
return false;
41+
}
42+
43+
function hasDeferUpdatesFalseOption(node: CallExpression): boolean {
44+
const opts = node.arguments[1];
45+
if (!opts || opts.type !== 'ObjectExpression') {
46+
return false;
47+
}
48+
for (const prop of opts.properties) {
49+
if (prop.type !== 'Property' || prop.computed) {
50+
continue;
51+
}
52+
const key = prop.key;
53+
const name =
54+
key.type === 'Identifier' ? key.name : key.type === 'Literal' ? String(key.value) : null;
55+
if (name !== 'deferUpdates') {
56+
continue;
57+
}
58+
if (isDeferUpdatesFalse(prop.value as Expression | Pattern)) {
59+
return true;
60+
}
61+
}
62+
return false;
63+
}
64+
65+
function collectUseNavigateBoundNamesFromNode(root: Node): Set<string> {
66+
const ids = new Set<string>();
67+
const stack: Node[] = [root];
68+
while (stack.length) {
69+
const n = stack.pop()!;
70+
if (n.type === 'VariableDeclarator' && n.id.type === 'Identifier' && n.init) {
71+
if (
72+
n.init.type === 'CallExpression' &&
73+
n.init.callee.type === 'Identifier' &&
74+
n.init.callee.name === 'useNavigate'
75+
) {
76+
ids.add(n.id.name);
77+
}
78+
}
79+
for (const key of Object.keys(n) as (keyof Node)[]) {
80+
if (key === 'parent') {
81+
continue;
82+
}
83+
const child = (n as unknown as Record<string, unknown>)[key as string];
84+
if (Array.isArray(child)) {
85+
for (const c of child) {
86+
if (c && typeof c === 'object' && c !== null && 'type' in (c as object)) {
87+
stack.push(c as Node);
88+
}
89+
}
90+
} else if (child && typeof child === 'object' && 'type' in (child as object)) {
91+
stack.push(child as Node);
92+
}
93+
}
94+
}
95+
return ids;
96+
}
97+
98+
function collectNavigateBindingsForUseTask(useTaskCall: CallExpression): Set<string> {
99+
const ids = new Set<string>();
100+
let current: Node | null = useTaskCall.parent;
101+
while (current) {
102+
if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') {
103+
const p = current.parent;
104+
if (
105+
p?.type === 'CallExpression' &&
106+
p.callee.type === 'Identifier' &&
107+
p.callee.name === 'component$'
108+
) {
109+
for (const id of collectUseNavigateBoundNamesFromNode(current)) {
110+
ids.add(id);
111+
}
112+
}
113+
}
114+
if (current.type === 'Program') {
115+
for (const id of collectUseNavigateBoundNamesFromNode(current)) {
116+
ids.add(id);
117+
}
118+
break;
119+
}
120+
current = current.parent as Node | null;
121+
}
122+
return ids;
123+
}
124+
125+
function reportAwaitedNavigateCalls(
126+
context: Rule.RuleContext,
127+
root: Node,
128+
navigateIds: Set<string>
129+
) {
130+
const stack: Node[] = [root];
131+
while (stack.length) {
132+
const n = stack.pop()!;
133+
if (n.type === 'AwaitExpression') {
134+
const arg = n.argument;
135+
if (arg.type === 'CallExpression' && arg.callee.type === 'Identifier') {
136+
if (navigateIds.has(arg.callee.name)) {
137+
context.report({
138+
node: n,
139+
messageId: 'noAwaitBlocking',
140+
data: { name: arg.callee.name },
141+
});
142+
}
143+
}
144+
}
145+
for (const key of Object.keys(n) as (keyof Node)[]) {
146+
if (key === 'parent') {
147+
continue;
148+
}
149+
const child = (n as unknown as Record<string, unknown>)[key as string];
150+
if (Array.isArray(child)) {
151+
for (const c of child) {
152+
if (c && typeof c === 'object' && c !== null && 'type' in (c as object)) {
153+
stack.push(c as Node);
154+
}
155+
}
156+
} else if (child && typeof child === 'object' && 'type' in (child as object)) {
157+
stack.push(child as Node);
158+
}
159+
}
160+
}
161+
}
162+
163+
export const noAwaitNavigateInUseTask: Rule.RuleModule = {
164+
meta: {
165+
type: 'problem',
166+
docs: {
167+
description:
168+
'Disallow awaiting the function returned by `useNavigate()` inside blocking `useTask$` callbacks.',
169+
recommended: true,
170+
url: 'https://qwik.dev/docs/advanced/eslint/',
171+
},
172+
messages: {
173+
noAwaitBlocking:
174+
'Awaiting `{{name}}()` from `useNavigate()` inside a blocking `useTask$` can deadlock. Remove `await`, or pass `{ deferUpdates: false }` as the second argument to `useTask$`.',
175+
},
176+
},
177+
create(context) {
178+
return {
179+
CallExpression(node: CallExpression) {
180+
if (!isUseTaskCall(node)) {
181+
return;
182+
}
183+
if (hasDeferUpdatesFalseOption(node)) {
184+
return;
185+
}
186+
const taskFn = getTaskCallback(node);
187+
if (!taskFn) {
188+
return;
189+
}
190+
const navigateIds = collectNavigateBindingsForUseTask(node);
191+
if (!navigateIds.size) {
192+
return;
193+
}
194+
reportAwaitedNavigateCalls(context, taskFn.body, navigateIds);
195+
},
196+
};
197+
},
198+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Expect error: { "messageId": "noAwaitBlocking" }
2+
3+
import { component$, useTask$ } from '@builder.io/qwik';
4+
import { useNavigate } from '@builder.io/qwik-city';
5+
6+
export default component$(() => {
7+
const nav = useNavigate();
8+
useTask$(async () => {
9+
await nav('/');
10+
});
11+
return <div />;
12+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { component$, useTask$ } from '@builder.io/qwik';
2+
import { useNavigate } from '@builder.io/qwik-city';
3+
4+
export default component$(() => {
5+
const nav = useNavigate();
6+
useTask$(
7+
async () => {
8+
await nav('/');
9+
},
10+
{ deferUpdates: false }
11+
);
12+
return <div />;
13+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { component$, useTask$ } from '@builder.io/qwik';
2+
import { useNavigate } from '@builder.io/qwik-city';
3+
4+
export default component$(() => {
5+
const nav = useNavigate();
6+
useTask$(async () => {
7+
void nav('/');
8+
});
9+
return <div />;
10+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { component$, useTask$ } from '@builder.io/qwik';
2+
import { useNavigate } from '@builder.io/qwik-city';
3+
4+
export default component$(() => {
5+
const nav = useNavigate();
6+
const other = async () => Promise.resolve();
7+
useTask$(async () => {
8+
await other();
9+
void nav('/');
10+
});
11+
return <div />;
12+
});

0 commit comments

Comments
 (0)