From 21b9c14b14bd040d7782172b4ecaf88072eaa77a Mon Sep 17 00:00:00 2001 From: Varixo Date: Thu, 28 Aug 2025 20:39:05 +0200 Subject: [PATCH 1/2] feat: add resolve method for async computed --- packages/docs/src/routes/api/qwik/api.json | 19 ++++++++++++- packages/docs/src/routes/api/qwik/index.mdx | 28 +++++++++++++++++++ .../impl/async-computed-signal-impl.ts | 6 +++- .../core/reactive-primitives/signal.public.ts | 1 + .../core/tests/use-async-computed.spec.tsx | 22 +++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 23bc6db620d..76d389effa3 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -244,7 +244,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface AsyncComputedReadonlySignal extends ComputedSignal \n```\n**Extends:** [ComputedSignal](#computedsignal)<T>\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[error](#)\n\n\n\n\n\n\n\nError \\| null\n\n\n\n\nThe error that occurred while computing the signal.\n\n\n
\n\n[loading](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\nWhether the signal is currently loading.\n\n\n
", + "content": "```typescript\nexport interface AsyncComputedReadonlySignal extends ComputedSignal \n```\n**Extends:** [ComputedSignal](#computedsignal)<T>\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[error](#)\n\n\n\n\n\n\n\nError \\| null\n\n\n\n\nThe error that occurred while computing the signal.\n\n\n
\n\n[loading](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\nWhether the signal is currently loading.\n\n\n
\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[resolve()](#asynccomputedreadonlysignal-resolve)\n\n\n\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", "mdFile": "core.asynccomputedreadonlysignal.md" }, @@ -1745,6 +1745,23 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/ssr/ssr-types.ts", "mdFile": "core.renderssroptions.md" }, + { + "name": "resolve", + "id": "asynccomputedreadonlysignal-resolve", + "hierarchy": [ + { + "name": "AsyncComputedReadonlySignal", + "id": "asynccomputedreadonlysignal-resolve" + }, + { + "name": "resolve", + "id": "asynccomputedreadonlysignal-resolve" + } + ], + "kind": "MethodSignature", + "content": "```typescript\nresolve(): Promise;\n```\n**Returns:**\n\nPromise<void>", + "mdFile": "core.asynccomputedreadonlysignal.resolve.md" + }, { "name": "Resource", "id": "resource", diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 62f8745c9e3..eb8b78d57f8 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -185,6 +185,24 @@ Whether the signal is currently loading. + + +
+ +Method + + + +Description + +
+ +[resolve()](#asynccomputedreadonlysignal-resolve) + + + +
+ [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts) ## AsyncComputedReturnType @@ -3469,6 +3487,16 @@ StreamWriter [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/ssr/ssr-types.ts) +## resolve + +```typescript +resolve(): Promise; +``` + +**Returns:** + +Promise<void> + ## Resource This method works like an async memoized function that runs whenever some tracked value changes and returns some data. diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts index a149e174554..01f43af6176 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts @@ -1,6 +1,6 @@ import { qwikDebugToString } from '../../debug'; import type { Container } from '../../shared/types'; -import { isPromise } from '../../shared/utils/promises'; +import { isPromise, retryOnPromise } from '../../shared/utils/promises'; import { cleanupFn, trackFn } from '../../use/utils/tracker'; import type { BackRef } from '../cleanup'; import { AsyncComputeQRL, SerializationSignalFlags, EffectSubscription } from '../types'; @@ -103,6 +103,10 @@ export class AsyncComputedSignalImpl this.$promiseValue$ = NEEDS_COMPUTATION; } + async resolve(): Promise { + await retryOnPromise(() => this.$computeIfNeeded$()); + } + $computeIfNeeded$() { if (!(this.$flags$ & SignalFlags.INVALID)) { return; diff --git a/packages/qwik/src/core/reactive-primitives/signal.public.ts b/packages/qwik/src/core/reactive-primitives/signal.public.ts index fa3c6fef262..fe014931b54 100644 --- a/packages/qwik/src/core/reactive-primitives/signal.public.ts +++ b/packages/qwik/src/core/reactive-primitives/signal.public.ts @@ -21,6 +21,7 @@ export interface AsyncComputedReadonlySignal extends ComputedSignal loading: boolean; /** The error that occurred while computing the signal. */ error: Error | null; + resolve(): Promise; } /** diff --git a/packages/qwik/src/core/tests/use-async-computed.spec.tsx b/packages/qwik/src/core/tests/use-async-computed.spec.tsx index 259f8a8ea6a..6f85cd607c5 100644 --- a/packages/qwik/src/core/tests/use-async-computed.spec.tsx +++ b/packages/qwik/src/core/tests/use-async-computed.spec.tsx @@ -216,4 +216,26 @@ describe.each([ ); }); }); + + describe('resolve', () => { + it('should not rerun if resolve is used before', async () => { + (globalThis as any).log = []; + const Counter = component$(() => { + const count = useSignal(1); + const doubleCount = useAsyncComputed$(() => Promise.resolve(count.value * 2)); + + useTask$(async () => { + await doubleCount.resolve(); + (globalThis as any).log.push('task'); + (globalThis as any).log.push(doubleCount.value); + }); + + return
; + }); + await render(, { debug }); + expect((globalThis as any).log).toEqual(['task', 2]); + + (globalThis as any).log = undefined; + }); + }); }); From 73e92e9e9c87672103587a88961451e19db290cf Mon Sep 17 00:00:00 2001 From: Varixo Date: Thu, 28 Aug 2025 20:39:32 +0200 Subject: [PATCH 2/2] test: add eslint rule for resolving async computed --- .changeset/funny-feet-wish.md | 5 + .changeset/polite-parents-win.md | 5 + packages/eslint-plugin-qwik/index.ts | 4 + .../src/asyncComputedTop.ts | 316 ++++++++++++++++++ .../async-computed/component.tsx | 33 ++ .../test-fixtures/async-computed/exported.ts | 5 + .../invalid-imported-non-top.tsx | 11 + .../async-computed-top/invalid-non-top.tsx | 15 + .../invalid-second-async.tsx | 15 + .../valid-await-resolve.tsx | 13 + .../valid-imported-await-resolve.tsx | 11 + .../async-computed-top/valid-imported-top.tsx | 10 + .../async-computed-top/valid-non-qrl.tsx | 13 + .../valid-only-first-async.tsx | 14 + .../tests/async-computed-top/valid-top.tsx | 14 + packages/qwik/src/core/qwik.core.api.md | 2 + 16 files changed, 486 insertions(+) create mode 100644 .changeset/funny-feet-wish.md create mode 100644 .changeset/polite-parents-win.md create mode 100644 packages/eslint-plugin-qwik/src/asyncComputedTop.ts create mode 100644 packages/eslint-plugin-qwik/test-fixtures/async-computed/component.tsx create mode 100644 packages/eslint-plugin-qwik/test-fixtures/async-computed/exported.ts create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/invalid-imported-non-top.tsx create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/invalid-non-top.tsx create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/invalid-second-async.tsx create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/valid-await-resolve.tsx create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/valid-imported-await-resolve.tsx create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/valid-imported-top.tsx create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/valid-non-qrl.tsx create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/valid-only-first-async.tsx create mode 100644 packages/eslint-plugin-qwik/tests/async-computed-top/valid-top.tsx diff --git a/.changeset/funny-feet-wish.md b/.changeset/funny-feet-wish.md new file mode 100644 index 00000000000..774cac926c8 --- /dev/null +++ b/.changeset/funny-feet-wish.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-qwik': minor +--- + +feat: add eslint rule for resolving async computed diff --git a/.changeset/polite-parents-win.md b/.changeset/polite-parents-win.md new file mode 100644 index 00000000000..786c4564c93 --- /dev/null +++ b/.changeset/polite-parents-win.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: add resolve method for async computed diff --git a/packages/eslint-plugin-qwik/index.ts b/packages/eslint-plugin-qwik/index.ts index e1f28a12acb..0529aff77c4 100644 --- a/packages/eslint-plugin-qwik/index.ts +++ b/packages/eslint-plugin-qwik/index.ts @@ -13,6 +13,7 @@ import { validLexicalScope } from './src/validLexicalScope'; import { serializerSignalUsage } from './src/serializerSignalUsage'; import pkg from './package.json'; import { scopeUseTask } from './src/scope-use-task'; +import { asyncComputedTop } from './src/asyncComputedTop'; type Rules = NonNullable; @@ -30,6 +31,7 @@ const rules = { 'no-use-visible-task': noUseVisibleTask, 'serializer-signal-usage': serializerSignalUsage, 'scope-use-task': scopeUseTask, + 'async-computed-top': asyncComputedTop, } satisfies Rules; const recommendedRulesLevels = { @@ -46,6 +48,7 @@ const recommendedRulesLevels = { 'qwik/no-use-visible-task': 'warn', 'qwik/serializer-signal-usage': 'error', 'qwik/scope-use-task': 'error', + 'qwik/async-computed-top': 'warn', } satisfies TSESLint.FlatConfig.Rules; const strictRulesLevels = { @@ -62,6 +65,7 @@ const strictRulesLevels = { 'qwik/no-use-visible-task': 'warn', 'qwik/serializer-signal-usage': 'error', 'qwik/scope-use-task': 'error', + 'qwik/async-computed-top': 'warn', } satisfies TSESLint.FlatConfig.Rules; const configs = { diff --git a/packages/eslint-plugin-qwik/src/asyncComputedTop.ts b/packages/eslint-plugin-qwik/src/asyncComputedTop.ts new file mode 100644 index 00000000000..5e11f088287 --- /dev/null +++ b/packages/eslint-plugin-qwik/src/asyncComputedTop.ts @@ -0,0 +1,316 @@ +import { Rule } from 'eslint'; +import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils'; +import ts from 'typescript'; + +function isFromQwikModule(resolvedVar: any): boolean { + return resolvedVar?.defs?.some((def: any) => { + if (def.type !== 'ImportBinding') { + return false; + } + const importSource = def.parent.source.value; + return ( + importSource.startsWith('@qwik.dev/core') || + importSource.startsWith('@qwik.dev/router') || + importSource.startsWith('@builder.io/qwik') || + importSource.startsWith('@builder.io/qwik-city') + ); + }); +} + +function resolveVariableForIdentifier(context: Rule.RuleContext, ident: any) { + const scope = context.sourceCode.getScope(ident); + const ref = scope.references.find((r) => r.identifier === ident); + if (ref && ref.resolved) { + return ref.resolved; + } + // Fallback lookup walking up scopes by name + let current: any = scope; + while (current) { + const found = current.variables.find((v: any) => v.name === ident.name); + if (found) { + return found; + } + current = current.upper; + } + return null; +} + +function isQrlCallee(context: Rule.RuleContext, callee: TSESTree.Identifier): boolean { + if (!callee || callee.type !== AST_NODE_TYPES.Identifier) { + return false; + } + if (!callee.name.endsWith('$')) { + return false; + } + const resolved = resolveVariableForIdentifier(context, callee); + return isFromQwikModule(resolved); +} + +function getFirstStatementIfValueRead( + body: TSESTree.BlockStatement +): TSESTree.ExpressionStatement | null { + const first = body.body[0]; + if (!first || first.type !== AST_NODE_TYPES.ExpressionStatement) { + return null; + } + const expr = first.expression; + if ( + expr.type === AST_NODE_TYPES.MemberExpression && + !expr.computed && + expr.property.type === AST_NODE_TYPES.Identifier && + expr.property.name === 'value' + ) { + return first; + } + return null; +} + +function isAsyncComputedIdentifier(context: Rule.RuleContext, ident: any): boolean { + const variable = resolveVariableForIdentifier(context, ident); + if (!variable || (variable.defs && variable.defs.length === 0)) { + return false; + } + + const services: any = (context as any).sourceCode.parserServices; + const checker: ts.TypeChecker | undefined = services?.program?.getTypeChecker(); + const esTreeNodeToTSNodeMap = services?.esTreeNodeToTSNodeMap; + + function declIsAsyncComputedCall(decl: ts.Declaration | undefined): boolean { + if (!decl) { + return false; + } + if ( + ts.isVariableDeclaration(decl) && + decl.initializer && + ts.isCallExpression(decl.initializer) + ) { + const callee = decl.initializer.expression; + if (ts.isIdentifier(callee)) { + const name = callee.text; + return name === 'createAsyncComputed$' || name === 'useAsyncComputed$'; + } + } + if (ts.isExportSpecifier(decl) || ts.isImportSpecifier(decl)) { + // We'll rely on symbol resolution below; these alone don't tell us. + return false; + } + return false; + } + + for (const def of variable.defs) { + if (def.type === 'Variable' && def.node.type === AST_NODE_TYPES.VariableDeclarator) { + const init = def.node.init; + if (init && init.type === AST_NODE_TYPES.CallExpression) { + const callee = init.callee; + if (callee.type === AST_NODE_TYPES.Identifier) { + const name = callee.name; + if ( + (name === 'useAsyncComputed$' || name === 'createAsyncComputed$') && + isFromQwikModule(resolveVariableForIdentifier(context, callee)) + ) { + return true; + } + } + } + } + if (def.type === 'ImportBinding' && checker && esTreeNodeToTSNodeMap) { + try { + // Map the identifier to TS node & resolve symbol (following re-exports) + const tsNode = esTreeNodeToTSNodeMap.get(ident as any); + if (tsNode) { + let symbol = checker.getSymbolAtLocation(tsNode); + if (symbol && symbol.flags & ts.SymbolFlags.Alias) { + symbol = checker.getAliasedSymbol(symbol); + } + if (symbol) { + for (const d of symbol.declarations ?? []) { + if (declIsAsyncComputedCall(d)) { + return true; + } + // Variable statement with multiple declarations + if (ts.isVariableStatement(d)) { + for (const decl of d.declarationList.declarations) { + if (declIsAsyncComputedCall(decl)) { + return true; + } + } + } + } + // As an extra heuristic, use the inferred return type of the symbol if it's a const + const type = checker.getTypeOfSymbolAtLocation(symbol, tsNode); + const typeStr = checker.typeToString(type.getNonNullableType()); + if (/AsyncComputed/i.test(typeStr)) { + return true; + } + } + } + } catch { + // ignore resolution errors + } + } + } + + // As a fallback, try using TypeScript type information if available + if (checker && esTreeNodeToTSNodeMap) { + try { + const tsNode = esTreeNodeToTSNodeMap.get(ident as any); + const type = checker.getTypeAtLocation(tsNode); + const typeStr = checker.typeToString(type.getNonNullableType()); + // Heuristic: type name includes AsyncComputed + if (/AsyncComputed/i.test(typeStr)) { + return true; + } + } catch { + // ignore + } + } + + return false; +} + +function hasAwaitResolveBefore( + body: TSESTree.BlockStatement, + beforeStmt: TSESTree.Statement, + identifierName: string +): boolean { + for (const stmt of body.body) { + if (stmt === beforeStmt) { + break; + } + if (stmt.type !== AST_NODE_TYPES.ExpressionStatement) { + continue; + } + const expr = stmt.expression; + if (expr.type !== AST_NODE_TYPES.AwaitExpression) { + continue; + } + const awaited = expr.argument; + if ( + awaited && + awaited.type === AST_NODE_TYPES.CallExpression && + awaited.callee.type === AST_NODE_TYPES.MemberExpression && + !awaited.callee.computed && + awaited.callee.object.type === AST_NODE_TYPES.Identifier && + awaited.callee.object.name === identifierName && + awaited.callee.property.type === AST_NODE_TYPES.Identifier && + awaited.callee.property.name === 'resolve' + ) { + return true; + } + } + return false; +} + +export const asyncComputedTop: Rule.RuleModule = { + meta: { + type: 'problem', + docs: { + description: + "Warn when an async computed signal '.value' is read not at the top of a QRL callback.", + recommended: false, + url: '', + }, + schema: [], + messages: { + asyncComputedNotTop: + "Async computed '{{name}}.value' must be first, or use 'await {{name}}.resolve()' beforehand.", + }, + }, + create(context) { + const qrlFnStack: Array< + TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression | TSESTree.FunctionDeclaration + > = []; + + function isInTrackedQrl( + fn: + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionDeclaration + ): boolean { + const parent = fn.parent; + if (!parent || parent.type !== AST_NODE_TYPES.CallExpression) { + return false; + } + if (parent.callee.type !== AST_NODE_TYPES.Identifier) { + return false; + } + if (!isQrlCallee(context, parent.callee)) { + return false; + } + // Function must be passed as a direct argument + return parent.arguments.includes(fn as any); + } + + return { + ':function'(node: any) { + if ( + (node.type === AST_NODE_TYPES.FunctionExpression || + node.type === AST_NODE_TYPES.ArrowFunctionExpression) && + isInTrackedQrl(node) + ) { + qrlFnStack.push(node); + } + }, + ':function:exit'(node: any) { + if (qrlFnStack.length && qrlFnStack[qrlFnStack.length - 1] === node) { + qrlFnStack.pop(); + } + }, + + MemberExpression(node) { + if (!qrlFnStack.length) { + return; + } + const currentFn = qrlFnStack[qrlFnStack.length - 1]!; + // Only care about reads like `foo.value;` that are expression statements + if ( + node.parent?.type !== AST_NODE_TYPES.ExpressionStatement || + node.computed || + node.property.type !== AST_NODE_TYPES.Identifier || + node.property.name !== 'value' + ) { + return; + } + const exprStmt = node.parent as TSESTree.ExpressionStatement; + // Find the top-of-body allowed zone + const body = + currentFn.body && currentFn.body.type === AST_NODE_TYPES.BlockStatement + ? currentFn.body + : null; + if (!body) { + return; + } + // Only consider top-level statements of the QRL callback body + if (exprStmt.parent !== body) { + return; + } + + const allowedFirst = getFirstStatementIfValueRead(body); + const isAtTop = allowedFirst === exprStmt; + if (isAtTop) { + return; + } + + // Determine if the object is an async computed signal + const obj = node.object; + if (obj.type !== AST_NODE_TYPES.Identifier) { + return; + } + if (!isAsyncComputedIdentifier(context, obj)) { + return; + } + + // Allow if there is an earlier 'await .resolve()' in the same body + if (hasAwaitResolveBefore(body, exprStmt, obj.name)) { + return; + } + + context.report({ + node: node as any, + messageId: 'asyncComputedNotTop', + data: { name: obj.name }, + }); + }, + } as Rule.RuleListener; + }, +}; diff --git a/packages/eslint-plugin-qwik/test-fixtures/async-computed/component.tsx b/packages/eslint-plugin-qwik/test-fixtures/async-computed/component.tsx new file mode 100644 index 00000000000..69b37fe79ee --- /dev/null +++ b/packages/eslint-plugin-qwik/test-fixtures/async-computed/component.tsx @@ -0,0 +1,33 @@ +import { component$, useSignal, useTask$ } from '@qwik.dev/core'; +import { userData } from './exported'; + +// C1: should warn (not first, no resolve) +export const C1 = component$(() => { + const signal = useSignal(0); + useTask$(() => { + signal.value++; + userData.value; // expect errorł + }); + return null; +}); + +// C2: ok (first statement) +export const C2 = component$(() => { + const signal = useSignal(0); + useTask$(() => { + userData.value; // ok + signal.value++; + }); + return null; +}); + +// C3: ok because awaited resolve earlier +export const C3 = component$(() => { + const signal = useSignal(0); + useTask$(async () => { + await userData.resolve(); + signal.value++; + userData.value; // ok + }); + return null; +}); diff --git a/packages/eslint-plugin-qwik/test-fixtures/async-computed/exported.ts b/packages/eslint-plugin-qwik/test-fixtures/async-computed/exported.ts new file mode 100644 index 00000000000..11ca935f450 --- /dev/null +++ b/packages/eslint-plugin-qwik/test-fixtures/async-computed/exported.ts @@ -0,0 +1,5 @@ +import { createAsyncComputed$ } from '@qwik.dev/core'; + +export const userData = createAsyncComputed$(async () => { + return { name: 'A' }; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-imported-non-top.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-imported-non-top.tsx new file mode 100644 index 00000000000..aab6f942a30 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-imported-non-top.tsx @@ -0,0 +1,11 @@ +import { component$, useTask$ } from '@qwik.dev/core'; +import { userData } from '../../test-fixtures/async-computed/exported'; + +export default component$(() => { + useTask$(() => { + const y = 0; + // Expect error: {"messageId":"asyncComputedNotTop"} + userData.value; + }); + return
; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-non-top.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-non-top.tsx new file mode 100644 index 00000000000..428a96de8e5 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-non-top.tsx @@ -0,0 +1,15 @@ +import { component$, useTask$, useAsyncComputed$, useComputed$ } from '@qwik.dev/core'; + +export default component$(() => { + const async1 = useAsyncComputed$(() => Promise.resolve(1)); + const sync1 = useComputed$(() => 2); + + useTask$(() => { + const x = 1; + // Expect error: {"messageId":"asyncComputedNotTop"} + async1.value; + sync1.value; + }); + + return
; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-second-async.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-second-async.tsx new file mode 100644 index 00000000000..aa103334f34 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/invalid-second-async.tsx @@ -0,0 +1,15 @@ +import { component$, useTask$, useAsyncComputed$ } from '@qwik.dev/core'; + +export default component$(() => { + const async1 = useAsyncComputed$(() => Promise.resolve(1)); + const async2 = useAsyncComputed$(() => Promise.resolve(2)); + + useTask$(() => { + async1.value; + // Expect error: {"messageId":"asyncComputedNotTop"} + async2.value; + const x = 1; + }); + + return
; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/valid-await-resolve.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-await-resolve.tsx new file mode 100644 index 00000000000..d1a850d2872 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-await-resolve.tsx @@ -0,0 +1,13 @@ +import { component$, useTask$, useAsyncComputed$ } from '@qwik.dev/core'; + +export default component$(() => { + const async1 = useAsyncComputed$(() => Promise.resolve(1)); + + useTask$(async () => { + await async1.resolve(); + const x = 1; + async1.value; + }); + + return
; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/valid-imported-await-resolve.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-imported-await-resolve.tsx new file mode 100644 index 00000000000..f068523ca36 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-imported-await-resolve.tsx @@ -0,0 +1,11 @@ +import { component$, useTask$ } from '@qwik.dev/core'; +import { userData } from '../../test-fixtures/async-computed/exported'; + +export default component$(() => { + useTask$(async () => { + await userData.resolve(); + const z = 1; + userData.value; + }); + return
; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/valid-imported-top.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-imported-top.tsx new file mode 100644 index 00000000000..e9b9cd763a5 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-imported-top.tsx @@ -0,0 +1,10 @@ +import { component$, useTask$ } from '@qwik.dev/core'; +import { userData } from '../../test-fixtures/async-computed/exported'; + +export default component$(() => { + useTask$(() => { + userData.value; + const x = 1; + }); + return
; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/valid-non-qrl.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-non-qrl.tsx new file mode 100644 index 00000000000..f4c4a809236 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-non-qrl.tsx @@ -0,0 +1,13 @@ +import { component$, useAsyncComputed$ } from '@qwik.dev/core'; + +export default component$(() => { + const async1 = useAsyncComputed$(() => Promise.resolve(1)); + + function notQrl() { + const x = 1; + async1.value; + } + + notQrl(); + return
; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/valid-only-first-async.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-only-first-async.tsx new file mode 100644 index 00000000000..265e45fe244 --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-only-first-async.tsx @@ -0,0 +1,14 @@ +import { component$, useTask$, useAsyncComputed$, useComputed$ } from '@qwik.dev/core'; + +export default component$(() => { + const async1 = useAsyncComputed$(() => Promise.resolve(1)); + const sync1 = useComputed$(() => 2); + + useTask$(() => { + async1.value; + sync1.value; + const x = 1; + }); + + return
; +}); diff --git a/packages/eslint-plugin-qwik/tests/async-computed-top/valid-top.tsx b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-top.tsx new file mode 100644 index 00000000000..1145742a79c --- /dev/null +++ b/packages/eslint-plugin-qwik/tests/async-computed-top/valid-top.tsx @@ -0,0 +1,14 @@ +import { component$, useTask$, useAsyncComputed$, useComputed$ } from '@qwik.dev/core'; + +export default component$(() => { + const a = useAsyncComputed$(() => Promise.resolve(1)); + const b = useComputed$(() => 2); + + useTask$(() => { + a.value; + b.value; + const x = 1; + }); + + return
{a.value}
; +}); diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 499069e7ec1..d91c184121a 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -24,6 +24,8 @@ export type AsyncComputedFn = (ctx: AsyncComputedCtx) => Promise; export interface AsyncComputedReadonlySignal extends ComputedSignal { error: Error | null; loading: boolean; + // (undocumented) + resolve(): Promise; } // @public (undocumented)