diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 3ac5286798e..be46656cb13 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -220,6 +220,48 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/qrl/qrl.public.ts", "mdFile": "core._.md" }, + { + "name": "AsyncComputedFn", + "id": "asynccomputedfn", + "hierarchy": [ + { + "name": "AsyncComputedFn", + "id": "asynccomputedfn" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type AsyncComputedFn = (ctx: AsyncComputedCtx) => Promise;\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-async-computed.ts", + "mdFile": "core.asynccomputedfn.md" + }, + { + "name": "AsyncComputedReadonlySignal", + "id": "asynccomputedreadonlysignal", + "hierarchy": [ + { + "name": "AsyncComputedReadonlySignal", + "id": "asynccomputedreadonlysignal" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface AsyncComputedReadonlySignal extends ReadonlySignal \n```\n**Extends:** [ReadonlySignal](#readonlysignal)<T>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", + "mdFile": "core.asynccomputedreadonlysignal.md" + }, + { + "name": "AsyncComputedReturnType", + "id": "asynccomputedreturntype", + "hierarchy": [ + { + "name": "AsyncComputedReturnType", + "id": "asynccomputedreturntype" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type AsyncComputedReturnType = T extends Promise ? AsyncComputedReadonlySignal : AsyncComputedReadonlySignal;\n```\n**References:** [AsyncComputedReadonlySignal](#asynccomputedreadonlysignal)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-async-computed.ts", + "mdFile": "core.asynccomputedreturntype.md" + }, { "name": "cache", "id": "resourcectx-cache", @@ -324,6 +366,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts", "mdFile": "core.computedfn.md" }, + { + "name": "ComputedReturnType", + "id": "computedreturntype", + "hierarchy": [ + { + "name": "ComputedReturnType", + "id": "computedreturntype" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type ComputedReturnType = T extends Promise ? never : ReadonlySignal;\n```\n**References:** [ReadonlySignal](#readonlysignal)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts", + "mdFile": "core.computedreturntype.md" + }, { "name": "ComputedSignal", "id": "computedsignal", @@ -2060,7 +2116,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface TaskCtx \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[track](#)\n\n\n\n\n\n\n\n[Tracker](#tracker)\n\n\n\n\n\n
\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[cleanup(callback)](#)\n\n\n\n\n\n
", + "content": "```typescript\nexport interface TaskCtx \n```\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[cleanup](#)\n\n\n\n\n\n\n\n(callback: () => void) => void\n\n\n\n\n\n
\n\n[track](#)\n\n\n\n\n\n\n\n[Tracker](#tracker)\n\n\n\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts", "mdFile": "core.taskctx.md" }, @@ -2120,6 +2176,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/impl/store.ts", "mdFile": "core.unwrapstore.md" }, + { + "name": "useAsyncComputed$", + "id": "useasynccomputed_", + "hierarchy": [ + { + "name": "useAsyncComputed$", + "id": "useasynccomputed_" + } + ], + "kind": "Function", + "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\nuseAsyncComputed$: (qrl: AsyncComputedFn) => AsyncComputedReturnType\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\n[AsyncComputedFn](#asynccomputedfn)<T>\n\n\n\n\n\n
\n**Returns:**\n\n[AsyncComputedReturnType](#asynccomputedreturntype)<T>", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-async-computed.ts", + "mdFile": "core.useasynccomputed_.md" + }, { "name": "useComputed$", "id": "usecomputed_", @@ -2130,7 +2200,7 @@ } ], "kind": "Function", - "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$: (qrl: ComputedFn) => T extends Promise ? never : ReadonlySignal\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\n[ComputedFn](#computedfn)<T>\n\n\n\n\n\n
\n**Returns:**\n\nT extends Promise<any> ? never : [ReadonlySignal](#readonlysignal)<T>", + "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$: (qrl: ComputedFn) => ComputedReturnType\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\n[ComputedFn](#computedfn)<T>\n\n\n\n\n\n
\n**Returns:**\n\n[ComputedReturnType](#computedreturntype)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts", "mdFile": "core.usecomputed_.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index bb1de806426..7082f98517c 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -119,6 +119,37 @@ Expression which should be lazy loaded [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/qrl/qrl.public.ts) +## AsyncComputedFn + +```typescript +export type AsyncComputedFn = (ctx: AsyncComputedCtx) => Promise; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-async-computed.ts) + +## AsyncComputedReadonlySignal + +```typescript +export interface AsyncComputedReadonlySignal extends ReadonlySignal +``` + +**Extends:** [ReadonlySignal](#readonlysignal)<T> + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts) + +## AsyncComputedReturnType + +```typescript +export type AsyncComputedReturnType = + T extends Promise + ? AsyncComputedReadonlySignal + : AsyncComputedReadonlySignal; +``` + +**References:** [AsyncComputedReadonlySignal](#asynccomputedreadonlysignal) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-async-computed.ts) + ## cache ```typescript @@ -353,6 +384,17 @@ export type ComputedFn = () => T; [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts) +## ComputedReturnType + +```typescript +export type ComputedReturnType = + T extends Promise ? never : ReadonlySignal; +``` + +**References:** [ReadonlySignal](#readonlysignal) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts) + ## ComputedSignal 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. @@ -8267,31 +8309,26 @@ Description -[track](#) +[cleanup](#) -[Tracker](#tracker) +(callback: () => void) => void - - - -
- -Method +
- +[track](#) -Description + -
+ -[cleanup(callback)](#) +[Tracker](#tracker) @@ -8433,6 +8470,47 @@ T [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/impl/store.ts) +## useAsyncComputed$ + +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. + +The function must be synchronous and must not have any side effects. + +```typescript +useAsyncComputed$: (qrl: AsyncComputedFn) => AsyncComputedReturnType; +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +qrl + + + +[AsyncComputedFn](#asynccomputedfn)<T> + + + +
+**Returns:** + +[AsyncComputedReturnType](#asynccomputedreturntype)<T> + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-async-computed.ts) + ## useComputed$ 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. @@ -8440,7 +8518,7 @@ Creates a computed signal which is calculated from the given function. A compute The function must be synchronous and must not have any side effects. ```typescript -useComputed$: (qrl: ComputedFn) => T extends Promise ? never : ReadonlySignal +useComputed$: (qrl: ComputedFn) => ComputedReturnType; ```
@@ -8470,7 +8548,7 @@ qrl
**Returns:** -T extends Promise<any> ? never : [ReadonlySignal](#readonlysignal)<T> +[ComputedReturnType](#computedreturntype)<T> [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-computed.ts) diff --git a/packages/qwik/public.d.ts b/packages/qwik/public.d.ts index f212731c4d9..ed32e9d1373 100644 --- a/packages/qwik/public.d.ts +++ b/packages/qwik/public.d.ts @@ -56,6 +56,7 @@ export { TaskCtx, // TODO do we really want to export this? untrack, + useAsyncComputed$, useComputed$, useConstant, useContext, diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index 921188abe73..b04984c184c 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -111,7 +111,7 @@ export type { UseStylesScoped } from './use/use-styles'; export type { UseSignal } from './use/use-signal'; export type { ContextId } from './use/use-context'; export type { UseStoreOptions } from './use/use-store.public'; -export type { ComputedFn } from './use/use-computed'; +export type { ComputedFn, ComputedReturnType } from './use/use-computed'; export { useComputedQrl } from './use/use-computed'; export { useSerializerQrl, useSerializer$ } from './use/use-serializer'; export type { OnVisibleTaskOptions, VisibleTaskStrategy } from './use/use-visible-task'; @@ -133,10 +133,13 @@ export { useTaskQrl } from './use/use-task'; export { useTask$ } from './use/use-task-dollar'; export { useVisibleTask$ } from './use/use-visible-task-dollar'; export { useComputed$ } from './use/use-computed'; +export type { AsyncComputedFn, AsyncComputedReturnType } from './use/use-async-computed'; +export { useAsyncComputedQrl, useAsyncComputed$ } from './use/use-async-computed'; export { useErrorBoundary } from './use/use-error-boundary'; export type { ErrorBoundaryStore } from './shared/error/error-handling'; export { type ReadonlySignal, + type AsyncComputedReadonlySignal, type Signal, type ComputedSignal, } from './reactive-primitives/signal.public'; diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 2df8dbce463..203b1664586 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -16,6 +16,18 @@ import { ValueOrPromise as ValueOrPromise_2 } from '..'; // @public export const $: (expression: T) => QRL; +// Warning: (ae-forgotten-export) The symbol "AsyncComputedCtx" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type AsyncComputedFn = (ctx: AsyncComputedCtx) => Promise; + +// @public (undocumented) +export interface AsyncComputedReadonlySignal extends ReadonlySignal { +} + +// @public (undocumented) +export type AsyncComputedReturnType = T extends Promise ? AsyncComputedReadonlySignal : AsyncComputedReadonlySignal; + // @public export type ClassList = string | undefined | null | false | Record | ClassList[]; @@ -74,6 +86,9 @@ export const componentQrl: >(componentQrl: QRL = () => T; +// @public (undocumented) +export type ComputedReturnType = T extends Promise ? never : ReadonlySignal; + // @public export interface ComputedSignal extends ReadonlySignal { force(): void; @@ -1583,7 +1598,7 @@ export const _task: (_event: Event, element: Element) => void; // @public (undocumented) export interface TaskCtx { // (undocumented) - cleanup(callback: () => void): void; + cleanup: (callback: () => void) => void; // (undocumented) track: Tracker; } @@ -1621,12 +1636,20 @@ export const untrack: (fn: () => T) => T; export const unwrapStore: (value: T) => T; // @public -export const useComputed$: (qrl: ComputedFn) => T extends Promise ? never : ReadonlySignal; +export const useAsyncComputed$: (qrl: AsyncComputedFn) => AsyncComputedReturnType; + +// Warning: (ae-internal-missing-underscore) The name "useAsyncComputedQrl" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export const useAsyncComputedQrl: (qrl: QRL>) => AsyncComputedReturnType; + +// @public +export const useComputed$: (qrl: ComputedFn) => ComputedReturnType; // Warning: (ae-internal-missing-underscore) The name "useComputedQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const useComputedQrl: (qrl: QRL>) => T extends Promise ? never : ReadonlySignal; +export const useComputedQrl: (qrl: QRL>) => ComputedReturnType; // @public export const useConstant: (value: (() => T) | T) => T; diff --git a/packages/qwik/src/core/reactive-primitives/cleanup.ts b/packages/qwik/src/core/reactive-primitives/cleanup.ts index a21e0f9f690..4c40d124b27 100644 --- a/packages/qwik/src/core/reactive-primitives/cleanup.ts +++ b/packages/qwik/src/core/reactive-primitives/cleanup.ts @@ -10,6 +10,7 @@ import { type EffectProperty, type EffectSubscription, } from './types'; +import { AsyncComputedSignalImpl } from './impl/async-computed-signal-impl'; /** Class for back reference to the EffectSubscription */ export abstract class BackRef { @@ -32,6 +33,8 @@ export function clearAllEffects(container: Container, consumer: Consumer): void for (const producer of backRefs) { if (producer instanceof SignalImpl) { clearSignal(container, producer, effect); + } else if (producer instanceof AsyncComputedSignalImpl) { + clearAsyncComputedSignal(producer, effect); } else if (container.$storeProxyMap$.has(producer)) { const target = container.$storeProxyMap$.get(producer)!; const storeHandler = getStoreHandler(target)!; @@ -53,6 +56,20 @@ function clearSignal(container: Container, producer: SignalImpl, effect: EffectS } } +function clearAsyncComputedSignal( + producer: AsyncComputedSignalImpl, + effect: EffectSubscription +) { + const effects = producer.$effects$; + if (effects) { + effects.delete(effect); + } + const pendingEffects = producer.$loadingEffects$; + if (pendingEffects) { + pendingEffects.delete(effect); + } +} + function clearStore(producer: StoreHandler, effect: EffectSubscription) { const effects = producer?.$effects$; if (effects) { 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 new file mode 100644 index 00000000000..bb98493cbaa --- /dev/null +++ b/packages/qwik/src/core/reactive-primitives/impl/async-computed-signal-impl.ts @@ -0,0 +1,136 @@ +import { qwikDebugToString } from '../../debug'; +import type { Container } from '../../shared/types'; +import { ChoreType } from '../../shared/util-chore-type'; +import { isPromise } from '../../shared/utils/promises'; +import { cleanupFn, trackFn } from '../../use/utils/tracker'; +import type { BackRef } from '../cleanup'; +import type { AsyncComputeQRL, EffectSubscription } from '../types'; +import { _EFFECT_BACK_REF, EffectProperty, SignalFlags } from '../types'; +import { throwIfQRLNotResolved } from '../utils'; +import { ComputedSignalImpl } from './computed-signal-impl'; +import { setupSignalValueAccess } from './signal-impl'; +import type { NoSerialize } from '../../shared/utils/serialize-utils'; + +const DEBUG = false; +const log = (...args: any[]) => + // eslint-disable-next-line no-console + console.log('ASYNC COMPUTED SIGNAL', ...args.map(qwikDebugToString)); + +/** + * # ================================ + * + * AsyncComputedSignalImpl + * + * # ================================ + */ +export class AsyncComputedSignalImpl + extends ComputedSignalImpl> + implements BackRef +{ + $untrackedLoading$: boolean = false; + $untrackedError$: Error | null = null; + + $loadingEffects$: null | Set = null; + $errorEffects$: null | Set = null; + $destroy$: NoSerialize<() => void> | null; + private $promiseValue$: T | null = null; + + [_EFFECT_BACK_REF]: Map | null = null; + + constructor(container: Container | null, fn: AsyncComputeQRL, flags = SignalFlags.INVALID) { + super(container, fn, flags); + } + + /** + * Loading is true if the signal is still waiting for the promise to resolve, false if the promise + * has resolved or rejected. + */ + get loading(): boolean { + return setupSignalValueAccess( + this, + () => (this.$loadingEffects$ ||= new Set()), + () => this.untrackedLoading + ); + } + + set untrackedLoading(value: boolean) { + if (value !== this.$untrackedLoading$) { + this.$untrackedLoading$ = value; + this.$container$?.$scheduler$( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + this, + this.$loadingEffects$ + ); + } + } + + get untrackedLoading() { + return this.$untrackedLoading$; + } + + /** The error that occurred when the signal was resolved. */ + get error(): Error | null { + return setupSignalValueAccess( + this, + () => (this.$errorEffects$ ||= new Set()), + () => this.untrackedError + ); + } + + set untrackedError(value: Error | null) { + if (value !== this.$untrackedError$) { + this.$untrackedError$ = value; + this.$container$?.$scheduler$( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + this, + this.$errorEffects$ + ); + } + } + + get untrackedError() { + return this.$untrackedError$; + } + + $computeIfNeeded$() { + if (!(this.$flags$ & SignalFlags.INVALID)) { + return false; + } + const computeQrl = this.$computeQrl$; + throwIfQRLNotResolved(computeQrl); + + const [cleanup] = cleanupFn(this, (err) => this.$container$?.handleError(err, null!)); + const untrackedValue = + this.$promiseValue$ ?? + (computeQrl.getFn()({ + track: trackFn(this, this.$container$), + cleanup, + }) as T); + if (isPromise(untrackedValue)) { + this.untrackedLoading = true; + this.untrackedError = null; + throw untrackedValue + .then((promiseValue) => { + this.$promiseValue$ = promiseValue; + this.untrackedLoading = false; + this.untrackedError = null; + }) + .catch((err) => { + this.untrackedLoading = false; + this.untrackedError = err; + }); + } + this.$promiseValue$ = null; + DEBUG && log('Signal.$asyncCompute$', untrackedValue); + + this.$flags$ &= ~SignalFlags.INVALID; + + const didChange = untrackedValue !== this.$untrackedValue$; + if (didChange) { + this.$untrackedValue$ = untrackedValue; + } + return didChange; + } +} diff --git a/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts index e16b9724fa5..0595c7d5471 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts @@ -11,6 +11,7 @@ import { getSubscriber } from '../subscriber'; import type { ComputeQRL, EffectSubscription } from '../types'; import { _EFFECT_BACK_REF, EffectProperty, NEEDS_COMPUTATION, SignalFlags } from '../types'; import { SignalImpl } from './signal-impl'; +import type { QRLInternal } from '../../shared/qrl/qrl-class'; const DEBUG = false; // eslint-disable-next-line no-console @@ -21,21 +22,24 @@ const log = (...args: any[]) => console.log('COMPUTED SIGNAL', ...args.map(qwikD * * The value is available synchronously, but the computation is done lazily. */ -export class ComputedSignalImpl extends SignalImpl implements BackRef { +export class ComputedSignalImpl> + extends SignalImpl + implements BackRef +{ /** * The compute function is stored here. * * The computed functions must be executed synchronously (because of this we need to eagerly * resolve the QRL during the mark dirty phase so that any call to it will be synchronous). ) */ - $computeQrl$: ComputeQRL; + $computeQrl$: S; $flags$: SignalFlags; $forceRunEffects$: boolean = false; [_EFFECT_BACK_REF]: Map | null = null; constructor( container: Container | null, - fn: ComputeQRL, + fn: S, // We need a separate flag to know when the computation needs running because // we need the old value to know if effects need running after computation flags = SignalFlags.INVALID @@ -50,7 +54,12 @@ export class ComputedSignalImpl extends SignalImpl implements BackRef { $invalidate$() { this.$flags$ |= SignalFlags.INVALID; this.$forceRunEffects$ = false; - this.$container$?.$scheduler$(ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, this); + this.$container$?.$scheduler$( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + this, + this.$effects$ + ); } /** @@ -59,7 +68,12 @@ export class ComputedSignalImpl extends SignalImpl implements BackRef { */ force() { this.$forceRunEffects$ = true; - this.$container$?.$scheduler$(ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, null, this); + this.$container$?.$scheduler$( + ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + null, + this, + this.$effects$ + ); } get untrackedValue() { @@ -82,7 +96,7 @@ export class ComputedSignalImpl extends SignalImpl implements BackRef { const previousEffectSubscription = ctx?.$effectSubscriber$; ctx && (ctx.$effectSubscriber$ = getSubscriber(this, EffectProperty.VNODE)); try { - const untrackedValue = computeQrl.getFn(ctx)() as T; + const untrackedValue = (computeQrl.getFn(ctx) as S)() as T; if (isPromise(untrackedValue)) { throw qError(QError.computedNotSync, [ computeQrl.dev ? computeQrl.dev.file : '', diff --git a/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts index 3bde3fb291d..fdbff77772b 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts @@ -42,43 +42,26 @@ export class SignalImpl implements Signal { } get value() { - const ctx = tryGetInvokeContext(); - if (ctx) { - if (this.$container$ === null) { - if (!ctx.$container$) { - return this.untrackedValue; - } - // Grab the container now we have access to it - this.$container$ = ctx.$container$; - } else { - assertTrue( - !ctx.$container$ || ctx.$container$ === this.$container$, - 'Do not use signals across containers' - ); - } - const effectSubscriber = ctx.$effectSubscriber$; - if (effectSubscriber) { - const effects = (this.$effects$ ||= new Set()); - // Let's make sure that we have a reference to this effect. - // Adding reference is essentially adding a subscription, so if the signal - // changes we know who to notify. - ensureContainsSubscription(effects, effectSubscriber); - // But when effect is scheduled in needs to be able to know which signals - // to unsubscribe from. So we need to store the reference from the effect back - // to this signal. - ensureContainsBackRef(effectSubscriber, this); - addQrlToSerializationCtx(effectSubscriber, this.$container$); - DEBUG && log('read->sub', pad('\n' + this.toString(), ' ')); - } - } - return this.untrackedValue; + return setupSignalValueAccess( + this, + () => (this.$effects$ ||= new Set()), + () => this.untrackedValue + ); } + set value(value) { if (value !== this.$untrackedValue$) { DEBUG && log('Signal.set', this.$untrackedValue$, '->', value, pad('\n' + this.toString(), ' ')); this.$untrackedValue$ = value; + // TODO: move this to the scheduler triggerEffects(this.$container$, this, this.$effects$); + // this.$container$?.$scheduler$( + // ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, + // null, + // this, + // this.$effects$ + // ); } } @@ -105,3 +88,47 @@ export class SignalImpl implements Signal { return { value: this.$untrackedValue$ }; } } + +const addEffect = ( + signal: SignalImpl, + effectSubscriber: EffectSubscription, + effects: Set +) => { + // Let's make sure that we have a reference to this effect. + // Adding reference is essentially adding a subscription, so if the signal + // changes we know who to notify. + ensureContainsSubscription(effects, effectSubscriber); + // But when effect is scheduled in needs to be able to know which signals + // to unsubscribe from. So we need to store the reference from the effect back + // to this signal. + ensureContainsBackRef(effectSubscriber, signal); + addQrlToSerializationCtx(effectSubscriber, signal.$container$); +}; + +export const setupSignalValueAccess = ( + target: SignalImpl, + effectsFn: () => Set, + returnValueFn: () => S +) => { + const ctx = tryGetInvokeContext(); + if (ctx) { + if (target.$container$ === null) { + if (!ctx.$container$) { + return returnValueFn(); + } + // Grab the container now we have access to it + target.$container$ = ctx.$container$; + } else { + assertTrue( + !ctx.$container$ || ctx.$container$ === target.$container$, + 'Do not use signals across containers' + ); + } + const effectSubscriber = ctx.$effectSubscriber$; + if (effectSubscriber) { + addEffect(target, effectSubscriber, effectsFn()); + DEBUG && log('read->sub', pad('\n' + target.toString(), ' ')); + } + } + return returnValueFn(); +}; diff --git a/packages/qwik/src/core/reactive-primitives/impl/store.ts b/packages/qwik/src/core/reactive-primitives/impl/store.ts index f5e7202ee38..9f101298bf1 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/store.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/store.ts @@ -244,6 +244,7 @@ function setNewValueAndTriggerEffects>( currentStore: StoreHandler ): void { (target as any)[prop] = value; + // TODO: trigger effects through the scheduler triggerEffects( currentStore.$container$, currentStore, diff --git a/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts index 3f92da0e52e..69eed669b68 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/wrapped-signal-impl.ts @@ -49,7 +49,8 @@ export class WrappedSignalImpl extends SignalImpl implements BackRef { this.$container$?.$scheduler$( ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, this.$hostElement$, - this + this, + this.$effects$ ); } diff --git a/packages/qwik/src/core/reactive-primitives/internal-api.ts b/packages/qwik/src/core/reactive-primitives/internal-api.ts index 17c3bdae903..c797378708b 100644 --- a/packages/qwik/src/core/reactive-primitives/internal-api.ts +++ b/packages/qwik/src/core/reactive-primitives/internal-api.ts @@ -6,6 +6,7 @@ import { getStoreTarget } from './impl/store'; import { isPropsProxy } from '../shared/jsx/jsx-runtime'; import { SignalFlags, WrappedSignalFlags } from './types'; import { WrappedSignalImpl } from './impl/wrapped-signal-impl'; +import { AsyncComputedSignalImpl } from './impl/async-computed-signal-impl'; // Keep these properties named like this so they're the same as from wrapSignal const getValueProp = (p0: any) => p0.value; @@ -33,7 +34,9 @@ export const _wrapProp = , P extends keyof T>(...args return obj[prop]; } if (isSignal(obj)) { - assertEqual(prop, 'value', 'Left side is a signal, prop must be value'); + if (!(obj instanceof AsyncComputedSignalImpl)) { + assertEqual(prop, 'value', 'Left side is a signal, prop must be value'); + } if (obj instanceof WrappedSignalImpl && obj.flags & WrappedSignalFlags.UNWRAP) { return obj; } diff --git a/packages/qwik/src/core/reactive-primitives/signal-api.ts b/packages/qwik/src/core/reactive-primitives/signal-api.ts index 14aeabd5e9e..f9d6fcf3232 100644 --- a/packages/qwik/src/core/reactive-primitives/signal-api.ts +++ b/packages/qwik/src/core/reactive-primitives/signal-api.ts @@ -4,8 +4,9 @@ import { SignalImpl } from './impl/signal-impl'; import { ComputedSignalImpl } from './impl/computed-signal-impl'; import { throwIfQRLNotResolved } from './utils'; import type { Signal } from './signal.public'; -import type { SerializerArg } from './types'; +import type { AsyncComputedCtx, AsyncComputeQRL, ComputeQRL, SerializerArg } from './types'; import { SerializerSignalImpl } from './impl/serializer-signal-impl'; +import { AsyncComputedSignalImpl } from './impl/async-computed-signal-impl'; /** @internal */ export const createSignal = (value?: T): Signal => { @@ -15,7 +16,15 @@ export const createSignal = (value?: T): Signal => { /** @internal */ export const createComputedSignal = (qrl: QRL<() => T>): ComputedSignalImpl => { throwIfQRLNotResolved(qrl); - return new ComputedSignalImpl(null, qrl as QRLInternal<() => T>); + return new ComputedSignalImpl(null, qrl as ComputeQRL); +}; + +/** @internal */ +export const createAsyncComputedSignal = ( + qrl: QRL<(ctx: AsyncComputedCtx) => Promise> +): AsyncComputedSignalImpl => { + throwIfQRLNotResolved(qrl); + return new AsyncComputedSignalImpl(null, qrl as AsyncComputeQRL); }; /** @internal */ diff --git a/packages/qwik/src/core/reactive-primitives/signal.public.ts b/packages/qwik/src/core/reactive-primitives/signal.public.ts index 7211bc7472e..bf3e11a3963 100644 --- a/packages/qwik/src/core/reactive-primitives/signal.public.ts +++ b/packages/qwik/src/core/reactive-primitives/signal.public.ts @@ -13,6 +13,13 @@ export interface ReadonlySignal { readonly value: T; } +/** @public */ +export interface AsyncComputedReadonlySignal extends ReadonlySignal { + // TODO: enable later this, after the scheduler changes for "streaming" signals values + // loading: boolean; + // error: Error | null; +} + /** * A signal is a reactive value which can be read and written. When the signal is written, all tasks * which are tracking the signal will be re-run and all components that read the signal will be diff --git a/packages/qwik/src/core/reactive-primitives/types.ts b/packages/qwik/src/core/reactive-primitives/types.ts index 22db7f7d9cc..5cd995464a9 100644 --- a/packages/qwik/src/core/reactive-primitives/types.ts +++ b/packages/qwik/src/core/reactive-primitives/types.ts @@ -1,11 +1,13 @@ import type { VNode } from '../client/types'; import type { ISsrNode } from '../ssr/ssr-types'; -import type { Task } from '../use/use-task'; +import type { Task, Tracker } from '../use/use-task'; import type { SubscriptionData } from './subscription-data'; import type { ReadonlySignal } from './signal.public'; import type { SignalImpl } from './impl/signal-impl'; import type { QRLInternal } from '../shared/qrl/qrl-class'; import type { SerializerSymbol } from '../shared/utils/serialize-utils'; +import type { ComputedFn } from '../use/use-computed'; +import type { AsyncComputedFn } from '../use/use-async-computed'; /** * # ================================ @@ -33,7 +35,12 @@ export interface InternalSignal extends InternalReadonlySignal { untrackedValue: T; } -export type ComputeQRL = QRLInternal<() => T>; +export type ComputeQRL = QRLInternal>; +export type AsyncComputedCtx = { + track: Tracker; + cleanup: (callback: () => void) => void; +}; +export type AsyncComputeQRL = QRLInternal>; export const enum SignalFlags { INVALID = 1, diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index 9bb93a3410d..23322023b75 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -93,7 +93,12 @@ import { VNodeJournalOpCode, vnode_isVNode, vnode_setAttr } from '../client/vnod import { vnode_diff } from '../client/vnode-diff'; import { triggerEffects } from '../reactive-primitives/utils'; import { isSignal, type Signal } from '../reactive-primitives/signal.public'; -import type { StoreTarget } from '../reactive-primitives/types'; +import { + type AsyncComputeQRL, + type ComputeQRL, + type EffectSubscription, + type StoreTarget, +} from '../reactive-primitives/types'; import type { ISsrNode } from '../ssr/ssr-types'; import { runResource, type ResourceDescriptor } from '../use/use-resource'; import { @@ -120,8 +125,10 @@ import { addComponentStylePrefix } from './utils/scoped-styles'; import { serializeAttribute } from './utils/styles'; import type { ValueOrPromise } from './utils/types'; import type { NodePropPayload } from '../reactive-primitives/subscription-data'; -import type { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; -import type { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; +import { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; +import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; +import type { StoreHandler } from '../reactive-primitives/impl/store'; +import { SignalImpl } from '../reactive-primitives/impl/signal-impl'; // Turn this on to get debug output of what the scheduler is doing. const DEBUG: boolean = false; @@ -170,7 +177,7 @@ export const createScheduler = ( function schedule( type: ChoreType.QRL_RESOLVE, ignore: null, - target: QRLInternal<(...args: unknown[]) => unknown> + target: ComputeQRL | AsyncComputeQRL ): ValueOrPromise; function schedule(type: ChoreType.JOURNAL_FLUSH): ValueOrPromise; function schedule(type: ChoreType.WAIT_FOR_ALL): ValueOrPromise; @@ -184,7 +191,8 @@ export const createScheduler = ( function schedule( type: ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS, host: HostElement | null, - target: Signal + target: Signal | StoreHandler, + effects: Set | null ): ValueOrPromise; function schedule(type: ChoreType.TASK | ChoreType.VISIBLE, task: Task): ValueOrPromise; function schedule( @@ -229,7 +237,9 @@ export const createScheduler = ( const isClientOnly = type === ChoreType.JOURNAL_FLUSH || type === ChoreType.NODE_DIFF || - type === ChoreType.NODE_PROP; + type === ChoreType.NODE_PROP || + type === ChoreType.QRL_RESOLVE || + type === ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS; if (isServer && isClientOnly) { DEBUG && debugTrace( @@ -468,18 +478,29 @@ export const createScheduler = ( case ChoreType.RECOMPUTE_AND_SCHEDULE_EFFECTS: { { const target = chore.$target$ as + | SignalImpl | ComputedSignalImpl - | WrappedSignalImpl; - const forceRunEffects = target.$forceRunEffects$; - target.$forceRunEffects$ = false; - if (!target.$effects$?.size) { - break; - } - returnValue = retryOnPromise(() => { - if (target.$computeIfNeeded$() || forceRunEffects) { - triggerEffects(container, target, target.$effects$); + | WrappedSignalImpl + | StoreHandler; + + const effects = chore.$payload$ as Set; + + if (target instanceof ComputedSignalImpl || target instanceof WrappedSignalImpl) { + const forceRunEffects = target.$forceRunEffects$; + target.$forceRunEffects$ = false; + if (!target.$effects$?.size) { + break; } - }); + returnValue = retryOnPromise(() => { + if (target.$computeIfNeeded$() || forceRunEffects) { + triggerEffects(container, target, effects); + } + }); + } else { + returnValue = retryOnPromise(() => { + triggerEffects(container, target, effects); + }); + } } break; } diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 3909d563550..89e4adefe4b 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -7,7 +7,7 @@ import { type DomContainer } from '../client/dom-container'; import type { VNode } from '../client/types'; import { vnode_getNode, vnode_isVNode, vnode_locate, vnode_toString } from '../client/vnode'; import { isSerializerObj } from '../reactive-primitives/utils'; -import type { SerializerArg } from '../reactive-primitives/types'; +import type { AsyncComputeQRL, SerializerArg } from '../reactive-primitives/types'; import { getOrCreateStore, getStoreHandler, @@ -56,6 +56,7 @@ import { SignalImpl } from '../reactive-primitives/impl/signal-impl'; import { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; import { SerializerSignalImpl } from '../reactive-primitives/impl/serializer-signal-impl'; +import { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl'; const deserializedProxyMap = new WeakMap(); @@ -288,6 +289,31 @@ const inflate = ( signal.$effects$ = new Set(d.slice(5) as EffectSubscription[]); break; } + case TypeIds.AsyncComputedSignal: { + const asyncComputed = target as AsyncComputedSignalImpl; + const d = data as [ + AsyncComputeQRL, + Array | null, + Array | null, + Array | null, + boolean, + Error, + unknown?, + ]; + asyncComputed.$computeQrl$ = d[0]; + asyncComputed.$effects$ = new Set(d[1]); + asyncComputed.$loadingEffects$ = new Set(d[2]); + asyncComputed.$errorEffects$ = new Set(d[3]); + asyncComputed.$untrackedLoading$ = d[4]; + asyncComputed.$untrackedError$ = d[5]; + const hasValue = d.length > 6; + if (hasValue) { + asyncComputed.$untrackedValue$ = d[6]; + } else { + asyncComputed.$flags$ |= SignalFlags.INVALID; + } + break; + } // Inflating a SerializerSignal is the same as inflating a ComputedSignal case TypeIds.SerializerSignal: case TypeIds.ComputedSignal: { @@ -498,6 +524,8 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow return new WrappedSignalImpl(container as any, null!, null!, null!); case TypeIds.ComputedSignal: return new ComputedSignalImpl(container as any, null!); + case TypeIds.AsyncComputedSignal: + return new AsyncComputedSignalImpl(container as any, null!); case TypeIds.SerializerSignal: return new SerializerSignalImpl(container as any, null!); case TypeIds.Store: @@ -1170,6 +1198,28 @@ async function serialize(serializationContext: SerializationContext): Promise | null, + Set | null, + Set | null, + boolean, + Error | null, + unknown?, + ] = [ + value.$computeQrl$, + value.$effects$, + value.$loadingEffects$, + value.$errorEffects$, + value.$untrackedLoading$, + value.$untrackedError$, + ]; + if (v !== NEEDS_COMPUTATION) { + out.push(v); + } + output(TypeIds.AsyncComputedSignal, out); } else if (value instanceof ComputedSignalImpl) { addPreloadQrl(value.$computeQrl$); const out: [QRLInternal, Set | null, unknown?] = [ @@ -1839,6 +1889,7 @@ export const enum TypeIds { Signal, WrappedSignal, ComputedSignal, + AsyncComputedSignal, SerializerSignal, Store, StoreArray, @@ -1876,6 +1927,7 @@ export const _typeIdNames = [ 'Signal', 'WrappedSignal', 'ComputedSignal', + 'AsyncComputedSignal', 'SerializerSignal', 'Store', 'StoreArray', diff --git a/packages/qwik/src/core/shared/shared-serialization.unit.ts b/packages/qwik/src/core/shared/shared-serialization.unit.ts index bf3b6822f0b..bcb738e2c0c 100644 --- a/packages/qwik/src/core/shared/shared-serialization.unit.ts +++ b/packages/qwik/src/core/shared/shared-serialization.unit.ts @@ -26,6 +26,8 @@ import { isQrl } from './qrl/qrl-utils'; import { NoSerializeSymbol, SerializerSymbol } from './utils/serialize-utils'; import { SubscriptionData } from '../reactive-primitives/subscription-data'; import { StoreFlags } from '../reactive-primitives/types'; +import { createAsyncComputedSignal } from '../reactive-primitives/signal-api'; +import { retryOnPromise } from './utils/promises'; const DEBUG = false; @@ -508,6 +510,55 @@ describe('shared-serialization', () => { (72 chars)" `); }); + it(title(TypeIds.AsyncComputedSignal), async () => { + const foo = createSignal(1); + const dirty = createAsyncComputedSignal( + inlinedQrl( + ({ track }) => Promise.resolve(track(() => (foo as SignalImpl).value) + 1), + 'dirty', + [foo] + ) + ); + const clean = createAsyncComputedSignal( + inlinedQrl( + ({ track }) => Promise.resolve(track(() => (foo as SignalImpl).value) + 1), + 'clean', + [foo] + ) + ); + await retryOnPromise(() => { + // note that this won't subscribe because we're not setting up the context + expect(clean.value).toBe(2); + }); + + const objs = await serialize(dirty, clean); + expect(dumpState(objs)).toMatchInlineSnapshot(` + " + 0 AsyncComputedSignal [ + RootRef 2 + Constant null + Constant null + Constant null + Constant false + Constant false + ] + 1 AsyncComputedSignal [ + RootRef 3 + Constant null + Constant null + Constant null + Constant false + Constant false + Number 2 + ] + 2 PreloadQRL "mock-chunk#dirty[4]" + 3 PreloadQRL "mock-chunk#clean[4]" + 4 Signal [ + Number 1 + ] + (122 chars)" + `); + }); it(title(TypeIds.Store), async () => { expect(await dump(createStore(null, { a: { b: true } }, StoreFlags.RECURSIVE))) .toMatchInlineSnapshot(` diff --git a/packages/qwik/src/core/shared/util-chore-type.ts b/packages/qwik/src/core/shared/util-chore-type.ts index f042eb81de0..e0f76992ea9 100644 --- a/packages/qwik/src/core/shared/util-chore-type.ts +++ b/packages/qwik/src/core/shared/util-chore-type.ts @@ -12,7 +12,6 @@ export const enum ChoreType { NODE_PROP, COMPONENT, RECOMPUTE_AND_SCHEDULE_EFFECTS, - // Next macro level JOURNAL_FLUSH /* ******************** */ = 16, // Next macro level diff --git a/packages/qwik/src/core/shared/utils/markers.ts b/packages/qwik/src/core/shared/utils/markers.ts index 98daa3f0a81..d83c77ed6db 100644 --- a/packages/qwik/src/core/shared/utils/markers.ts +++ b/packages/qwik/src/core/shared/utils/markers.ts @@ -56,7 +56,6 @@ export const SVG_NS = 'http://www.w3.org/2000/svg'; export const MATH_NS = 'http://www.w3.org/1998/Math/MathML'; export const ResourceEvent = 'qResource'; -export const ComputedEvent = 'qComputed'; export const RenderEvent = 'qRender'; export const TaskEvent = 'qTask'; diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts index 51829a41093..39ad302e02b 100644 --- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts @@ -28,7 +28,7 @@ import { QSlotParent, qwikInspectorAttr, } from '../shared/utils/markers'; -import { isPromise } from '../shared/utils/promises'; +import { isPromise, retryOnPromise } from '../shared/utils/promises'; import { qInspector } from '../shared/utils/qdev'; import { addComponentStylePrefix, isClassAttr } from '../shared/utils/scoped-styles'; import { serializeAttribute } from '../shared/utils/styles'; @@ -78,7 +78,7 @@ export async function _walkJSX( await (value as StackFn).apply(ssr); continue; } - processJSXNode(ssr, enqueue, value as JSXOutput, { + await processJSXNode(ssr, enqueue, value as JSXOutput, { styleScoped: options.currentStyleScoped, parentComponentFrame: options.parentComponentFrame, }); @@ -87,7 +87,7 @@ export async function _walkJSX( await drain(); } -function processJSXNode( +async function processJSXNode( ssr: SSRContainer, enqueue: (value: StackValue) => void, value: JSXOutput, @@ -114,7 +114,9 @@ function processJSXNode( ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.WrappedSignal] : EMPTY_ARRAY); const signalNode = ssr.getLastNode(); enqueue(ssr.closeFragment); - enqueue(trackSignalAndAssignHost(value, signalNode, EffectProperty.VNODE, ssr)); + await retryOnPromise(() => { + enqueue(trackSignalAndAssignHost(value, signalNode, EffectProperty.VNODE, ssr)); + }); } else if (isPromise(value)) { ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.Awaited] : EMPTY_ARRAY); enqueue(ssr.closeFragment); diff --git a/packages/qwik/src/core/tests/use-async-computed.spec.tsx b/packages/qwik/src/core/tests/use-async-computed.spec.tsx new file mode 100644 index 00000000000..cab5663f52e --- /dev/null +++ b/packages/qwik/src/core/tests/use-async-computed.spec.tsx @@ -0,0 +1,95 @@ +import { Fragment as Signal, component$, useSignal } from '@qwik.dev/core'; +import { domRender, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; +import { describe, expect, it } from 'vitest'; +import { useAsyncComputed$ } from '../use/use-async-computed'; + +const debug = false; //true; +Error.stackTraceLimit = 100; + +describe.each([ + { render: ssrRenderToDom }, // + { render: domRender }, // +])('$render.name: useAsyncComputed', ({ render }) => { + it('should resolve promise in computed result', async () => { + const Counter = component$(() => { + const count = useSignal(1); + const doubleCount = useAsyncComputed$(({ track }) => Promise.resolve(track(count) * 2)); + return ; + }); + const { vNode, container } = await render(, { debug }); + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + }); + + it('should compute async computed result from async computed result', async () => { + const Counter = component$(() => { + const count = useSignal(1); + const doubleCount = useAsyncComputed$(({ track }) => Promise.resolve(track(count) * 2)); + const quadrupleCount = useAsyncComputed$(({ track }) => + Promise.resolve(track(doubleCount) * 2) + ); + return ; + }); + const { vNode, container } = await render(, { debug }); + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + }); + + it('should resolve delayed promise in computed result', async () => { + const Counter = component$(() => { + const count = useSignal(1); + const doubleCount = useAsyncComputed$( + ({ track }) => + new Promise((resolve) => { + setTimeout(() => { + resolve(track(() => count.value * 2)); + }); + }) + ); + return ; + }); + const { vNode, container } = await render(, { debug }); + + expect(vNode).toMatchVDOM( + <> + + + ); + await trigger(container.element, 'button', 'click'); + expect(vNode).toMatchVDOM( + <> + + + ); + }); +}); diff --git a/packages/qwik/src/core/use/use-async-computed.ts b/packages/qwik/src/core/use/use-async-computed.ts new file mode 100644 index 00000000000..634c7811c84 --- /dev/null +++ b/packages/qwik/src/core/use/use-async-computed.ts @@ -0,0 +1,32 @@ +import { AsyncComputedSignalImpl } from '../reactive-primitives/impl/async-computed-signal-impl'; +import type { ComputedSignalImpl } from '../reactive-primitives/impl/computed-signal-impl'; +import { type AsyncComputedReadonlySignal } from '../reactive-primitives/signal.public'; +import type { AsyncComputedCtx } from '../reactive-primitives/types'; +import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; +import type { QRL } from '../shared/qrl/qrl.public'; +import { useComputedCommon } from './use-computed'; + +/** @public */ +export type AsyncComputedFn = (ctx: AsyncComputedCtx) => Promise; +/** @public */ +export type AsyncComputedReturnType = + T extends Promise ? AsyncComputedReadonlySignal : AsyncComputedReadonlySignal; + +/** @internal */ +export const useAsyncComputedQrl = ( + qrl: QRL> +): AsyncComputedReturnType => { + return useComputedCommon(qrl, AsyncComputedSignalImpl as typeof ComputedSignalImpl); +}; + +/** + * 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. + * + * The function must be synchronous and must not have any side effects. + * + * @public + */ +export const useAsyncComputed$ = implicit$FirstArg(useAsyncComputedQrl); diff --git a/packages/qwik/src/core/use/use-computed.ts b/packages/qwik/src/core/use/use-computed.ts index d8f63dd88db..64191ab0817 100644 --- a/packages/qwik/src/core/use/use-computed.ts +++ b/packages/qwik/src/core/use/use-computed.ts @@ -8,11 +8,17 @@ import { useSequentialScope } from './use-sequential-scope'; /** @public */ export type ComputedFn = () => T; +/** @public */ +export type ComputedReturnType = T extends Promise ? never : ReadonlySignal; -export const useComputedCommon = ( - qrl: QRL>, +export const useComputedCommon = < + T, + FUNC extends Function = ComputedFn, + RETURN = ComputedReturnType, +>( + qrl: QRL, Class: typeof ComputedSignalImpl -): T extends Promise ? never : ReadonlySignal => { +): RETURN => { const { val, set } = useSequentialScope>(); if (val) { return val as any; @@ -29,9 +35,7 @@ export const useComputedCommon = ( }; /** @internal */ -export const useComputedQrl = ( - qrl: QRL> -): T extends Promise ? never : ReadonlySignal => { +export const useComputedQrl = (qrl: QRL>): ComputedReturnType => { return useComputedCommon(qrl, ComputedSignalImpl); }; diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index bcb1721ef9e..346bf720e70 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -3,7 +3,7 @@ import { assertDefined } from '../shared/error/assert'; import { QError, qError } from '../shared/error/error'; import type { QRLInternal } from '../shared/qrl/qrl-class'; import type { QRL } from '../shared/qrl/qrl.public'; -import { ComputedEvent, RenderEvent, ResourceEvent, TaskEvent } from '../shared/utils/markers'; +import { RenderEvent, ResourceEvent, TaskEvent } from '../shared/utils/markers'; import { seal } from '../shared/utils/qdev'; import { isArray } from '../shared/utils/types'; import { setLocale } from './use-locale'; @@ -32,7 +32,6 @@ export type PossibleEvents = | SimplifiedServerRequestEvent | typeof TaskEvent | typeof RenderEvent - | typeof ComputedEvent | typeof ResourceEvent; export interface RenderInvokeContext extends InvokeContext { diff --git a/packages/qwik/src/core/use/use-resource.ts b/packages/qwik/src/core/use/use-resource.ts index 9d3197ad99b..ec2702659b0 100644 --- a/packages/qwik/src/core/use/use-resource.ts +++ b/packages/qwik/src/core/use/use-resource.ts @@ -1,25 +1,24 @@ +import { Fragment, _jsxSorted } from '../shared/jsx/jsx-runtime'; import { isServerPlatform } from '../shared/platform/platform'; import { assertQrl } from '../shared/qrl/qrl-utils'; import { type QRL } from '../shared/qrl/qrl.public'; -import { Fragment, _jsxSorted } from '../shared/jsx/jsx-runtime'; import { invoke, newInvokeContext, untrack, useBindInvokeContext } from './use-core'; import { Task, TaskFlags, cleanupTask, type DescriptorBase, type Tracker } from './use-task'; import type { Container, HostElement, ValueOrPromise } from '../../server/qwik-types'; -import type { JSXOutput } from '../shared/jsx/types/jsx-node'; -import { delay, isPromise, safeCall } from '../shared/utils/promises'; -import { isFunction, isObject } from '../shared/utils/types'; +import { clearAllEffects } from '../reactive-primitives/cleanup'; import { createStore, getStoreTarget, unwrapStore } from '../reactive-primitives/impl/store'; -import { useSequentialScope } from './use-sequential-scope'; -import { isSignal } from '../reactive-primitives/utils'; import type { Signal } from '../reactive-primitives/signal.public'; -import { clearAllEffects } from '../reactive-primitives/cleanup'; -import { ResourceEvent } from '../shared/utils/markers'; +import { StoreFlags } from '../reactive-primitives/types'; +import { isSignal } from '../reactive-primitives/utils'; import { assertDefined } from '../shared/error/assert'; -import { noSerialize } from '../shared/utils/serialize-utils'; +import type { JSXOutput } from '../shared/jsx/types/jsx-node'; import { ChoreType } from '../shared/util-chore-type'; -import { getSubscriber } from '../reactive-primitives/subscriber'; -import { EffectProperty, StoreFlags } from '../reactive-primitives/types'; +import { ResourceEvent } from '../shared/utils/markers'; +import { delay, isPromise, safeCall } from '../shared/utils/promises'; +import { isObject } from '../shared/utils/types'; +import { useSequentialScope } from './use-sequential-scope'; +import { cleanupFn, trackFn } from './utils/tracker'; const DEBUG: boolean = false; @@ -285,46 +284,15 @@ export const runResource = ( task ); - const track: Tracker = (obj: (() => unknown) | object | Signal, prop?: string) => { - const ctx = newInvokeContext(); - ctx.$effectSubscriber$ = getSubscriber(task, EffectProperty.COMPONENT); - ctx.$container$ = container; - return invoke(ctx, () => { - if (isFunction(obj)) { - return obj(); - } - if (prop) { - return (obj as Record)[prop]; - } else if (isSignal(obj)) { - return obj.value; - } else { - return obj; - } - }); - }; - - const handleError = (reason: unknown) => container.handleError(reason, host); - - const cleanups: (() => void)[] = []; - task.$destroy$ = noSerialize(() => { - cleanups.forEach((fn) => { - try { - fn(); - } catch (err) { - handleError(err); - } - }); - done = true; - }); + const track = trackFn(task, container); + const [cleanup, cleanups] = cleanupFn(task, (reason: unknown) => + container.handleError(reason, host) + ); const resourceTarget = unwrapStore(resource); const opts: ResourceCtx = { track, - cleanup(fn) { - if (typeof fn === 'function') { - cleanups.push(fn); - } - }, + cleanup, cache(policy) { let milliseconds = 0; if (policy === 'immutable') { diff --git a/packages/qwik/src/core/use/use-task.ts b/packages/qwik/src/core/use/use-task.ts index 626eae58948..7ba1bcfa6c1 100644 --- a/packages/qwik/src/core/use/use-task.ts +++ b/packages/qwik/src/core/use/use-task.ts @@ -1,30 +1,21 @@ import { getDomContainer } from '../client/dom-container'; -import { QError, qError } from '../shared/error/error'; +import { BackRef, clearAllEffects } from '../reactive-primitives/cleanup'; +import { type Signal } from '../reactive-primitives/signal.public'; import { type QRLInternal } from '../shared/qrl/qrl-class'; import { assertQrl } from '../shared/qrl/qrl-utils'; import type { QRL } from '../shared/qrl/qrl.public'; -import { ChoreType } from '../shared/util-chore-type'; import { type Container, type HostElement } from '../shared/types'; +import { ChoreType } from '../shared/util-chore-type'; import { logError } from '../shared/utils/log'; import { TaskEvent } from '../shared/utils/markers'; import { isPromise, safeCall } from '../shared/utils/promises'; -import { noSerialize, type NoSerialize } from '../shared/utils/serialize-utils'; -import { isFunction, type ValueOrPromise } from '../shared/utils/types'; -import { isSignal } from '../reactive-primitives/utils'; -import { BackRef, clearAllEffects } from '../reactive-primitives/cleanup'; -import { type Signal } from '../reactive-primitives/signal.public'; -import { invoke, newInvokeContext } from './use-core'; +import { type NoSerialize } from '../shared/utils/serialize-utils'; +import { type ValueOrPromise } from '../shared/utils/types'; +import { newInvokeContext } from './use-core'; import { useLexicalScope } from './use-lexical-scope.public'; import type { ResourceReturnInternal } from './use-resource'; import { useSequentialScope } from './use-sequential-scope'; -import { getSubscriber } from '../reactive-primitives/subscriber'; -import { - addStoreEffect, - getStoreHandler, - getStoreTarget, - isStore, -} from '../reactive-primitives/impl/store'; -import { EffectProperty, STORE_ALL_PROPS } from '../reactive-primitives/types'; +import { cleanupFn, trackFn } from './utils/tracker'; export const enum TaskFlags { VISIBLE_TASK = 1 << 0, @@ -127,7 +118,7 @@ export interface Tracker { /** @public */ export interface TaskCtx { track: Tracker; - cleanup(callback: () => void): void; + cleanup: (callback: () => void) => void; } /** @public */ @@ -183,52 +174,8 @@ export const runTask = ( iCtx.$container$ = container; const taskFn = task.$qrl$.getFn(iCtx, () => clearAllEffects(container, task)) as TaskFn; - const track: Tracker = (obj: (() => unknown) | object | Signal, prop?: string) => { - const ctx = newInvokeContext(); - ctx.$effectSubscriber$ = getSubscriber(task, EffectProperty.COMPONENT); - ctx.$container$ = container; - return invoke(ctx, () => { - if (isFunction(obj)) { - return obj(); - } - if (prop) { - return (obj as Record)[prop]; - } else if (isSignal(obj)) { - return obj.value; - } else if (isStore(obj)) { - // track whole store - addStoreEffect( - getStoreTarget(obj)!, - STORE_ALL_PROPS, - getStoreHandler(obj)!, - ctx.$effectSubscriber$! - ); - return obj; - } else { - throw qError(QError.trackObjectWithoutProp); - } - }); - }; - const handleError = (reason: unknown) => container.handleError(reason, host); - let cleanupFns: (() => void)[] | null = null; - const cleanup = (fn: () => void) => { - if (typeof fn == 'function') { - if (!cleanupFns) { - cleanupFns = []; - task.$destroy$ = noSerialize(() => { - task.$destroy$ = null; - cleanupFns!.forEach((fn) => { - try { - fn(); - } catch (err) { - handleError(err); - } - }); - }); - } - cleanupFns.push(fn); - } - }; + const track = trackFn(task, container); + const [cleanup] = cleanupFn(task, (reason: unknown) => container.handleError(reason, host)); const taskApi: TaskCtx = { track, cleanup }; const result: ValueOrPromise = safeCall( diff --git a/packages/qwik/src/core/use/use-visible-task.ts b/packages/qwik/src/core/use/use-visible-task.ts index dd3c953e80d..bd3ae9e50a7 100644 --- a/packages/qwik/src/core/use/use-visible-task.ts +++ b/packages/qwik/src/core/use/use-visible-task.ts @@ -58,6 +58,5 @@ export const useRunTask = (task: Task, eagerness: VisibleTaskStrategy | undefine }; const getTaskHandlerQrl = (task: Task): QRL => { - const taskHandler = createQRL(null, '_task', scheduleTask, null, null, [task]); - return taskHandler; + return createQRL(null, '_task', scheduleTask, null, null, [task]); }; diff --git a/packages/qwik/src/core/use/utils/tracker.ts b/packages/qwik/src/core/use/utils/tracker.ts new file mode 100644 index 00000000000..eae927125a5 --- /dev/null +++ b/packages/qwik/src/core/use/utils/tracker.ts @@ -0,0 +1,73 @@ +import { + addStoreEffect, + getStoreHandler, + getStoreTarget, + isStore, +} from '../../reactive-primitives/impl/store'; +import type { Signal } from '../../reactive-primitives/signal.public'; +import { getSubscriber } from '../../reactive-primitives/subscriber'; +import { EffectProperty, STORE_ALL_PROPS, type Consumer } from '../../reactive-primitives/types'; +import { isSignal } from '../../reactive-primitives/utils'; +import { qError, QError } from '../../shared/error/error'; +import type { Container } from '../../shared/types'; +import { noSerialize, type NoSerialize } from '../../shared/utils/serialize-utils'; +import { isFunction, isObject } from '../../shared/utils/types'; +import { invoke, newInvokeContext } from '../use-core'; +import type { Tracker } from '../use-task'; + +export type Destroyable = { $destroy$: NoSerialize<() => void> | null }; + +export const trackFn = + (target: Consumer, container: Container | null): Tracker => + (obj: (() => unknown) | object | Signal, prop?: string) => { + const ctx = newInvokeContext(); + ctx.$effectSubscriber$ = getSubscriber(target, EffectProperty.COMPONENT); + ctx.$container$ = container || undefined; + return invoke(ctx, () => { + if (isFunction(obj)) { + return obj(); + } + if (prop) { + return (obj as Record)[prop]; + } else if (isSignal(obj)) { + return obj.value; + } else if (isObject(obj) && isStore(obj)) { + // track whole store + addStoreEffect( + getStoreTarget(obj)!, + STORE_ALL_PROPS, + getStoreHandler(obj)!, + ctx.$effectSubscriber$! + ); + return obj; + } else { + throw qError(QError.trackObjectWithoutProp); + } + }); + }; + +export const cleanupFn = ( + target: T, + handleError: (err: unknown) => void +): [(callback: () => void) => void, (() => void)[]] => { + let cleanupFns: (() => void)[] | null = null; + const cleanup = (fn: () => void) => { + if (typeof fn == 'function') { + if (!cleanupFns) { + cleanupFns = []; + target.$destroy$ = noSerialize(() => { + target.$destroy$ = null; + cleanupFns!.forEach((fn) => { + try { + fn(); + } catch (err) { + handleError(err); + } + }); + }); + } + cleanupFns.push(fn); + } + }; + return [cleanup, cleanupFns ?? []]; +}; diff --git a/starters/apps/e2e/src/components/async-computed/async-computed.tsx b/starters/apps/e2e/src/components/async-computed/async-computed.tsx new file mode 100644 index 00000000000..23280cf1200 --- /dev/null +++ b/starters/apps/e2e/src/components/async-computed/async-computed.tsx @@ -0,0 +1,67 @@ +import { component$, useAsyncComputed$, useSignal } from "@qwik.dev/core"; + +export const AsyncComputedRoot = component$(() => { + const rerender = useSignal(0); + + return ( +
+ + Renders: {rerender.value} + + +
+ ); +}); + +export const AsyncComputedBasic = component$(() => { + const count = useSignal(0); + const double = useAsyncComputed$(({ track }) => + Promise.resolve(track(count) * 2), + ); + const plus3 = useAsyncComputed$(({ track }) => + Promise.resolve(track(double) + 3), + ); + const triple = useAsyncComputed$(({ track }) => + Promise.resolve(track(plus3) * 3), + ); + const sum = useAsyncComputed$(({ track }) => + Promise.resolve(track(double) + track(plus3) + track(triple)), + ); + + return ( +
+
count: {count.value}
+
double: {double.value}
+
plus3: {plus3.value}
+
triple: {triple.value}
+
sum: {sum.value + ""}
+ +
+ ); +}); + +export const PendingComponent = component$(() => { + const count = useSignal(0); + const double = useAsyncComputed$( + ({ track }) => + new Promise((resolve) => { + setTimeout(() => { + resolve(track(count) * 2); + }, 1000); + }), + ); + + return ( +
+ {(double as any).loading ? "loading" : "not loading"} +
double: {double.value}
+ +
+ ); +}); diff --git a/starters/apps/e2e/src/root.tsx b/starters/apps/e2e/src/root.tsx index e82fd7f1186..7ffba5efc94 100644 --- a/starters/apps/e2e/src/root.tsx +++ b/starters/apps/e2e/src/root.tsx @@ -36,6 +36,7 @@ import { Watch } from "./components/watch/watch"; import "./global.css"; import { QRL } from "./components/qrl/qrl"; +import { AsyncComputedRoot } from "./components/async-computed/async-computed"; const tests: Record = { "/e2e/two-listeners": () => , @@ -73,6 +74,7 @@ const tests: Record = { "/e2e/exception/render": () => , "/e2e/exception/use-task": () => , "/e2e/qrl": () => , + "/e2e/async-computed": () => , }; export const Root = component$<{ pathname: string }>(({ pathname }) => {