Skip to content

Commit 7ab8deb

Browse files
authored
Merge pull request #7552 from QwikDev/v2-async-computed
feat: allow async operations in useComputed$ hook
2 parents bcd4798 + 6ddb0cf commit 7ab8deb

File tree

10 files changed

+115
-47
lines changed

10 files changed

+115
-47
lines changed

.changeset/good-tables-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/core': minor
3+
---
4+
5+
feat: allow async operations in useComputed$ hook

packages/docs/src/routes/api/qwik/api.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,20 @@
324324
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts",
325325
"mdFile": "core.computedfn.md"
326326
},
327+
{
328+
"name": "ComputedReturnType",
329+
"id": "computedreturntype",
330+
"hierarchy": [
331+
{
332+
"name": "ComputedReturnType",
333+
"id": "computedreturntype"
334+
}
335+
],
336+
"kind": "TypeAlias",
337+
"content": "```typescript\nexport type ComputedReturnType<T> = T extends Promise<infer T> ? ReadonlySignal<T> : ReadonlySignal<T>;\n```\n**References:** [ReadonlySignal](#readonlysignal)",
338+
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts",
339+
"mdFile": "core.computedreturntype.md"
340+
},
327341
{
328342
"name": "ComputedSignal",
329343
"id": "computedsignal",
@@ -2102,7 +2116,7 @@
21022116
}
21032117
],
21042118
"kind": "Function",
2105-
"content": "Creates a computed signal which is calculated from the given function. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated, and if the result changed, all tasks which are tracking the signal will be re-run and all components that read the signal will be re-rendered.\n\nThe function must be synchronous and must not have any side effects.\n\n\n```typescript\nuseComputed$: <T>(qrl: ComputedFn<T>) => T extends Promise<any> ? never : ReadonlySignal<T>\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nqrl\n\n\n</td><td>\n\n[ComputedFn](#computedfn)<!-- -->&lt;T&gt;\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>\n**Returns:**\n\nT extends Promise&lt;any&gt; ? never : [ReadonlySignal](#readonlysignal)<!-- -->&lt;T&gt;",
2119+
"content": "Creates a computed signal which is calculated from the given function. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated, and if the result changed, all tasks which are tracking the signal will be re-run and all components that read the signal will be re-rendered.\n\nThe function must be synchronous and must not have any side effects.\n\n\n```typescript\nuseComputed$: <T>(qrl: ComputedFn<T>) => ComputedReturnType<T>\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nqrl\n\n\n</td><td>\n\n[ComputedFn](#computedfn)<!-- -->&lt;T&gt;\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>\n**Returns:**\n\n[ComputedReturnType](#computedreturntype)<!-- -->&lt;T&gt;",
21062120
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts",
21072121
"mdFile": "core.usecomputed_.md"
21082122
},

packages/docs/src/routes/api/qwik/index.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,17 @@ export type ComputedFn<T> = () => T;
353353

354354
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts)
355355

356+
## ComputedReturnType
357+
358+
```typescript
359+
export type ComputedReturnType<T> =
360+
T extends Promise<infer T> ? ReadonlySignal<T> : ReadonlySignal<T>;
361+
```
362+
363+
**References:** [ReadonlySignal](#readonlysignal)
364+
365+
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts)
366+
356367
## ComputedSignal
357368
358369
A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated, and if the result changed, all tasks which are tracking the signal will be re-run and all components that read the signal will be re-rendered.
@@ -8424,7 +8435,7 @@ Creates a computed signal which is calculated from the given function. A compute
84248435
The function must be synchronous and must not have any side effects.
84258436
84268437
```typescript
8427-
useComputed$: <T>(qrl: ComputedFn<T>) => T extends Promise<any> ? never : ReadonlySignal<T>
8438+
useComputed$: <T>(qrl: ComputedFn<T>) => ComputedReturnType<T>;
84288439
```
84298440
84308441
<table><thead><tr><th>
@@ -8454,7 +8465,7 @@ qrl
84548465
</tbody></table>
84558466
**Returns:**
84568467
8457-
T extends Promise&lt;any&gt; ? never : [ReadonlySignal](#readonlysignal)&lt;T&gt;
8468+
[ComputedReturnType](#computedreturntype)&lt;T&gt;
84588469
84598470
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts)
84608471

packages/qwik/src/core/core.api.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ export const componentQrl: <PROPS extends Record<any, any>>(componentQrl: QRL<On
7474
// @public (undocumented)
7575
export type ComputedFn<T> = () => T;
7676

77+
// @public (undocumented)
78+
export type ComputedReturnType<T> = T extends Promise<infer T> ? ReadonlySignal<T> : ReadonlySignal<T>;
79+
7780
// @public
7881
export interface ComputedSignal<T> extends ReadonlySignal<T> {
7982
force(): void;
@@ -1605,12 +1608,12 @@ export const untrack: <T>(fn: () => T) => T;
16051608
export const unwrapStore: <T>(value: T) => T;
16061609

16071610
// @public
1608-
export const useComputed$: <T>(qrl: ComputedFn<T>) => T extends Promise<any> ? never : ReadonlySignal<T>;
1611+
export const useComputed$: <T>(qrl: ComputedFn<T>) => ComputedReturnType<T>;
16091612

16101613
// Warning: (ae-internal-missing-underscore) The name "useComputedQrl" should be prefixed with an underscore because the declaration is marked as @internal
16111614
//
16121615
// @internal (undocumented)
1613-
export const useComputedQrl: <T>(qrl: QRL<ComputedFn<T>>) => T extends Promise<any> ? never : ReadonlySignal<T>;
1616+
export const useComputedQrl: <T>(qrl: QRL<ComputedFn<T>>) => ComputedReturnType<T>;
16141617

16151618
// @public
16161619
export const useConstant: <T>(value: (() => T) | T) => T;

packages/qwik/src/core/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export type { UseStylesScoped } from './use/use-styles';
109109
export type { UseSignal } from './use/use-signal';
110110
export type { ContextId } from './use/use-context';
111111
export type { UseStoreOptions } from './use/use-store.public';
112-
export type { ComputedFn } from './use/use-computed';
112+
export type { ComputedFn, ComputedReturnType } from './use/use-computed';
113113
export { useComputedQrl } from './use/use-computed';
114114
export { useSerializerQrl, useSerializer$ } from './use/use-serializer';
115115
export type { OnVisibleTaskOptions, VisibleTaskStrategy } from './use/use-visible-task';

packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export class ComputedSignalImpl<T> extends SignalImpl<T> implements BackRef {
3131
$computeQrl$: ComputeQRL<T>;
3232
$flags$: SignalFlags;
3333
$forceRunEffects$: boolean = false;
34+
private $resolvedPromiseValue$: T | null = null;
35+
3436
[_EFFECT_BACK_REF]: Map<EffectProperty | string, EffectSubscription> | null = null;
3537

3638
constructor(
@@ -82,13 +84,13 @@ export class ComputedSignalImpl<T> extends SignalImpl<T> implements BackRef {
8284
const previousEffectSubscription = ctx?.$effectSubscriber$;
8385
ctx && (ctx.$effectSubscriber$ = getSubscriber(this, EffectProperty.VNODE));
8486
try {
85-
const untrackedValue = computeQrl.getFn(ctx)() as T;
87+
const untrackedValue = this.$resolvedPromiseValue$ || (computeQrl.getFn(ctx)() as T);
8688
if (isPromise(untrackedValue)) {
87-
throw qError(QError.computedNotSync, [
88-
computeQrl.dev ? computeQrl.dev.file : '',
89-
computeQrl.$hash$,
90-
]);
89+
throw untrackedValue.then((promiseValue) => {
90+
this.$resolvedPromiseValue$ = promiseValue;
91+
});
9192
}
93+
this.$resolvedPromiseValue$ = null;
9294
DEBUG && log('Signal.$compute$', untrackedValue);
9395

9496
this.$flags$ &= ~SignalFlags.INVALID;

packages/qwik/src/core/shared/error/error.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export const codeToText = (code: number, ...parts: any[]): string => {
5151
'Unknown vnode type {{0}}.', // 43
5252
'Materialize error: missing element: {{0}} {{1}} {{2}}', // 44
5353
'Cannot coerce a Signal, use `.value` instead', // 45
54-
'useComputedSignal$ QRL {{0}} {{1}} returned a Promise', // 46
54+
'', // 46 unused
5555
'ComputedSignal is read-only', // 47
5656
'WrappedSignal is read-only', // 48
5757
'Attribute value is unsafe for SSR', // 49
@@ -121,7 +121,7 @@ export const enum QError {
121121
invalidVNodeType = 43,
122122
materializeVNodeDataError = 44,
123123
cannotCoerceSignal = 45,
124-
computedNotSync = 46,
124+
UNUSED_46 = 46,
125125
computedReadOnly = 47,
126126
wrappedReadOnly = 48,
127127
unsafeAttr = 49,

packages/qwik/src/core/ssr/ssr-render-jsx.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
QSlotParent,
2929
qwikInspectorAttr,
3030
} from '../shared/utils/markers';
31-
import { isPromise } from '../shared/utils/promises';
31+
import { isPromise, retryOnPromise } from '../shared/utils/promises';
3232
import { qInspector } from '../shared/utils/qdev';
3333
import { addComponentStylePrefix, isClassAttr } from '../shared/utils/scoped-styles';
3434
import { serializeAttribute } from '../shared/utils/styles';
@@ -78,7 +78,7 @@ export async function _walkJSX(
7878
await (value as StackFn).apply(ssr);
7979
continue;
8080
}
81-
processJSXNode(ssr, enqueue, value as JSXOutput, {
81+
await processJSXNode(ssr, enqueue, value as JSXOutput, {
8282
styleScoped: options.currentStyleScoped,
8383
parentComponentFrame: options.parentComponentFrame,
8484
});
@@ -87,7 +87,7 @@ export async function _walkJSX(
8787
await drain();
8888
}
8989

90-
function processJSXNode(
90+
async function processJSXNode(
9191
ssr: SSRContainer,
9292
enqueue: (value: StackValue) => void,
9393
value: JSXOutput,
@@ -114,7 +114,9 @@ function processJSXNode(
114114
ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.WrappedSignal] : EMPTY_ARRAY);
115115
const signalNode = ssr.getLastNode();
116116
enqueue(ssr.closeFragment);
117-
enqueue(trackSignalAndAssignHost(value, signalNode, EffectProperty.VNODE, ssr));
117+
await retryOnPromise(() => {
118+
enqueue(trackSignalAndAssignHost(value, signalNode, EffectProperty.VNODE, ssr));
119+
});
118120
} else if (isPromise(value)) {
119121
ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.Awaited] : EMPTY_ARRAY);
120122
enqueue(ssr.closeFragment);

packages/qwik/src/core/tests/use-computed.spec.tsx

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ import {
1515
useTask$,
1616
} from '@qwik.dev/core';
1717
import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing';
18-
import { describe, expect, it, vi } from 'vitest';
19-
import { ErrorProvider } from '../../testing/rendering.unit-util';
20-
import * as qError from '../shared/error/error';
21-
import { QError } from '../shared/error/error';
18+
import { describe, expect, it } from 'vitest';
2219

2320
const debug = false; //true;
2421
Error.stackTraceLimit = 100;
@@ -221,31 +218,64 @@ describe.each([
221218
);
222219
});
223220

224-
it('should disallow Promise in computed result', async () => {
225-
const qErrorSpy = vi.spyOn(qError, 'qError');
226-
const Counter = component$(() => {
227-
const count = useSignal(1);
228-
const doubleCount = useComputed$(() => Promise.resolve(count.value * 2));
229-
return (
230-
<button onClick$={() => count.value++}>
231-
{
232-
// @ts-expect-error
233-
doubleCount.value
234-
}
235-
</button>
221+
describe('async computed', () => {
222+
it('should resolve promise in computed result', async () => {
223+
const Counter = component$(() => {
224+
const count = useSignal(1);
225+
const doubleCount = useComputed$(() => Promise.resolve(count.value * 2));
226+
return <button onClick$={() => count.value++}>{doubleCount.value}</button>;
227+
});
228+
const { vNode, container } = await render(<Counter />, { debug });
229+
expect(vNode).toMatchVDOM(
230+
<>
231+
<button>
232+
<Signal ssr-required>{'2'}</Signal>
233+
</button>
234+
</>
235+
);
236+
await trigger(container.element, 'button', 'click');
237+
expect(vNode).toMatchVDOM(
238+
<>
239+
<button>
240+
<Signal ssr-required>{'4'}</Signal>
241+
</button>
242+
</>
236243
);
237244
});
238-
try {
239-
await render(
240-
<ErrorProvider>
241-
<Counter />
242-
</ErrorProvider>,
243-
{ debug }
245+
246+
it('should resolve delayed promise in computed result', async () => {
247+
const Counter = component$(() => {
248+
const count = useSignal(1);
249+
const doubleCount = useComputed$(
250+
() =>
251+
new Promise<number>((resolve) => {
252+
// TODO: hack: for some reason inside set timeout invoke context is undefined
253+
const value = count.value * 2;
254+
setTimeout(() => {
255+
resolve(value);
256+
});
257+
})
258+
);
259+
return <button onClick$={() => count.value++}>{doubleCount.value}</button>;
260+
});
261+
const { vNode, container } = await render(<Counter />, { debug });
262+
263+
expect(vNode).toMatchVDOM(
264+
<>
265+
<button>
266+
<Signal ssr-required>{'2'}</Signal>
267+
</button>
268+
</>
244269
);
245-
} catch (e) {
246-
expect((e as Error).message).toBeDefined();
247-
expect(qErrorSpy).toHaveBeenCalledWith(QError.computedNotSync, expect.any(Array));
248-
}
270+
await trigger(container.element, 'button', 'click');
271+
expect(vNode).toMatchVDOM(
272+
<>
273+
<button>
274+
<Signal ssr-required>{'4'}</Signal>
275+
</button>
276+
</>
277+
);
278+
});
249279
});
250280

251281
describe('createComputed$', () => {

packages/qwik/src/core/use/use-computed.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import { useSequentialScope } from './use-sequential-scope';
88

99
/** @public */
1010
export type ComputedFn<T> = () => T;
11+
/** @public */
12+
export type ComputedReturnType<T> =
13+
T extends Promise<infer T> ? ReadonlySignal<T> : ReadonlySignal<T>;
1114

1215
export const useComputedCommon = <T>(
1316
qrl: QRL<ComputedFn<T>>,
1417
Class: typeof ComputedSignalImpl
15-
): T extends Promise<any> ? never : ReadonlySignal<T> => {
18+
): ComputedReturnType<T> => {
1619
const { val, set } = useSequentialScope<Signal<T>>();
1720
if (val) {
1821
return val as any;
@@ -29,9 +32,7 @@ export const useComputedCommon = <T>(
2932
};
3033

3134
/** @internal */
32-
export const useComputedQrl = <T>(
33-
qrl: QRL<ComputedFn<T>>
34-
): T extends Promise<any> ? never : ReadonlySignal<T> => {
35+
export const useComputedQrl = <T>(qrl: QRL<ComputedFn<T>>): ComputedReturnType<T> => {
3536
return useComputedCommon(qrl, ComputedSignalImpl);
3637
};
3738

0 commit comments

Comments
 (0)