Skip to content

Commit d84bb2a

Browse files
committed
test: add eslint rule for resolving async computed
1 parent 21b9c14 commit d84bb2a

File tree

14 files changed

+476
-0
lines changed

14 files changed

+476
-0
lines changed

packages/eslint-plugin-qwik/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { validLexicalScope } from './src/validLexicalScope';
1313
import { serializerSignalUsage } from './src/serializerSignalUsage';
1414
import pkg from './package.json';
1515
import { scopeUseTask } from './src/scope-use-task';
16+
import { asyncComputedTop } from './src/asyncComputedTop';
1617

1718
type Rules = NonNullable<TSESLint.FlatConfig.Plugin['rules']>;
1819

@@ -30,6 +31,7 @@ const rules = {
3031
'no-use-visible-task': noUseVisibleTask,
3132
'serializer-signal-usage': serializerSignalUsage,
3233
'scope-use-task': scopeUseTask,
34+
'async-computed-top': asyncComputedTop,
3335
} satisfies Rules;
3436

3537
const recommendedRulesLevels = {
@@ -46,6 +48,7 @@ const recommendedRulesLevels = {
4648
'qwik/no-use-visible-task': 'warn',
4749
'qwik/serializer-signal-usage': 'error',
4850
'qwik/scope-use-task': 'error',
51+
'qwik/async-computed-top': 'warn',
4952
} satisfies TSESLint.FlatConfig.Rules;
5053

5154
const strictRulesLevels = {
@@ -62,6 +65,7 @@ const strictRulesLevels = {
6265
'qwik/no-use-visible-task': 'warn',
6366
'qwik/serializer-signal-usage': 'error',
6467
'qwik/scope-use-task': 'error',
68+
'qwik/async-computed-top': 'warn',
6569
} satisfies TSESLint.FlatConfig.Rules;
6670

6771
const configs = {
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import { Rule } from 'eslint';
2+
import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils';
3+
import ts from 'typescript';
4+
5+
function isFromQwikModule(resolvedVar: any): boolean {
6+
return resolvedVar?.defs?.some((def: any) => {
7+
if (def.type !== 'ImportBinding') {
8+
return false;
9+
}
10+
const importSource = def.parent.source.value;
11+
return (
12+
importSource.startsWith('@qwik.dev/core') ||
13+
importSource.startsWith('@qwik.dev/router') ||
14+
importSource.startsWith('@builder.io/qwik') ||
15+
importSource.startsWith('@builder.io/qwik-city')
16+
);
17+
});
18+
}
19+
20+
function resolveVariableForIdentifier(context: Rule.RuleContext, ident: any) {
21+
const scope = context.sourceCode.getScope(ident);
22+
const ref = scope.references.find((r) => r.identifier === ident);
23+
if (ref && ref.resolved) {
24+
return ref.resolved;
25+
}
26+
// Fallback lookup walking up scopes by name
27+
let current: any = scope;
28+
while (current) {
29+
const found = current.variables.find((v: any) => v.name === ident.name);
30+
if (found) {
31+
return found;
32+
}
33+
current = current.upper;
34+
}
35+
return null;
36+
}
37+
38+
function isQrlCallee(context: Rule.RuleContext, callee: TSESTree.Identifier): boolean {
39+
if (!callee || callee.type !== AST_NODE_TYPES.Identifier) {
40+
return false;
41+
}
42+
if (!callee.name.endsWith('$')) {
43+
return false;
44+
}
45+
const resolved = resolveVariableForIdentifier(context, callee);
46+
return isFromQwikModule(resolved);
47+
}
48+
49+
function getFirstStatementIfValueRead(
50+
body: TSESTree.BlockStatement
51+
): TSESTree.ExpressionStatement | null {
52+
const first = body.body[0];
53+
if (!first || first.type !== AST_NODE_TYPES.ExpressionStatement) {
54+
return null;
55+
}
56+
const expr = first.expression;
57+
if (
58+
expr.type === AST_NODE_TYPES.MemberExpression &&
59+
!expr.computed &&
60+
expr.property.type === AST_NODE_TYPES.Identifier &&
61+
expr.property.name === 'value'
62+
) {
63+
return first;
64+
}
65+
return null;
66+
}
67+
68+
function isAsyncComputedIdentifier(context: Rule.RuleContext, ident: any): boolean {
69+
const variable = resolveVariableForIdentifier(context, ident);
70+
if (!variable || (variable.defs && variable.defs.length === 0)) {
71+
return false;
72+
}
73+
74+
const services: any = (context as any).sourceCode.parserServices;
75+
const checker: ts.TypeChecker | undefined = services?.program?.getTypeChecker();
76+
const esTreeNodeToTSNodeMap = services?.esTreeNodeToTSNodeMap;
77+
78+
function declIsAsyncComputedCall(decl: ts.Declaration | undefined): boolean {
79+
if (!decl) {
80+
return false;
81+
}
82+
if (
83+
ts.isVariableDeclaration(decl) &&
84+
decl.initializer &&
85+
ts.isCallExpression(decl.initializer)
86+
) {
87+
const callee = decl.initializer.expression;
88+
if (ts.isIdentifier(callee)) {
89+
const name = callee.text;
90+
return name === 'createAsyncComputed$' || name === 'useAsyncComputed$';
91+
}
92+
}
93+
if (ts.isExportSpecifier(decl) || ts.isImportSpecifier(decl)) {
94+
// We'll rely on symbol resolution below; these alone don't tell us.
95+
return false;
96+
}
97+
return false;
98+
}
99+
100+
for (const def of variable.defs) {
101+
if (def.type === 'Variable' && def.node.type === AST_NODE_TYPES.VariableDeclarator) {
102+
const init = def.node.init;
103+
if (init && init.type === AST_NODE_TYPES.CallExpression) {
104+
const callee = init.callee;
105+
if (callee.type === AST_NODE_TYPES.Identifier) {
106+
const name = callee.name;
107+
if (
108+
(name === 'useAsyncComputed$' || name === 'createAsyncComputed$') &&
109+
isFromQwikModule(resolveVariableForIdentifier(context, callee))
110+
) {
111+
return true;
112+
}
113+
}
114+
}
115+
}
116+
if (def.type === 'ImportBinding' && checker && esTreeNodeToTSNodeMap) {
117+
try {
118+
// Map the identifier to TS node & resolve symbol (following re-exports)
119+
const tsNode = esTreeNodeToTSNodeMap.get(ident as any);
120+
if (tsNode) {
121+
let symbol = checker.getSymbolAtLocation(tsNode);
122+
if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
123+
symbol = checker.getAliasedSymbol(symbol);
124+
}
125+
if (symbol) {
126+
for (const d of symbol.declarations ?? []) {
127+
if (declIsAsyncComputedCall(d)) {
128+
return true;
129+
}
130+
// Variable statement with multiple declarations
131+
if (ts.isVariableStatement(d)) {
132+
for (const decl of d.declarationList.declarations) {
133+
if (declIsAsyncComputedCall(decl)) {
134+
return true;
135+
}
136+
}
137+
}
138+
}
139+
// As an extra heuristic, use the inferred return type of the symbol if it's a const
140+
const type = checker.getTypeOfSymbolAtLocation(symbol, tsNode);
141+
const typeStr = checker.typeToString(type.getNonNullableType());
142+
if (/AsyncComputed/i.test(typeStr)) {
143+
return true;
144+
}
145+
}
146+
}
147+
} catch {
148+
// ignore resolution errors
149+
}
150+
}
151+
}
152+
153+
// As a fallback, try using TypeScript type information if available
154+
if (checker && esTreeNodeToTSNodeMap) {
155+
try {
156+
const tsNode = esTreeNodeToTSNodeMap.get(ident as any);
157+
const type = checker.getTypeAtLocation(tsNode);
158+
const typeStr = checker.typeToString(type.getNonNullableType());
159+
// Heuristic: type name includes AsyncComputed
160+
if (/AsyncComputed/i.test(typeStr)) {
161+
return true;
162+
}
163+
} catch {
164+
// ignore
165+
}
166+
}
167+
168+
return false;
169+
}
170+
171+
function hasAwaitResolveBefore(
172+
body: TSESTree.BlockStatement,
173+
beforeStmt: TSESTree.Statement,
174+
identifierName: string
175+
): boolean {
176+
for (const stmt of body.body) {
177+
if (stmt === beforeStmt) {
178+
break;
179+
}
180+
if (stmt.type !== AST_NODE_TYPES.ExpressionStatement) {
181+
continue;
182+
}
183+
const expr = stmt.expression;
184+
if (expr.type !== AST_NODE_TYPES.AwaitExpression) {
185+
continue;
186+
}
187+
const awaited = expr.argument;
188+
if (
189+
awaited &&
190+
awaited.type === AST_NODE_TYPES.CallExpression &&
191+
awaited.callee.type === AST_NODE_TYPES.MemberExpression &&
192+
!awaited.callee.computed &&
193+
awaited.callee.object.type === AST_NODE_TYPES.Identifier &&
194+
awaited.callee.object.name === identifierName &&
195+
awaited.callee.property.type === AST_NODE_TYPES.Identifier &&
196+
awaited.callee.property.name === 'resolve'
197+
) {
198+
return true;
199+
}
200+
}
201+
return false;
202+
}
203+
204+
export const asyncComputedTop: Rule.RuleModule = {
205+
meta: {
206+
type: 'problem',
207+
docs: {
208+
description:
209+
"Warn when an async computed signal '.value' is read not at the top of a QRL callback.",
210+
recommended: false,
211+
url: '',
212+
},
213+
schema: [],
214+
messages: {
215+
asyncComputedNotTop:
216+
"Async computed '{{name}}.value' must be first, or use 'await {{name}}.resolve()' beforehand.",
217+
},
218+
},
219+
create(context) {
220+
const qrlFnStack: Array<
221+
TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration
222+
> = [];
223+
224+
function isInTrackedQrl(
225+
fn:
226+
| TSESTree.FunctionExpression
227+
| TSESTree.ArrowFunctionExpression
228+
| TSESTree.FunctionDeclaration
229+
): boolean {
230+
const parent = fn.parent;
231+
if (!parent || parent.type !== AST_NODE_TYPES.CallExpression) {
232+
return false;
233+
}
234+
if (parent.callee.type !== AST_NODE_TYPES.Identifier) {
235+
return false;
236+
}
237+
if (!isQrlCallee(context, parent.callee)) {
238+
return false;
239+
}
240+
// Function must be passed as a direct argument
241+
return parent.arguments.includes(fn as any);
242+
}
243+
244+
return {
245+
':function'(node: any) {
246+
if (
247+
(node.type === AST_NODE_TYPES.FunctionExpression ||
248+
node.type === AST_NODE_TYPES.ArrowFunctionExpression) &&
249+
isInTrackedQrl(node)
250+
) {
251+
qrlFnStack.push(node);
252+
}
253+
},
254+
':function:exit'(node: any) {
255+
if (qrlFnStack.length && qrlFnStack[qrlFnStack.length - 1] === node) {
256+
qrlFnStack.pop();
257+
}
258+
},
259+
260+
MemberExpression(node) {
261+
if (!qrlFnStack.length) {
262+
return;
263+
}
264+
const currentFn = qrlFnStack[qrlFnStack.length - 1]!;
265+
// Only care about reads like `foo.value;` that are expression statements
266+
if (
267+
node.parent?.type !== AST_NODE_TYPES.ExpressionStatement ||
268+
node.computed ||
269+
node.property.type !== AST_NODE_TYPES.Identifier ||
270+
node.property.name !== 'value'
271+
) {
272+
return;
273+
}
274+
const exprStmt = node.parent as TSESTree.ExpressionStatement;
275+
// Find the top-of-body allowed zone
276+
const body =
277+
currentFn.body && currentFn.body.type === AST_NODE_TYPES.BlockStatement
278+
? currentFn.body
279+
: null;
280+
if (!body) {
281+
return;
282+
}
283+
// Only consider top-level statements of the QRL callback body
284+
if (exprStmt.parent !== body) {
285+
return;
286+
}
287+
288+
const allowedFirst = getFirstStatementIfValueRead(body);
289+
const isAtTop = allowedFirst === exprStmt;
290+
if (isAtTop) {
291+
return;
292+
}
293+
294+
// Determine if the object is an async computed signal
295+
const obj = node.object;
296+
if (obj.type !== AST_NODE_TYPES.Identifier) {
297+
return;
298+
}
299+
if (!isAsyncComputedIdentifier(context, obj)) {
300+
return;
301+
}
302+
303+
// Allow if there is an earlier 'await <ident>.resolve()' in the same body
304+
if (hasAwaitResolveBefore(body, exprStmt, obj.name)) {
305+
return;
306+
}
307+
308+
context.report({
309+
node: node as any,
310+
messageId: 'asyncComputedNotTop',
311+
data: { name: obj.name },
312+
});
313+
},
314+
} as Rule.RuleListener;
315+
},
316+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { component$, useSignal, useTask$ } from '@qwik.dev/core';
2+
import { userData } from './exported';
3+
4+
// C1: should warn (not first, no resolve)
5+
export const C1 = component$(() => {
6+
const signal = useSignal(0);
7+
useTask$(() => {
8+
signal.value++;
9+
userData.value; // expect errorł
10+
});
11+
return null;
12+
});
13+
14+
// C2: ok (first statement)
15+
export const C2 = component$(() => {
16+
const signal = useSignal(0);
17+
useTask$(() => {
18+
userData.value; // ok
19+
signal.value++;
20+
});
21+
return null;
22+
});
23+
24+
// C3: ok because awaited resolve earlier
25+
export const C3 = component$(() => {
26+
const signal = useSignal(0);
27+
useTask$(async () => {
28+
await userData.resolve();
29+
signal.value++;
30+
userData.value; // ok
31+
});
32+
return null;
33+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createAsyncComputed$ } from '@qwik.dev/core';
2+
3+
export const userData = createAsyncComputed$(async () => {
4+
return { name: 'A' };
5+
});

0 commit comments

Comments
 (0)