From e1144e8524f0b6f1b9d7894d69402f50016405bd Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Wed, 4 Feb 2026 23:17:10 +0100 Subject: [PATCH 01/10] feat(useAsync$): pollMs option --- packages/docs/src/routes/api/qwik/api.json | 4 +- packages/docs/src/routes/api/qwik/index.mdx | 10 ++- .../routes/docs/(qwik)/core/state/index.mdx | 6 +- packages/qwik/src/core/qwik.core.api.md | 6 +- .../impl/async-signal-impl.ts | 30 +++++++- .../reactive-primitives/impl/signal.unit.tsx | 63 ++++++++++++++++- .../core/reactive-primitives/signal-api.ts | 6 +- .../core/reactive-primitives/signal.public.ts | 18 +++-- .../src/core/reactive-primitives/types.ts | 13 ++-- .../qwik/src/core/shared/serdes/inflate.ts | 4 +- .../src/core/shared/serdes/serdes.unit.ts | 70 ++++++++++++------- .../qwik/src/core/shared/serdes/serialize.ts | 8 ++- .../src/core/use/use-lexical-scope.public.ts | 2 +- 13 files changed, 179 insertions(+), 61 deletions(-) diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 44bc8cb52f7..ca1310e8db7 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -446,7 +446,7 @@ } ], "kind": "Function", - "content": "Create an async computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals or async operation. When the signals change, the computed signal is recalculated.\n\nThe QRL must be a function which returns the value of the signal. The function must not have side effects, and it can be async.\n\n\n```typescript\ncreateAsync$: (qrl: () => Promise, options?: ComputedOptions) => AsyncSignal\n```\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() => Promise<T>\n\n\n\n\n\n
\n\noptions\n\n\n\n\n[ComputedOptions](#computedoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\n[AsyncSignal](#asyncsignal)<T>", + "content": "Create a signal holding a `.value` which is calculated from the given async function (QRL). The standalone version of `useAsync$`.\n\n\n```typescript\ncreateAsync$: (qrl: () => Promise, options?: AsyncSignalOptions) => AsyncSignal\n```\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() => Promise<T>\n\n\n\n\n\n
\n\noptions\n\n\n\n\nAsyncSignalOptions<T>\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\n[AsyncSignal](#asyncsignal)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", "mdFile": "core.createasync_.md" }, @@ -460,7 +460,7 @@ } ], "kind": "Function", - "content": "Create a computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated.\n\nThe QRL must be a function which returns the value of the signal. The function must not have side effects, and it must be synchronous.\n\nIf you need the function to be async, use `useAsync$` instead.\n\n\n```typescript\ncreateComputed$: (qrl: () => T, options?: ComputedOptions) => ComputedReturnType\n```\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() => T\n\n\n\n\n\n
\n\noptions\n\n\n\n\n[ComputedOptions](#computedoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\n[ComputedReturnType](#computedreturntype)<T>", + "content": "Create a computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals. When the signals change, the computed signal is recalculated.\n\nThe QRL must be a function which returns the value of the signal. The function must not have side effects, and it must be synchronous.\n\nIf you need the function to be async, use `createAsync$` instead (don't forget to use `track()`).\n\n\n```typescript\ncreateComputed$: (qrl: () => T, options?: ComputedOptions) => ComputedReturnType\n```\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() => T\n\n\n\n\n\n
\n\noptions\n\n\n\n\n[ComputedOptions](#computedoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\n[ComputedReturnType](#computedreturntype)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", "mdFile": "core.createcomputed_.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 25f183b258f..d8bdf5f718b 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -819,12 +819,10 @@ Description ## createAsync$ -Create an async computed signal which is calculated from the given QRL. A computed signal is a signal which is calculated from other signals or async operation. When the signals change, the computed signal is recalculated. - -The QRL must be a function which returns the value of the signal. The function must not have side effects, and it can be async. +Create a signal holding a `.value` which is calculated from the given async function (QRL). The standalone version of `useAsync$`. ```typescript -createAsync$: (qrl: () => Promise, options?: ComputedOptions) => +createAsync$: (qrl: () => Promise, options?: AsyncSignalOptions) => AsyncSignal; ``` @@ -858,7 +856,7 @@ options -[ComputedOptions](#computedoptions) +AsyncSignalOptions<T> @@ -879,7 +877,7 @@ Create a computed signal which is calculated from the given QRL. A computed sign The QRL must be a function which returns the value of the signal. The function must not have side effects, and it must be synchronous. -If you need the function to be async, use `useAsync$` instead. +If you need the function to be async, use `createAsync$` instead (don't forget to use `track()`). ```typescript createComputed$: (qrl: () => T, options?: ComputedOptions) => diff --git a/packages/docs/src/routes/docs/(qwik)/core/state/index.mdx b/packages/docs/src/routes/docs/(qwik)/core/state/index.mdx index c200a164112..bd14fc6345d 100644 --- a/packages/docs/src/routes/docs/(qwik)/core/state/index.mdx +++ b/packages/docs/src/routes/docs/(qwik)/core/state/index.mdx @@ -269,7 +269,11 @@ You can use this to instantiate a custom class. ### `useAsync$()` -`useAsync$()` is similar to `useComputed$()`, but it allows the computed function to be asynchronous. It returns a signal that has the result of the async function.If you read it before the async function has completed, it will stop execution and re-run the reading function when the async function is resolved. +`useAsync$()` is similar to `useComputed$()`, but it allows the compute function to be asynchronous. It returns a signal that has the result of the async function. The common use case is to fetch data asynchronously, possibly based on other signals (you need to read those with `track()`). + +If you read the `.value` before the async function has completed, it will throw a `Promise` for the result, which causes Qwik to wait until that resolves and then re-run the reading function. + +You can pass the `pollMs` option to have the async function re-execute periodically. This is useful to keep data fresh. ### `useResource$()` diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index b99524936e0..7cd11b5615c 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -189,14 +189,16 @@ export interface CorrectedToggleEvent extends Event { readonly prevState: 'open' | 'closed'; } +// Warning: (ae-forgotten-export) The symbol "AsyncSignalOptions" needs to be exported by the entry point index.d.ts +// // @public -export const createAsync$: (qrl: () => Promise, options?: ComputedOptions) => AsyncSignal; +export const createAsync$: (qrl: () => Promise, options?: AsyncSignalOptions) => AsyncSignal; // Warning: (ae-forgotten-export) The symbol "AsyncSignalImpl" needs to be exported by the entry point index.d.ts // Warning: (ae-internal-missing-underscore) The name "createAsyncQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const createAsyncQrl: (qrl: QRL<(ctx: AsyncCtx) => Promise>, options?: ComputedOptions) => AsyncSignalImpl; +export const createAsyncQrl: (qrl: QRL<(ctx: AsyncCtx) => Promise>, options?: AsyncSignalOptions) => AsyncSignalImpl; // @public export const createComputed$: (qrl: () => T, options?: ComputedOptions) => ComputedReturnType; diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts index 9a5542a6741..408736451fa 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts @@ -1,3 +1,4 @@ +import { isBrowser } from '@qwik.dev/core/build'; import { qwikDebugToString } from '../../debug'; import type { NoSerialize } from '../../shared/serdes/verify'; import type { Container } from '../../shared/types'; @@ -13,6 +14,7 @@ import { NEEDS_COMPUTATION, SerializationSignalFlags, SignalFlags, + type AsyncSignalOptions, } from '../types'; import { scheduleEffects } from '../utils'; import { ComputedSignalImpl } from './computed-signal-impl'; @@ -39,15 +41,19 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple $destroy$: NoSerialize<() => void> | null; $promiseValue$: T | typeof NEEDS_COMPUTATION = NEEDS_COMPUTATION; private $promise$: ValueOrPromise | null = null; + $pollMs$: number = 0; + $pollTimeoutId$: ReturnType | undefined = undefined; [_EFFECT_BACK_REF]: Map | undefined = undefined; constructor( container: Container | null, fn: AsyncQRL, - flags: SignalFlags | SerializationSignalFlags = SignalFlags.INVALID + flags: SignalFlags | SerializationSignalFlags = SignalFlags.INVALID, + options?: AsyncSignalOptions ) { super(container, fn, flags); + this.$pollMs$ = options?.pollMs || 0; } /** @@ -95,6 +101,11 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple } override invalidate() { + // clear the poll timeout before invalidating + if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); + this.$pollTimeoutId$ = undefined; + } // clear the promise, we need to get function again this.$promise$ = null; super.invalidate(); @@ -142,6 +153,7 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple this.$flags$ &= ~SignalFlags.RUN_EFFECTS; scheduleEffects(this.$container$, this, this.$effects$); } + this.$scheduleNextPoll$(); }) .catch((err) => { if (isPromise(err)) { @@ -152,6 +164,7 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple this.$promiseValue$ = err; this.untrackedLoading = false; this.untrackedError = err; + this.$scheduleNextPoll$(); }); if (isFirstComputation) { @@ -226,4 +239,19 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple } return didChange; } + + private $scheduleNextPoll$() { + if ((isBrowser || import.meta.env.TEST) && this.$pollMs$ > 0 && this.$effects$?.size) { + if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); + } + this.$pollTimeoutId$ = setTimeout(() => { + this.invalidate(); + if (this.$effects$?.size) { + retryOnPromise(this.$computeIfNeeded$.bind(this)); + } + }, this.$pollMs$); + this.$pollTimeoutId$?.unref?.(); + } + } } diff --git a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx index 4248ceeb541..2067c99bcb3 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx +++ b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx @@ -7,7 +7,7 @@ import { inlinedQrl } from '../../shared/qrl/qrl'; import { type QRLInternal } from '../../shared/qrl/qrl-class'; import { type QRL } from '../../shared/qrl/qrl.public'; import type { Container, HostElement } from '../../shared/types'; -import { retryOnPromise } from '../../shared/utils/promises'; +import { delay, retryOnPromise } from '../../shared/utils/promises'; import { invoke, newInvokeContext } from '../../use/use-core'; import { Task } from '../../use/use-task'; import { @@ -21,6 +21,7 @@ import { createComputedQrl, createSerializer$, createSignal, + createAsync$, type ComputedSignal, type SerializerSignal, type Signal, @@ -29,6 +30,7 @@ import { getSubscriber } from '../subscriber'; import { vnode_newVirtual, vnode_setProp } from '../../client/vnode-utils'; import { ELEMENT_SEQ } from '../../shared/utils/markers'; import type { ComputedSignalImpl } from './computed-signal-impl'; +import type { AsyncSignalImpl } from './async-signal-impl'; class Foo { constructor(public val: number = 0) {} @@ -270,6 +272,65 @@ describe('signal', () => { expect(wrapped2).toBe(wrapped); }); }); + describe('async signal with poll', () => { + it('should store poll ms on instance', async () => { + await withContainer(async () => { + const pollMs = 50; + const signal = createAsync$(async () => 42, { pollMs }) as AsyncSignalImpl; + + // Verify poll is stored on instance + expect(signal.$pollMs$).toBe(pollMs); + expect(signal.$pollTimeoutId$).toBeUndefined(); + }); + }); + + it('should clear poll timeout on invalidate', async () => { + await withContainer(async () => { + const pollMs = 1; + const signal = createAsync$(async () => 42, { pollMs }) as AsyncSignalImpl; + + // Subscribe to create effects + await retryOnPromise(async () => { + effect$(() => signal.value); + }); + + // Invalidate signal - should clear any pending poll timeout + signal.invalidate(); + + // Poll timeout should be cleared + expect(signal.$pollTimeoutId$).toBeUndefined(); + }); + }); + + it('should poll', async () => { + await withContainer(async () => { + const pollMs = 1; + const ref = { count: 42 }; + const signal = createAsync$(async () => ref.count++, { + pollMs, + }) as AsyncSignalImpl; + + // Subscribe to create effects + await retryOnPromise(async () => { + effect$(() => signal.value); + await delay(10); + expect(signal.value).toBeGreaterThan(42); + }); + }); + }); + + it('should preserve poll setting for SSR hydration', async () => { + await withContainer(async () => { + const pollMs = 75; + const signal = createAsync$(async () => 99, { pollMs }) as AsyncSignalImpl; + + // Verify poll is preserved on instance (for SSR scenarios) + // Even on SSR (when isBrowser is false), the pollMs should be stored + // so that if the signal is hydrated on the browser, polling can resume + expect(signal.$pollMs$).toBe(pollMs); + }); + }); + }); }); //////////////////////////////////////// diff --git a/packages/qwik/src/core/reactive-primitives/signal-api.ts b/packages/qwik/src/core/reactive-primitives/signal-api.ts index 220af14538f..2b4d97ad35f 100644 --- a/packages/qwik/src/core/reactive-primitives/signal-api.ts +++ b/packages/qwik/src/core/reactive-primitives/signal-api.ts @@ -6,6 +6,7 @@ import type { Signal } from './signal.public'; import { type AsyncCtx, type AsyncQRL, + type AsyncSignalOptions, type ComputedOptions, type ComputeQRL, type SerializerArg, @@ -34,12 +35,13 @@ export const createComputedSignal = ( /** @internal */ export const createAsyncSignal = ( qrl: QRL<(ctx: AsyncCtx) => Promise>, - options?: ComputedOptions + options?: AsyncSignalOptions ): AsyncSignalImpl => { return new AsyncSignalImpl( options?.container || null, qrl as AsyncQRL, - getComputedSignalFlags(options?.serializationStrategy || 'never') + getComputedSignalFlags(options?.serializationStrategy || 'never'), + options ); }; diff --git a/packages/qwik/src/core/reactive-primitives/signal.public.ts b/packages/qwik/src/core/reactive-primitives/signal.public.ts index 1181f632c11..34c88554e26 100644 --- a/packages/qwik/src/core/reactive-primitives/signal.public.ts +++ b/packages/qwik/src/core/reactive-primitives/signal.public.ts @@ -1,5 +1,5 @@ import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; -import type { ComputedOptions, SerializerArg } from './types'; +import type { AsyncSignalOptions, ComputedOptions, SerializerArg } from './types'; import { createSignal as _createSignal, createComputedSignal as createComputedQrl, @@ -90,7 +90,7 @@ export const createSignal: { * The QRL must be a function which returns the value of the signal. The function must not have side * effects, and it must be synchronous. * - * If you need the function to be async, use `useAsync$` instead. + * If you need the function to be async, use `createAsync$` instead (don't forget to use `track()`). * * @public */ @@ -101,17 +101,15 @@ export const createComputed$: ( export { createComputedQrl }; /** - * Create an async computed signal which is calculated from the given QRL. A computed signal is a - * signal which is calculated from other signals or async operation. When the signals change, the - * computed signal is recalculated. - * - * The QRL must be a function which returns the value of the signal. The function must not have side - * effects, and it can be async. + * Create a signal holding a `.value` which is calculated from the given async function (QRL). The + * standalone version of `useAsync$`. * * @public */ -export const createAsync$: (qrl: () => Promise, options?: ComputedOptions) => AsyncSignal = - /*#__PURE__*/ implicit$FirstArg(createAsyncQrl as any); +export const createAsync$: ( + qrl: () => Promise, + options?: AsyncSignalOptions +) => AsyncSignal = /*#__PURE__*/ implicit$FirstArg(createAsyncQrl as any); export { createAsyncQrl }; /** diff --git a/packages/qwik/src/core/reactive-primitives/types.ts b/packages/qwik/src/core/reactive-primitives/types.ts index 23f822bd659..bedfb5f7ca9 100644 --- a/packages/qwik/src/core/reactive-primitives/types.ts +++ b/packages/qwik/src/core/reactive-primitives/types.ts @@ -58,25 +58,26 @@ export interface AsyncSignalOptions extends ComputedOptions { * When subscribers drop to 0, run cleanup in the next tick, instead of waiting for the function * inputs to change. * + * Defaults to `false`, meaning cleanup happens only when inputs change. + * * @deprecated Not implemented yet - * @default false */ eagerCleanup?: boolean; /** * Wait for previous invocation to complete before running again. * + * Defaults to `true`. + * * @deprecated Not implemented yet - * @default true */ awaitPrevious?: boolean; /** - * In the browser, re-run the function after `poll` ms if subscribers exist, even when no input + * In the browser, re-run the function after `pollMs` ms if subscribers exist, even when no input * state changed. If `0`, does not poll. * - * @deprecated Not implemented yet - * @default 0 + * Defaults to `0`. */ - poll?: number; + pollMs?: number; } export const enum SignalFlags { diff --git a/packages/qwik/src/core/shared/serdes/inflate.ts b/packages/qwik/src/core/shared/serdes/inflate.ts index 6ab5f85f290..be5dfe5013f 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -155,19 +155,21 @@ export const inflate = ( boolean, Error, unknown?, + number?, ]; asyncSignal.$computeQrl$ = d[0]; asyncSignal[_EFFECT_BACK_REF] = d[1]; asyncSignal.$effects$ = new Set(d[2]); asyncSignal.$loadingEffects$ = new Set(d[3]); asyncSignal.$errorEffects$ = new Set(d[4]); - asyncSignal.$untrackedLoading$ = d[5]; + asyncSignal.$untrackedLoading$ = d[5] || false; asyncSignal.$untrackedError$ = d[6]; const hasValue = d.length > 7; if (hasValue) { asyncSignal.$untrackedValue$ = d[7]; asyncSignal.$promiseValue$ = d[7]; } + asyncSignal.$pollMs$ = d[8] ?? 0; asyncSignal.$flags$ |= SignalFlags.INVALID; break; } diff --git a/packages/qwik/src/core/shared/serdes/serdes.unit.ts b/packages/qwik/src/core/shared/serdes/serdes.unit.ts index 584e07afc0c..27ee6bb835a 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.unit.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.unit.ts @@ -2,6 +2,7 @@ import { $, _verifySerializable, componentQrl, + createAsync$, createComputedQrl, createSerializer$, createSignal, @@ -9,6 +10,7 @@ import { noSerialize, NoSerializeSymbol, SerializerSymbol, + type AsyncSignal, } from '@qwik.dev/core'; import { describe, expect, it, vi } from 'vitest'; import { _fnSignal, _wrapProp } from '../../internal'; @@ -31,6 +33,7 @@ import { _dumpState } from './dump-state'; import { _createDeserializeContainer } from './serdes.public'; import { createSerializationContext } from './serialization-context'; import { _serializationWeakRef } from './serialize'; +import type { AsyncSignalImpl } from '../../reactive-primitives/impl/async-signal-impl'; const DEBUG = false; @@ -674,6 +677,14 @@ describe('shared-serialization', () => { serializationStrategy: 'always', } ); + const polling = createAsyncSignal( + inlinedQrl( + ({ track }) => Promise.resolve(track(() => (foo as SignalImpl).value) + 1), + 'polling', + [foo] + ), + { pollMs: 100 } + ); await retryOnPromise(() => { // note that this won't subscribe because we're not setting up the context @@ -682,52 +693,49 @@ describe('shared-serialization', () => { expect(always.value).toBe(2); }); - const objs = await serialize(dirty, clean, never, always); + const objs = await serialize(dirty, clean, never, always, polling); expect(_dumpState(objs)).toMatchInlineSnapshot(` " 0 AsyncSignal [ - QRL "5#6#4" - Constant undefined - Constant undefined - Constant undefined - Constant undefined - Constant false + QRL "6#7#5" ] 1 AsyncSignal [ - QRL "5#7#4" - Constant undefined - Constant undefined - Constant undefined - Constant undefined - Constant false + QRL "6#8#5" ] 2 AsyncSignal [ - QRL "5#8#4" + QRL "6#9#5" + ] + 3 AsyncSignal [ + QRL "6#10#5" Constant undefined Constant undefined Constant undefined Constant undefined - Constant false + Constant undefined + Constant undefined + {number} 2 ] - 3 AsyncSignal [ - QRL "5#9#4" + 4 AsyncSignal [ + QRL "6#11#5" Constant undefined Constant undefined Constant undefined Constant undefined - Constant false Constant undefined - {number} 2 + Constant undefined + Constant NEEDS_COMPUTATION + {number} 100 ] - 4 Signal [ + 5 Signal [ {number} 1 ] - 5 {string} "mock-chunk" - 6 {string} "dirty" - 7 {string} "clean" - 8 {string} "never" - 9 {string} "always" - (214 chars)" + 6 {string} "mock-chunk" + 7 {string} "dirty" + 8 {string} "clean" + 9 {string} "never" + 10 {string} "always" + 11 {string} "polling" + (217 chars)" `); }); it(title(TypeIds.Store), async () => { @@ -1009,6 +1017,16 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.WrappedSignal)); it.todo(title(TypeIds.ComputedSignal)); it.todo(title(TypeIds.SerializerSignal)); + it(title(TypeIds.AsyncSignal), async () => { + const asyncSignal = createAsync$(async () => 123, { pollMs: 50 }); + const objs = await serialize(asyncSignal); + const restored = deserialize(objs)[0] as AsyncSignal; + expect(isSignal(restored)).toBeTruthy(); + expect((restored as AsyncSignalImpl).$pollMs$).toBe(50); + await restored.promise(); + // note that this won't subscribe because we're not setting up the context + expect(restored.value).toBe(123); + }); // this requires a domcontainer it(title(TypeIds.Store), async () => { const orig: any = { a: { b: true } }; diff --git a/packages/qwik/src/core/shared/serdes/serialize.ts b/packages/qwik/src/core/shared/serdes/serialize.ts index 19dcd0fca60..6a46fd694bb 100644 --- a/packages/qwik/src/core/shared/serdes/serialize.ts +++ b/packages/qwik/src/core/shared/serdes/serialize.ts @@ -424,6 +424,7 @@ export async function serialize(serializationContext: SerializationContext): Pro value.$flags$ & SerializationSignalFlags.SERIALIZATION_STRATEGY_NEVER; const isInvalid = value.$flags$ & SignalFlags.INVALID; const isSkippable = fastSkipSerialize(value.$untrackedValue$); + const pollMs = value instanceof AsyncSignalImpl ? value.$pollMs$ : 0; if (shouldAlwaysSerialize) { v = value.$untrackedValue$; @@ -443,14 +444,14 @@ export async function serialize(serializationContext: SerializationContext): Pro out.push( value.$loadingEffects$, value.$errorEffects$, - value.$untrackedLoading$, + value.$untrackedLoading$ || undefined, value.$untrackedError$ ); } let keepUndefined = false; - if (v !== NEEDS_COMPUTATION) { + if (v !== NEEDS_COMPUTATION || pollMs) { out.push(v); if (!isAsync && v === undefined) { @@ -462,6 +463,9 @@ export async function serialize(serializationContext: SerializationContext): Pro keepUndefined = true; } } + if (pollMs) { + out.push(pollMs); + } output(isAsync ? TypeIds.AsyncSignal : TypeIds.ComputedSignal, out, keepUndefined); } else { const v = value.$untrackedValue$; diff --git a/packages/qwik/src/core/use/use-lexical-scope.public.ts b/packages/qwik/src/core/use/use-lexical-scope.public.ts index b78934e5ee8..fba9628e365 100644 --- a/packages/qwik/src/core/use/use-lexical-scope.public.ts +++ b/packages/qwik/src/core/use/use-lexical-scope.public.ts @@ -14,7 +14,7 @@ import { _captures } from '../shared/qrl/qrl-class'; * NOTE: `useLexicalScope` method can only be used in the synchronous portion of the callback * (before any `await` statements.) * - * @deprecated Read from `_captures` directly instead. + * @deprecated Use `_captures` instead. * @internal */ // From acd2829ea6cacc8c9ed091c0c14d0c7dec0cf3bc Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 5 Feb 2026 09:35:11 +0100 Subject: [PATCH 02/10] feat(useAsync$): add pollMs accessor this allows dynamically changing polling, even from within the compute function --- packages/docs/src/routes/api/qwik/api.json | 2 +- packages/docs/src/routes/api/qwik/index.mdx | 15 ++++++++++++++ packages/qwik/src/core/qwik.core.api.md | 1 + .../impl/async-signal-impl.ts | 20 ++++++++++++++++--- .../reactive-primitives/impl/signal.unit.tsx | 19 ++++++++++++++++++ .../core/reactive-primitives/signal-api.ts | 2 +- .../core/reactive-primitives/signal.public.ts | 5 +++++ .../qwik/src/core/shared/serdes/allocate.ts | 2 +- .../qwik/src/core/shared/serdes/inflate.ts | 3 ++- 9 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index ca1310e8db7..beb7db283b9 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 AsyncSignal 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 \\| undefined\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[promise()](#asyncsignal-promise)\n\n\n\n\nA promise that resolves when the value is computed.\n\n\n
", + "content": "```typescript\nexport interface AsyncSignal extends ComputedSignal \n```\n**Extends:** [ComputedSignal](#computedsignal)<T>\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[error](#)\n\n\n\n\n\n\n\nError \\| undefined\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[pollMs](#)\n\n\n\n\n\n\n\nnumber\n\n\n\n\nPoll interval in ms. Writable and immediately effective when the signal has consumers. If set to `0`, polling stops.\n\n\n
\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[promise()](#asyncsignal-promise)\n\n\n\n\nA promise that resolves when the value is computed.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", "mdFile": "core.asyncsignal.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index d8bdf5f718b..8404cf19d86 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -182,6 +182,21 @@ boolean Whether the signal is currently loading. + + + +[pollMs](#) + + + + + +number + + + +Poll interval in ms. Writable and immediately effective when the signal has consumers. If set to `0`, polling stops. + diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 7cd11b5615c..3958b9bfc61 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -21,6 +21,7 @@ export type AsyncFn = (ctx: AsyncCtx) => Promise; export interface AsyncSignal extends ComputedSignal { error: Error | undefined; loading: boolean; + pollMs: number; promise(): Promise; } diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts index 408736451fa..652479bd9d9 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts @@ -14,7 +14,6 @@ import { NEEDS_COMPUTATION, SerializationSignalFlags, SignalFlags, - type AsyncSignalOptions, } from '../types'; import { scheduleEffects } from '../utils'; import { ComputedSignalImpl } from './computed-signal-impl'; @@ -50,10 +49,10 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple container: Container | null, fn: AsyncQRL, flags: SignalFlags | SerializationSignalFlags = SignalFlags.INVALID, - options?: AsyncSignalOptions + pollMs: number = 0 ) { super(container, fn, flags); - this.$pollMs$ = options?.pollMs || 0; + this.pollMs = pollMs; } /** @@ -100,6 +99,21 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple return this.$untrackedError$; } + get pollMs() { + return this.$pollMs$; + } + + set pollMs(value: number) { + if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); + this.$pollTimeoutId$ = undefined; + } + this.$pollMs$ = value; + if (this.$pollMs$ > 0 && this.$effects$?.size) { + this.$scheduleNextPoll$(); + } + } + override invalidate() { // clear the poll timeout before invalidating if (this.$pollTimeoutId$ !== undefined) { diff --git a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx index 2067c99bcb3..75977cba818 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx +++ b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx @@ -279,11 +279,30 @@ describe('signal', () => { const signal = createAsync$(async () => 42, { pollMs }) as AsyncSignalImpl; // Verify poll is stored on instance + expect(signal.pollMs).toBe(pollMs); expect(signal.$pollMs$).toBe(pollMs); expect(signal.$pollTimeoutId$).toBeUndefined(); }); }); + it('should update pollMs and reschedule with consumers', async () => { + await withContainer(async () => { + const signal = createAsync$(async () => 42, { pollMs: 0 }) as AsyncSignalImpl; + + signal.pollMs = 1; + expect(signal.$pollTimeoutId$).toBeUndefined(); + + await retryOnPromise(async () => { + effect$(() => signal.value); + }); + + expect(signal.$pollTimeoutId$).toBeDefined(); + + signal.pollMs = 0; + expect(signal.$pollTimeoutId$).toBeUndefined(); + }); + }); + it('should clear poll timeout on invalidate', async () => { await withContainer(async () => { const pollMs = 1; diff --git a/packages/qwik/src/core/reactive-primitives/signal-api.ts b/packages/qwik/src/core/reactive-primitives/signal-api.ts index 2b4d97ad35f..f03b972eb41 100644 --- a/packages/qwik/src/core/reactive-primitives/signal-api.ts +++ b/packages/qwik/src/core/reactive-primitives/signal-api.ts @@ -41,7 +41,7 @@ export const createAsyncSignal = ( options?.container || null, qrl as AsyncQRL, getComputedSignalFlags(options?.serializationStrategy || 'never'), - options + options?.pollMs || 0 ); }; diff --git a/packages/qwik/src/core/reactive-primitives/signal.public.ts b/packages/qwik/src/core/reactive-primitives/signal.public.ts index 34c88554e26..dd1593b60f9 100644 --- a/packages/qwik/src/core/reactive-primitives/signal.public.ts +++ b/packages/qwik/src/core/reactive-primitives/signal.public.ts @@ -20,6 +20,11 @@ export interface AsyncSignal extends ComputedSignal { loading: boolean; /** The error that occurred while computing the signal. */ error: Error | undefined; + /** + * Poll interval in ms. Writable and immediately effective when the signal has consumers. If set + * to `0`, polling stops. + */ + pollMs: number; /** A promise that resolves when the value is computed. */ promise(): Promise; } diff --git a/packages/qwik/src/core/shared/serdes/allocate.ts b/packages/qwik/src/core/shared/serdes/allocate.ts index 3580cdd6b0b..d7aef32e2da 100644 --- a/packages/qwik/src/core/shared/serdes/allocate.ts +++ b/packages/qwik/src/core/shared/serdes/allocate.ts @@ -99,7 +99,7 @@ export const allocate = (container: DeserializeContainer, typeId: number, value: case TypeIds.ComputedSignal: return new ComputedSignalImpl(container as any, null!); case TypeIds.AsyncSignal: - return new AsyncSignalImpl(container as any, null!); + return new AsyncSignalImpl(container as any, null!, undefined, 0); case TypeIds.SerializerSignal: return new SerializerSignalImpl(container as any, null!); case TypeIds.Store: { diff --git a/packages/qwik/src/core/shared/serdes/inflate.ts b/packages/qwik/src/core/shared/serdes/inflate.ts index be5dfe5013f..63c2eee92b1 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -169,7 +169,8 @@ export const inflate = ( asyncSignal.$untrackedValue$ = d[7]; asyncSignal.$promiseValue$ = d[7]; } - asyncSignal.$pollMs$ = d[8] ?? 0; + // Note, we use the setter so that it schedules polling if needed + asyncSignal.pollMs = d[8] ?? 0; asyncSignal.$flags$ |= SignalFlags.INVALID; break; } From 4dcd179b0e442ab6f7c1e55d2ab9f6201a3e1059 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 5 Feb 2026 15:03:09 +0100 Subject: [PATCH 03/10] feat(useAsync$): resume polling on the client from qidle --- packages/docs/src/routes/api/qwik/api.json | 2 +- packages/docs/src/routes/api/qwik/index.mdx | 4 +- packages/qwik/handlers.mjs | 2 +- packages/qwik/src/core/internal.ts | 4 +- packages/qwik/src/core/qwik.core.api.md | 18 ++- .../impl/async-signal-impl.ts | 66 +++-------- .../qwik/src/core/shared/jsx/bind-handlers.ts | 11 ++ .../src/core/shared/jsx/bind-handlers.unit.ts | 111 ++++++++++++++++++ .../src/core/shared/serdes/qrl-to-string.ts | 2 + .../shared/serdes/serialization-context.ts | 3 + packages/qwik/src/core/ssr/ssr-render-jsx.ts | 21 ++++ .../qwik/src/core/tests/use-async.spec.tsx | 40 ++++++- packages/qwik/src/core/use/use-async.ts | 9 +- packages/qwik/src/optimizer/src/manifest.ts | 2 +- packages/qwik/src/server/ssr-container.ts | 16 ++- 15 files changed, 251 insertions(+), 60 deletions(-) create mode 100644 packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index beb7db283b9..4c9cef80490 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -2346,7 +2346,7 @@ } ], "kind": "Function", - "content": "Creates an AsyncSignal which holds the result of the given async function. If the function uses `track()` to track reactive state, and that state changes, the AsyncSignal is recalculated, and if the result changed, all tasks which are tracking the AsyncSignal will be re-run and all subscribers (components, tasks etc) that read the AsyncSignal will be updated.\n\nIf the async function throws an error, the AsyncSignal will capture the error and set the `error` property. The error can be cleared by re-running the async function successfully.\n\nWhile the async function is running, the `loading` property will be set to `true`. Once the function completes, `loading` will be set to `false`.\n\nIf the value has not yet been resolved, reading the AsyncSignal will throw a Promise, which will retry the component or task once the value resolves.\n\nIf the value has been resolved, but the async function is re-running, reading the AsyncSignal will subscribe to it and return the last resolved value until the new value is ready. As soon as the new value is ready, the subscribers will be updated.\n\n\n```typescript\nuseAsync$: (qrl: AsyncFn, options?: ComputedOptions | undefined) => AsyncSignal\n```\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[AsyncFn](#asyncfn)<T>\n\n\n\n\n\n
\n\noptions\n\n\n\n\n[ComputedOptions](#computedoptions) \\| undefined\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\n[AsyncSignal](#asyncsignal)<T>", + "content": "Creates an AsyncSignal which holds the result of the given async function. If the function uses `track()` to track reactive state, and that state changes, the AsyncSignal is recalculated, and if the result changed, all tasks which are tracking the AsyncSignal will be re-run and all subscribers (components, tasks etc) that read the AsyncSignal will be updated.\n\nIf the async function throws an error, the AsyncSignal will capture the error and set the `error` property. The error can be cleared by re-running the async function successfully.\n\nWhile the async function is running, the `loading` property will be set to `true`. Once the function completes, `loading` will be set to `false`.\n\nIf the value has not yet been resolved, reading the AsyncSignal will throw a Promise, which will retry the component or task once the value resolves.\n\nIf the value has been resolved, but the async function is re-running, reading the AsyncSignal will subscribe to it and return the last resolved value until the new value is ready. As soon as the new value is ready, the subscribers will be updated.\n\n\n```typescript\nuseAsync$: (qrl: AsyncFn, options?: AsyncSignalOptions | undefined) => AsyncSignal\n```\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[AsyncFn](#asyncfn)<T>\n\n\n\n\n\n
\n\noptions\n\n\n\n\nAsyncSignalOptions<T> \\| undefined\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\n[AsyncSignal](#asyncsignal)<T>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-async.ts", "mdFile": "core.useasync_.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 8404cf19d86..3c609972d91 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -9025,7 +9025,7 @@ If the value has not yet been resolved, reading the AsyncSignal will throw a Pro If the value has been resolved, but the async function is re-running, reading the AsyncSignal will subscribe to it and return the last resolved value until the new value is ready. As soon as the new value is ready, the subscribers will be updated. ```typescript -useAsync$: (qrl: AsyncFn, options?: ComputedOptions | undefined) => +useAsync$: (qrl: AsyncFn, options?: AsyncSignalOptions | undefined) => AsyncSignal; ``` @@ -9059,7 +9059,7 @@ options -[ComputedOptions](#computedoptions) \| undefined +AsyncSignalOptions<T> \| undefined diff --git a/packages/qwik/handlers.mjs b/packages/qwik/handlers.mjs index 8d92faa8df7..1c7c8924c4b 100644 --- a/packages/qwik/handlers.mjs +++ b/packages/qwik/handlers.mjs @@ -6,4 +6,4 @@ * * Make sure that these handlers are listed in manifest.ts */ -export { _chk, _run, _task, _val } from '@qwik.dev/core'; +export { _chk, _res, _run, _task, _val } from '@qwik.dev/core'; diff --git a/packages/qwik/src/core/internal.ts b/packages/qwik/src/core/internal.ts index 0734cf396ad..6ad09b4fde9 100644 --- a/packages/qwik/src/core/internal.ts +++ b/packages/qwik/src/core/internal.ts @@ -1,5 +1,7 @@ export { _noopQrl, _noopQrlDEV, _regSymbol } from './shared/qrl/qrl'; export type { QRLInternal as _QRLInternal } from './shared/qrl/qrl-class'; +export { createQRL as _createQRL } from './shared/qrl/qrl-class'; +export { qrlToString as _qrlToString } from './shared/serdes/qrl-to-string'; // ^ keep this above to avoid circular dependency issues export { @@ -41,7 +43,7 @@ export { isStringifiable as _isStringifiable, type Stringifiable as _Stringifiable, } from './shared-types'; -export { _chk, _val } from './shared/jsx/bind-handlers'; +export { _chk, _res, _val } from './shared/jsx/bind-handlers'; export { _jsxC, _jsxQ, _jsxS, _jsxSorted, _jsxSplit } from './shared/jsx/jsx-internal'; export { isJSXNode as _isJSXNode } from './shared/jsx/jsx-node'; export { _getConstProps, _getVarProps } from './shared/jsx/props-proxy'; diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 3958b9bfc61..3c40f4b2e44 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -213,6 +213,9 @@ export const createComputedQrl: (qrl: QRL<() => T>, options?: ComputedOptions // @public export const createContextId: (name: string) => ContextId; +// @internal +export const _createQRL: (chunk: string | null, symbol: string, symbolRef?: null | ValueOrPromise, symbolFn?: null | (() => Promise>), captures?: Readonly | string | null) => _QRLInternal; + // Warning: (ae-forgotten-export) The symbol "SerializerArg" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "SerializerSignal" needs to be exported by the entry point index.d.ts // @@ -717,6 +720,14 @@ export type _QRLInternal = QRL & QRLInternalMethods; // @internal export const _qrlSync: (fn: TYPE, serializedFn?: string) => SyncQRL; +// Warning: (ae-forgotten-export) The symbol "SyncQRLInternal" needs to be exported by the entry point index.d.ts +// +// @internal (undocumented) +export function _qrlToString(serializationContext: SerializationContext, qrl: _QRLInternal | SyncQRLInternal): string; + +// @internal (undocumented) +export function _qrlToString(serializationContext: SerializationContext, qrl: _QRLInternal | SyncQRLInternal, raw: true): [string, string, string | null]; + // @public @deprecated (undocumented) export type QwikAnimationEvent = NativeAnimationEvent; @@ -886,6 +897,9 @@ export interface RenderSSROptions { stream: StreamWriter; } +// @internal +export function _res(this: string | undefined, _: any, element: Element): void; + // @internal (undocumented) export const _resolveContextWithoutSequentialScope: (context: ContextId) => STATE | undefined; @@ -1740,12 +1754,12 @@ export const untrack: (expr: ((...args: A) => T) | Signal export const unwrapStore: (value: T) => T; // @public -export const useAsync$: (qrl: AsyncFn, options?: ComputedOptions | undefined) => AsyncSignal; +export const useAsync$: (qrl: AsyncFn, options?: AsyncSignalOptions | undefined) => AsyncSignal; // Warning: (ae-internal-missing-underscore) The name "useAsyncQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const useAsyncQrl: (qrl: QRL>, options?: ComputedOptions) => AsyncSignal; +export const useAsyncQrl: (qrl: QRL>, options?: AsyncSignalOptions) => AsyncSignal; // @public export const useComputed$: (qrl: ComputedFn, options?: ComputedOptions | undefined) => ComputedReturnType; diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts index 652479bd9d9..fcf1d2bd12b 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts @@ -1,5 +1,6 @@ import { isBrowser } from '@qwik.dev/core/build'; import { qwikDebugToString } from '../../debug'; +import { isServerPlatform } from '../../shared/platform/platform'; import type { NoSerialize } from '../../shared/serdes/verify'; import type { Container } from '../../shared/types'; import { isPromise, retryOnPromise } from '../../shared/utils/promises'; @@ -38,7 +39,9 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple $loadingEffects$: undefined | Set = undefined; $errorEffects$: undefined | Set = undefined; $destroy$: NoSerialize<() => void> | null; + /** The awaited result or error of the computation */ $promiseValue$: T | typeof NEEDS_COMPUTATION = NEEDS_COMPUTATION; + /** The currently running computation */ private $promise$: ValueOrPromise | null = null; $pollMs$: number = 0; $pollTimeoutId$: ReturnType | undefined = undefined; @@ -104,10 +107,7 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple } set pollMs(value: number) { - if (this.$pollTimeoutId$ !== undefined) { - clearTimeout(this.$pollTimeoutId$); - this.$pollTimeoutId$ = undefined; - } + this.$clearNextPoll$(); this.$pollMs$ = value; if (this.$pollMs$ > 0 && this.$effects$?.size) { this.$scheduleNextPoll$(); @@ -115,11 +115,6 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple } override invalidate() { - // clear the poll timeout before invalidating - if (this.$pollTimeoutId$ !== undefined) { - clearTimeout(this.$pollTimeoutId$); - this.$pollTimeoutId$ = undefined; - } // clear the promise, we need to get function again this.$promise$ = null; super.invalidate(); @@ -196,50 +191,17 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple } } + // TODO it would be nice to wait for all computations in the container, for tests private async $promiseComputation$(): Promise { if (!this.$promise$) { + this.$clearNextPoll$(); + const [cleanup] = cleanupFn(this, (err) => this.$container$?.handleError(err, null!)); + this.$promise$ = this.$computeQrl$.getFn()({ track: trackFn(this, this.$container$), cleanup, }) as ValueOrPromise; - - // TODO implement these - // const arg: { - // track: Tracker; - // cleanup: ReturnType[0]; - // poll: (ms: number) => void; - // } = { - // poll: (ms: number) => { - // setTimeout(() => { - // super.invalidate(); - // if (this.$effects$?.size) { - // this.$computeIfNeeded$(); - // } - // }, ms); - // }, - // } as any; - // Object.defineProperty(arg, 'track', { - // get() { - // const fn = trackFn(this, this.$container$); - // arg.track = fn; - // return fn; - // }, - // configurable: true, - // enumerable: true, - // writable: true, - // }); - // Object.defineProperty(arg, 'cleanup', { - // get() { - // const [fn] = cleanupFn(this, (err) => this.$container$?.handleError(err, null!)); - // arg.cleanup = fn; - // return fn; - // }, - // configurable: true, - // enumerable: true, - // writable: true, - // }); - // this.$promise$ = this.$computeQrl$.getFn()(arg) as ValueOrPromise; } return this.$promise$; } @@ -254,8 +216,18 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple return didChange; } + private $clearNextPoll$() { + if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); + this.$pollTimeoutId$ = undefined; + } + } private $scheduleNextPoll$() { - if ((isBrowser || import.meta.env.TEST) && this.$pollMs$ > 0 && this.$effects$?.size) { + if ( + (isBrowser || (import.meta.env.TEST && !isServerPlatform())) && + this.$pollMs$ > 0 && + this.$effects$?.size + ) { if (this.$pollTimeoutId$ !== undefined) { clearTimeout(this.$pollTimeoutId$); } diff --git a/packages/qwik/src/core/shared/jsx/bind-handlers.ts b/packages/qwik/src/core/shared/jsx/bind-handlers.ts index ed273e3b0c5..54a96dab326 100644 --- a/packages/qwik/src/core/shared/jsx/bind-handlers.ts +++ b/packages/qwik/src/core/shared/jsx/bind-handlers.ts @@ -35,3 +35,14 @@ export function _chk(this: string | undefined, _: any, element: HTMLInputElement const signal = _captures![0] as Signal; signal.value = element.checked; } + +/** + * Resumes selected state (e.g. polling AsyncSignals) by deserializing captures. Used for + * document:onQIdle to resume async signals with active polling. + * + * @internal + */ +export function _res(this: string | undefined, _: any, element: Element) { + maybeScopeFromQL(this, element); + // Captures are deserialized, signals are now resumed +} diff --git a/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts b/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts new file mode 100644 index 00000000000..e9aea7de828 --- /dev/null +++ b/packages/qwik/src/core/shared/jsx/bind-handlers.unit.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { _res, _chk, _val } from './bind-handlers'; +import { createSignal } from '../../reactive-primitives/signal.public'; +import { createDocument } from '@qwik.dev/core/testing'; +import { QContainerAttr } from '../../shared/utils/markers'; +import { setCaptures } from '../qrl/qrl-class'; + +describe('bind handlers', () => { + describe('_res', () => { + it('should handle being called with capture string without errors', () => { + const document = createDocument(); + document.body.setAttribute(QContainerAttr, 'paused'); + const element = document.createElement('div'); + document.body.appendChild(element); + + // Simulate capture string format: "0 1" (root IDs) + const captureString = '0 1'; + + // Call _res as qwikloader would - should not throw + expect(() => _res.call(captureString, null, element)).not.toThrow(); + }); + + it('should handle being called without capture string (as QRL)', () => { + const document = createDocument(); + document.body.setAttribute(QContainerAttr, 'paused'); + const element = document.createElement('div'); + document.body.appendChild(element); + + // Call _res without capture string (undefined this) + expect(() => _res.call(undefined, null, element)).not.toThrow(); + }); + + it('should be a true no-op (no side effects)', () => { + const document = createDocument(); + document.body.setAttribute(QContainerAttr, 'paused'); + const element = document.createElement('div'); + document.body.appendChild(element); + + const captureString = '0'; + + // Call _res - it should do nothing visible + const result = _res.call(captureString, null, element); + + // Returns undefined (no-op) + expect(result).toBeUndefined(); + }); + }); + + describe('_chk', () => { + it('should update signal with checkbox checked state', () => { + const document = createDocument(); + document.body.setAttribute(QContainerAttr, 'paused'); + const element = document.createElement('input') as HTMLInputElement; + element.type = 'checkbox'; + element.checked = true; + document.body.appendChild(element); + + const signal = createSignal(false); + + // Manually set up captures for the test + setCaptures([signal]); + + const captureString = undefined; // Captures already set + + _chk.call(captureString, null, element); + + expect(signal.value).toBe(true); + }); + }); + + describe('_val', () => { + it('should update signal with input value', () => { + const document = createDocument(); + document.body.setAttribute(QContainerAttr, 'paused'); + const element = document.createElement('input') as HTMLInputElement; + element.value = 'test value'; + document.body.appendChild(element); + + const signal = createSignal(''); + + // Manually set up captures for the test + setCaptures([signal]); + + const captureString = undefined; // Captures already set + + _val.call(captureString, null, element); + + expect(signal.value).toBe('test value'); + }); + + it('should update signal with number input value', () => { + const document = createDocument(); + document.body.setAttribute(QContainerAttr, 'paused'); + const element = document.createElement('input') as HTMLInputElement; + element.type = 'number'; + element.valueAsNumber = 42; + document.body.appendChild(element); + + const signal = createSignal(0); + + // Manually set up captures for the test + setCaptures([signal]); + + const captureString = undefined; // Captures already set + + _val.call(captureString, null, element); + + expect(signal.value).toBe(42); + }); + }); +}); diff --git a/packages/qwik/src/core/shared/serdes/qrl-to-string.ts b/packages/qwik/src/core/shared/serdes/qrl-to-string.ts index 076f237a780..1932d4f4de0 100644 --- a/packages/qwik/src/core/shared/serdes/qrl-to-string.ts +++ b/packages/qwik/src/core/shared/serdes/qrl-to-string.ts @@ -6,10 +6,12 @@ import { createQRL, type QRLInternal, type SyncQRLInternal } from '../qrl/qrl-cl import { isSyncQrl } from '../qrl/qrl-utils'; import { assertDefined } from '../error/assert'; +/** @internal */ export function qrlToString( serializationContext: SerializationContext, qrl: QRLInternal | SyncQRLInternal ): string; +/** @internal */ export function qrlToString( serializationContext: SerializationContext, qrl: QRLInternal | SyncQRLInternal, diff --git a/packages/qwik/src/core/shared/serdes/serialization-context.ts b/packages/qwik/src/core/shared/serdes/serialization-context.ts index 6ca72a4dd9b..4304809b6b9 100644 --- a/packages/qwik/src/core/shared/serdes/serialization-context.ts +++ b/packages/qwik/src/core/shared/serdes/serialization-context.ts @@ -90,6 +90,7 @@ export interface SerializationContext { $resources$: Set>; $renderSymbols$: Set; $storeProxyMap$: ObjToProxyMap; + $eagerResume$: Set; $getProp$: (obj: any, prop: string) => any; $setProp$: (obj: any, prop: string, value: any) => void; @@ -126,6 +127,7 @@ export const createSerializationContext = ( const syncFnMap = new Map(); const syncFns: string[] = []; const roots: unknown[] = []; + const eagerResume = new Set(); const getSeenRef = (obj: unknown) => seenObjsMap.get(obj); const $markSeen$ = (obj: unknown, parent: SeenRef | undefined, index: number) => { @@ -236,6 +238,7 @@ export const createSerializationContext = ( $resources$: new Set>(), $renderSymbols$: new Set(), $storeProxyMap$: storeProxyMap, + $eagerResume$: eagerResume, $getProp$: getProp, $setProp$: setProp, }; diff --git a/packages/qwik/src/core/ssr/ssr-render-jsx.ts b/packages/qwik/src/core/ssr/ssr-render-jsx.ts index 69a1251cb4d..35becea16f5 100644 --- a/packages/qwik/src/core/ssr/ssr-render-jsx.ts +++ b/packages/qwik/src/core/ssr/ssr-render-jsx.ts @@ -1,5 +1,6 @@ import { isDev } from '@qwik.dev/core/build'; import { _run } from '../client/run-qrl'; +import { AsyncSignalImpl } from '../reactive-primitives/impl/async-signal-impl'; import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; import { EffectProperty } from '../reactive-primitives/types'; import { isSignal } from '../reactive-primitives/utils'; @@ -130,6 +131,7 @@ function processJSXNode( enqueue(value[i]); } } else if (isSignal(value)) { + maybeAddPollingAsyncSignalToEagerResume(ssr.serializationCtx, value); ssr.openFragment(isDev ? [DEBUG_TYPE, VirtualType.WrappedSignal] : EMPTY_ARRAY); const signalNode = ssr.getOrCreateLastNode(); const unwrappedSignal = value instanceof WrappedSignalImpl ? value.$unwrapIfSignal$() : value; @@ -347,6 +349,7 @@ export function toSsrAttrs( } if (isSignal(value)) { + maybeAddPollingAsyncSignalToEagerResume(options.serializationCtx, value); // write signal as is. We will track this signal inside `writeAttrs` if (isClassAttr(key)) { // additionally append styleScopedId for class attr @@ -452,6 +455,24 @@ function addPreventDefaultEventToSerializationContext( } } +function maybeAddPollingAsyncSignalToEagerResume( + serializationCtx: SerializationContext, + signal: unknown +) { + // Unwrap if it's a WrappedSignalImpl + const unwrappedSignal = signal instanceof WrappedSignalImpl ? signal.$unwrapIfSignal$() : signal; + + if (unwrappedSignal instanceof AsyncSignalImpl) { + const pollMs = unwrappedSignal.$pollMs$; + // Don't check for $effects$ here - effects are added later during tracking. + // The AsyncSignal's polling mechanism will check for effects before scheduling. + if (pollMs > 0) { + serializationCtx.$addRoot$(unwrappedSignal); + serializationCtx.$eagerResume$.add(unwrappedSignal); + } + } +} + function getSlotName(host: ISsrNode, jsx: JSXNodeInternal, ssr: SSRContainer): string { const constProps = jsx.constProps; if (constProps && typeof constProps == 'object' && 'name' in constProps) { diff --git a/packages/qwik/src/core/tests/use-async.spec.tsx b/packages/qwik/src/core/tests/use-async.spec.tsx index 7730fc3f789..585241cd44e 100644 --- a/packages/qwik/src/core/tests/use-async.spec.tsx +++ b/packages/qwik/src/core/tests/use-async.spec.tsx @@ -4,12 +4,13 @@ import { _jsxSorted, _wrapProp, component$, + useAsync$, + useConstant, useSignal, useTask$, } from '@qwik.dev/core'; import { domRender, ssrRenderToDom, trigger, waitForDrain } from '@qwik.dev/core/testing'; import { describe, expect, it } from 'vitest'; -import { useAsync$ } from '../use/use-async'; import { delay } from '../shared/utils/promises'; const debug = false; //true; @@ -390,5 +391,42 @@ describe.each([ await trigger(container.element, 'button', 'click'); expect((globalThis as any).log).toEqual(['cleanup', 'cleanup']); }); + + it('should resume polling AsyncSignal with d:qidle on SSR', async () => { + // This test verifies that polling AsyncSignals are tracked during serialization + // and a d:qidle event is added to resume polling on document idle + const Counter = component$(() => { + const start = useConstant(Date.now); + const elapsed = useAsync$(async () => Date.now() - start, { pollMs: 50 }); + return ( +
+
{elapsed.value}
+ +
+ ); + }); + + const { container } = await render(, { debug }); + + if (render === ssrRenderToDom) { + await trigger(container.element, null, 'd:qidle'); + } + const elapsedBefore = Number(container.element.querySelector('#elapsed')!.textContent); + await delay(100); + const elapsedAfter = Number(container.element.querySelector('#elapsed')!.textContent); + expect(elapsedAfter).toBeGreaterThan(elapsedBefore); + + await trigger(container.element, 'button', 'click'); // disable polling + const elapsedWhenStopped = Number(container.element.querySelector('#elapsed')!.textContent); + await delay(100); + const elapsedAfterStop = Number(container.element.querySelector('#elapsed')!.textContent); + expect(elapsedAfterStop).toEqual(elapsedWhenStopped); + }); }); }); diff --git a/packages/qwik/src/core/use/use-async.ts b/packages/qwik/src/core/use/use-async.ts index 29e56163ffe..47a5b14c4d2 100644 --- a/packages/qwik/src/core/use/use-async.ts +++ b/packages/qwik/src/core/use/use-async.ts @@ -1,6 +1,6 @@ import { createAsyncSignal } from '../reactive-primitives/signal-api'; import { type AsyncSignal } from '../reactive-primitives/signal.public'; -import type { AsyncCtx, ComputedOptions } from '../reactive-primitives/types'; +import type { AsyncCtx, AsyncSignalOptions } from '../reactive-primitives/types'; import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; import type { QRL } from '../shared/qrl/qrl.public'; import { useConstant } from './use-signal'; @@ -8,13 +8,16 @@ import { useConstant } from './use-signal'; /** @public */ export type AsyncFn = (ctx: AsyncCtx) => Promise; -const creator = (qrl: QRL>, options?: ComputedOptions) => { +const creator = (qrl: QRL>, options?: AsyncSignalOptions) => { qrl.resolve(); return createAsyncSignal(qrl, options); }; /** @internal */ -export const useAsyncQrl = (qrl: QRL>, options?: ComputedOptions): AsyncSignal => { +export const useAsyncQrl = ( + qrl: QRL>, + options?: AsyncSignalOptions +): AsyncSignal => { return useConstant(creator, qrl, options); }; diff --git a/packages/qwik/src/optimizer/src/manifest.ts b/packages/qwik/src/optimizer/src/manifest.ts index b20cd23da46..15cf93fdb2e 100644 --- a/packages/qwik/src/optimizer/src/manifest.ts +++ b/packages/qwik/src/optimizer/src/manifest.ts @@ -4,7 +4,7 @@ import type { GlobalInjections, Path, QwikBundle, QwikManifest, SegmentAnalysis // The handlers that are exported by the core package // See handlers.mjs -const extraSymbols = new Set(['_chk', '_run', '_task', '_val']); +const extraSymbols = new Set(['_chk', '_res', '_run', '_task', '_val']); // This is just the initial prioritization of the symbols and entries // at build time so there's less work during each SSR. However, SSR should diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index 6f52cae8146..11ba508d895 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -1,6 +1,9 @@ /** @file Public APIs for the SSR */ import { isDev } from '@qwik.dev/core/build'; import { + _createQRL as createQRL, + _qrlToString as qrlToString, + _res, _SubscriptionData as SubscriptionData, _SharedContainer, _jsxSorted, @@ -898,7 +901,18 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { if (!this.serializationCtx.$roots$.length) { return; } - this.openElement('script', null, ['type', 'qwik/state']); + const attrs: string[] = ['type', 'qwik/state']; + + // Add q-d:qidle attribute if there are signals to eagerly resume + if (this.serializationCtx.$eagerResume$.size > 0) { + const qrl = createQRL(null, '_res', _res, null, [...this.serializationCtx.$eagerResume$]); + const qrlStr = qrlToString(this.serializationCtx, qrl); + attrs.push('q-d:qidle', qrlStr); + // Add 'd:qidle' to event names set + this.serializationCtx.$eventNames$.add('d:qidle'); + } + + this.openElement('script', null, attrs); return maybeThen(this.serializationCtx.$serialize$(), () => { this.closeElement(); }); From c5c4102d338b53739aaa8eddb77a74277362f7ef Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 5 Feb 2026 16:35:05 +0100 Subject: [PATCH 04/10] changeset --- .changeset/rich-peas-invite.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/rich-peas-invite.md diff --git a/.changeset/rich-peas-invite.md b/.changeset/rich-peas-invite.md new file mode 100644 index 00000000000..77e2c0e122f --- /dev/null +++ b/.changeset/rich-peas-invite.md @@ -0,0 +1,6 @@ +--- +'@qwik.dev/core': minor +--- + +FEAT: `useAsync$()` now has `pollMs`, which re-runs the compute function on intervals. You can change signal.pollMs to enable/disable it, and if you set it during SSR it will automatically resume to do the polling. +This way, you can auto-update data on the client without needing to set up timers or events. For example, you can show a "time ago" string that updates every minute, or you can poll an API for updates, and change the poll interval when the window goes idle. From 123e81c2649f22c8e5b179fbcbedb0d23d9092bc Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 5 Feb 2026 18:21:09 +0100 Subject: [PATCH 05/10] feat(useAsync$): add `initial` option --- .../impl/async-signal-impl.ts | 14 ++- .../reactive-primitives/impl/signal.unit.tsx | 104 ++++++++++++++++++ .../core/reactive-primitives/signal-api.ts | 2 +- .../src/core/reactive-primitives/types.ts | 6 +- .../qwik/src/core/shared/serdes/allocate.ts | 2 +- 5 files changed, 120 insertions(+), 8 deletions(-) diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts index fcf1d2bd12b..014c4dc63ad 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts @@ -15,6 +15,7 @@ import { NEEDS_COMPUTATION, SerializationSignalFlags, SignalFlags, + type AsyncSignalOptions, } from '../types'; import { scheduleEffects } from '../utils'; import { ComputedSignalImpl } from './computed-signal-impl'; @@ -52,9 +53,20 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple container: Container | null, fn: AsyncQRL, flags: SignalFlags | SerializationSignalFlags = SignalFlags.INVALID, - pollMs: number = 0 + options?: AsyncSignalOptions ) { super(container, fn, flags); + const pollMs = options?.pollMs || 0; + const initial = options?.initial; + + // Handle initial value - eagerly evaluate if function, set $untrackedValue$ and $promiseValue$ + // Do NOT call setValue() which would clear the INVALID flag and prevent async computation + if (initial !== undefined) { + const initialValue = typeof initial === 'function' ? (initial as () => T)() : initial; + this.$untrackedValue$ = initialValue; + this.$promiseValue$ = initialValue; + } + this.pollMs = pollMs; } diff --git a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx index 75977cba818..0c753fd4c88 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx +++ b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx @@ -39,6 +39,12 @@ class Foo { } } +let computeInitialCalls = 0; +const computeInitialFn = async () => { + computeInitialCalls++; + return 42; +}; + describe('signal types', () => { it('Signal', () => () => { const signal = createSignal(1); @@ -349,6 +355,104 @@ describe('signal', () => { expect(signal.$pollMs$).toBe(pollMs); }); }); + + it('should return initial value on first read', async () => { + await withContainer(async () => { + const signal = createAsync$(async () => 42, { + initial: 10, + }) as AsyncSignalImpl; + + // First read should return initial value without throwing + expect(signal.value).toBe(10); + // Promise value should be set to initial + expect((signal as any).$promiseValue$).toBe(10); + }); + }); + + it('should invoke compute on first read without promise()', async () => { + await withContainer(async () => { + computeInitialCalls = 0; + const signal = createAsync$(computeInitialFn, { + initial: 10, + }) as AsyncSignalImpl; + + // First read should return initial value + expect(signal.value).toBe(10); + // Compute function should have been called to start computation + expect(computeInitialCalls).toBe(1); + await retryOnPromise(() => { + if (signal.value !== 42) { + throw new Promise((resolve) => setTimeout(resolve, 0)); + } + return signal.value; + }); + expect(signal.value).toBe(42); + }); + }); + + it('should eagerly evaluate initial function on construction', async () => { + await withContainer(async () => { + let initCalls = 0; + const signal = createAsync$(async () => 42, { + initial: () => { + initCalls++; + return 20; + }, + }) as AsyncSignalImpl; + + // Initial function should be called immediately during construction + expect(initCalls).toBe(1); + // First read should return initial value + expect(signal.value).toBe(20); + }); + }); + + it('should propagate initial function errors immediately', async () => { + await withContainer(async () => { + const error = new Error('initial failed'); + expect(() => { + createAsync$(async () => 42, { + initial: () => { + throw error; + }, + }); + }).toThrow(error); + }); + }); + + it('initial and pollMs should work together', async () => { + await withContainer(async () => { + const pollMs = 1; + const signal = createAsync$(async () => 42, { + initial: 10, + pollMs, + }) as AsyncSignalImpl; + + // Should have initial value + expect(signal.value).toBe(10); + // Should have poll interval stored + expect(signal.pollMs).toBe(pollMs); + expect(signal.$pollMs$).toBe(pollMs); + }); + }); + + it('initial value should be replaced by computed promise', async () => { + await withContainer(async () => { + const signal = createAsync$(async () => 42, { + initial: 10, + }) as AsyncSignalImpl; + + // Start with initial value + expect(signal.value).toBe(10); + + // Wait for the async promise to resolve + const resolvedValue = await signal.promise(); + + // After promise resolves, should have computed value + expect(resolvedValue).toBe(42); + expect(signal.value).toBe(42); + }); + }); }); }); //////////////////////////////////////// diff --git a/packages/qwik/src/core/reactive-primitives/signal-api.ts b/packages/qwik/src/core/reactive-primitives/signal-api.ts index f03b972eb41..2b4d97ad35f 100644 --- a/packages/qwik/src/core/reactive-primitives/signal-api.ts +++ b/packages/qwik/src/core/reactive-primitives/signal-api.ts @@ -41,7 +41,7 @@ export const createAsyncSignal = ( options?.container || null, qrl as AsyncQRL, getComputedSignalFlags(options?.serializationStrategy || 'never'), - options?.pollMs || 0 + options ); }; diff --git a/packages/qwik/src/core/reactive-primitives/types.ts b/packages/qwik/src/core/reactive-primitives/types.ts index bedfb5f7ca9..a0921cf6177 100644 --- a/packages/qwik/src/core/reactive-primitives/types.ts +++ b/packages/qwik/src/core/reactive-primitives/types.ts @@ -48,11 +48,7 @@ export interface ComputedOptions { /** @public */ export interface AsyncSignalOptions extends ComputedOptions { - /** - * Like useSignal's `initial`; prevents the throw on first read when uninitialized - * - * @deprecated Not implemented yet - */ + /** Like useSignal's `initial`; prevents the throw on first read when uninitialized */ initial?: T | (() => T); /** * When subscribers drop to 0, run cleanup in the next tick, instead of waiting for the function diff --git a/packages/qwik/src/core/shared/serdes/allocate.ts b/packages/qwik/src/core/shared/serdes/allocate.ts index d7aef32e2da..41ab89cbb9b 100644 --- a/packages/qwik/src/core/shared/serdes/allocate.ts +++ b/packages/qwik/src/core/shared/serdes/allocate.ts @@ -99,7 +99,7 @@ export const allocate = (container: DeserializeContainer, typeId: number, value: case TypeIds.ComputedSignal: return new ComputedSignalImpl(container as any, null!); case TypeIds.AsyncSignal: - return new AsyncSignalImpl(container as any, null!, undefined, 0); + return new AsyncSignalImpl(container as any, null!, undefined, { pollMs: 0 }); case TypeIds.SerializerSignal: return new SerializerSignalImpl(container as any, null!); case TypeIds.Store: { From 1150670a5eb406d9cc7534811bc10a26402d2715 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Fri, 6 Feb 2026 10:13:02 +0100 Subject: [PATCH 06/10] perf(signal): small memory improvement --- .../impl/async-signal-impl.ts | 12 +---- .../reactive-primitives/impl/signal-impl.ts | 54 +++++++++---------- packages/qwik/src/core/use/utils/tracker.ts | 5 ++ 3 files changed, 32 insertions(+), 39 deletions(-) diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts index 014c4dc63ad..608e4627a1a 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts @@ -75,11 +75,7 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple * has resolved or rejected. */ get loading(): boolean { - return setupSignalValueAccess( - this, - () => (this.$loadingEffects$ ||= new Set()), - () => this.untrackedLoading - ); + return setupSignalValueAccess(this, '$loadingEffects$', 'untrackedLoading'); } set untrackedLoading(value: boolean) { @@ -96,11 +92,7 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple /** The error that occurred when the signal was resolved. */ get error(): Error | undefined { - return setupSignalValueAccess( - this, - () => (this.$errorEffects$ ||= new Set()), - () => this.untrackedError - ); + return setupSignalValueAccess(this, '$errorEffects$', 'untrackedError'); } set untrackedError(value: Error | undefined) { 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 3e8be2ab952..acce4eb6165 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts @@ -119,40 +119,36 @@ export class SignalImpl implements Signal { } } -export const setupSignalValueAccess = ( - target: SignalImpl, - effectsFn: () => Set, - returnValueFn: () => S -) => { +export const setupSignalValueAccess = , Prop extends keyof Sig>( + target: Sig, + effectsProp: keyof Sig, + valueProp: Prop +): Sig[Prop] => { const ctx = tryGetInvokeContext(); - if (!ctx) { - return returnValueFn(); - } - if (target.$container$ === null) { - if (!ctx.$container$) { - return returnValueFn(); - } - // Grab the container now we have access to it - target.$container$ = ctx.$container$; - } else { + // We need a container for this + // Grab the container if we have access to it + if (ctx && (target.$container$ ||= ctx.$container$ || null)) { isDev && assertTrue( !ctx.$container$ || ctx.$container$ === target.$container$, 'Do not use signals across containers' ); + const effectSubscriber = ctx.$effectSubscriber$; + if (effectSubscriber) { + // 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( + ((target[effectsProp] as Set) ||= new Set()), + 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, target); + addQrlToSerializationCtx(effectSubscriber, target.$container$); + DEBUG && log('read->sub', pad('\n' + target.toString(), ' ')); + } } - const effectSubscriber = ctx.$effectSubscriber$; - if (effectSubscriber) { - // 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(effectsFn(), 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, target); - addQrlToSerializationCtx(effectSubscriber, target.$container$); - DEBUG && log('read->sub', pad('\n' + target.toString(), ' ')); - } - return returnValueFn(); + return target[valueProp]; }; diff --git a/packages/qwik/src/core/use/utils/tracker.ts b/packages/qwik/src/core/use/utils/tracker.ts index 2812f8bd57d..979084e48ca 100644 --- a/packages/qwik/src/core/use/utils/tracker.ts +++ b/packages/qwik/src/core/use/utils/tracker.ts @@ -46,6 +46,10 @@ export const trackFn = }); }; +/** + * This adds $destroy$ to the target if a cleanup function is registered. It must be called before + * running any computations again. + */ export const cleanupFn = ( target: T, handleError: (err: unknown) => void @@ -57,6 +61,7 @@ export const cleanupFn = ( cleanupFns = []; target.$destroy$ = noSerialize(() => { target.$destroy$ = null; + // TODO handle promises for (const fn of cleanupFns!) { try { fn(); From 61fd0b4f58f9363406f9b4be10e1fc0dc3b7df83 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Fri, 6 Feb 2026 10:14:20 +0100 Subject: [PATCH 07/10] fix(core): don't crash on missing container --- packages/qwik/src/core/client/vnode-utils.ts | 16 +++++++++------- .../qwik/src/core/reactive-primitives/utils.ts | 4 ++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/qwik/src/core/client/vnode-utils.ts b/packages/qwik/src/core/client/vnode-utils.ts index d0d704abd1f..ecef0828ae6 100644 --- a/packages/qwik/src/core/client/vnode-utils.ts +++ b/packages/qwik/src/core/client/vnode-utils.ts @@ -1763,12 +1763,14 @@ export function vnode_toString( if (vnode.dirty) { attrs.push(` dirty:${vnode.dirty}`); } - vnode_getAttrKeys(container!, vnode).forEach((key) => { - if (key !== DEBUG_TYPE && key !== debugStyleScopeIdPrefixAttr) { - const value = vnode_getProp(vnode!, key, null); - attrs.push(' ' + key + '=' + qwikDebugToString(value)); - } - }); + if (container) { + vnode_getAttrKeys(container, vnode).forEach((key) => { + if (key !== DEBUG_TYPE && key !== debugStyleScopeIdPrefixAttr) { + const value = vnode_getProp(vnode!, key, null); + attrs.push(' ' + key + '=' + qwikDebugToString(value)); + } + }); + } const name = (colorize ? NAME_COL_PREFIX : '') + (VirtualTypeName[vnode_getProp(vnode, DEBUG_TYPE, null) || VirtualType.Virtual] || @@ -1793,7 +1795,7 @@ export function vnode_toString( if (vnode.dirtyChildren) { attrs.push(` dirtyChildren[${vnode.dirtyChildren.length}]`); } - const keys = vnode_getAttrKeys(container!, vnode); + const keys = container ? vnode_getAttrKeys(container, vnode) : []; for (const key of keys) { const value = vnode_getProp(vnode!, key, null); attrs.push(' ' + key + '=' + qwikDebugToString(value)); diff --git a/packages/qwik/src/core/reactive-primitives/utils.ts b/packages/qwik/src/core/reactive-primitives/utils.ts index 9396cae0b60..003ae048ae5 100644 --- a/packages/qwik/src/core/reactive-primitives/utils.ts +++ b/packages/qwik/src/core/reactive-primitives/utils.ts @@ -66,7 +66,7 @@ export const addQrlToSerializationCtx = ( effectSubscriber: EffectSubscription, container: Container | null ) => { - if (container) { + if ((container as SSRContainer | null)?.serializationCtx) { const effect = effectSubscriber.consumer; const property = effectSubscriber.property; let qrl: QRL | null = null; @@ -75,7 +75,7 @@ export const addQrlToSerializationCtx = ( } else if (effect instanceof ComputedSignalImpl) { qrl = effect.$computeQrl$; } else if (property === EffectProperty.COMPONENT) { - qrl = container.getHostProp(effect as VNode, OnRenderProp); + qrl = container!.getHostProp(effect as VNode, OnRenderProp); } if (qrl) { (container as SSRContainer).serializationCtx.$eventQrls$.add(qrl); From 110aa9f92c2896771ec2a6a5a9618b88e410ffdd Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Fri, 6 Feb 2026 15:11:29 +0100 Subject: [PATCH 08/10] refactor(container): single pending count --- packages/qwik/src/core/shared/cursor/cursor-queue.ts | 11 +++++------ .../qwik/src/core/shared/cursor/cursor-walker.ts | 12 ++---------- packages/qwik/src/core/shared/shared-container.ts | 10 ++++++++-- packages/qwik/src/core/shared/types.ts | 4 ++-- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/qwik/src/core/shared/cursor/cursor-queue.ts b/packages/qwik/src/core/shared/cursor/cursor-queue.ts index 869ca3d006b..77c29f8ac42 100644 --- a/packages/qwik/src/core/shared/cursor/cursor-queue.ts +++ b/packages/qwik/src/core/shared/cursor/cursor-queue.ts @@ -16,8 +16,7 @@ const globalCursorQueue: Cursor[] = []; const pausedCursorQueue: Cursor[] = []; /** - * Adds a cursor to the global queue. If the cursor already exists, it's removed and re-added to - * maintain correct priority order. + * Adds a cursor to the global queue. * * @param cursor - The cursor to add */ @@ -35,7 +34,7 @@ export function addCursorToQueue(container: Container, cursor: Cursor): void { globalCursorQueue.splice(insertIndex, 0, cursor); - container.$cursorCount$++; + container.$pendingCount$++; container.$renderPromise$ ||= new Promise((r) => (container.$resolveRenderPromise$ = r)); } @@ -68,7 +67,7 @@ export function getHighestPriorityCursor(): Cursor | null { export function pauseCursor(cursor: Cursor, container: Container): void { pausedCursorQueue.push(cursor); removeCursorFromQueue(cursor, container, true); - container.$pausedCursorCount$++; + container.$pendingCount$++; } export function resumeCursor(cursor: Cursor, container: Container): void { @@ -79,7 +78,7 @@ export function resumeCursor(cursor: Cursor, container: Container): void { pausedCursorQueue[index] = pausedCursorQueue[lastIndex]; } pausedCursorQueue.pop(); - container.$pausedCursorCount$--; + container.$pendingCount$--; } addCursorToQueue(container, cursor); } @@ -107,6 +106,6 @@ export function removeCursorFromQueue( // } // globalCursorQueue.pop(); globalCursorQueue.splice(index, 1); - container.$cursorCount$--; + container.$pendingCount$--; } } diff --git a/packages/qwik/src/core/shared/cursor/cursor-walker.ts b/packages/qwik/src/core/shared/cursor/cursor-walker.ts index 1b14ddaad7d..8660c0084ae 100644 --- a/packages/qwik/src/core/shared/cursor/cursor-walker.ts +++ b/packages/qwik/src/core/shared/cursor/cursor-walker.ts @@ -247,16 +247,8 @@ function finishWalk( } export function resolveCursor(container: Container): void { - DEBUG && - console.warn( - `walkCursor: cursor resolved, ${container.$cursorCount$} remaining, ${container.$pausedCursorCount$} paused` - ); - // TODO streaming as a cursor? otherwise we need to wait separately for it - // or just ignore and resolve manually - if (container.$cursorCount$ === 0 && container.$pausedCursorCount$ === 0) { - container.$resolveRenderPromise$!(); - container.$renderPromise$ = null; - } + DEBUG && console.warn(`walkCursor: cursor resolved, ${container.$pendingCount$} remaining`); + container.$checkPendingCount$(); } /** diff --git a/packages/qwik/src/core/shared/shared-container.ts b/packages/qwik/src/core/shared/shared-container.ts index 66a0353ac2c..a2f5bb1e93c 100644 --- a/packages/qwik/src/core/shared/shared-container.ts +++ b/packages/qwik/src/core/shared/shared-container.ts @@ -24,8 +24,7 @@ export abstract class _SharedContainer implements Container { $buildBase$: string | null = null; $renderPromise$: Promise | null = null; $resolveRenderPromise$: (() => void) | null = null; - $cursorCount$: number = 0; - $pausedCursorCount$: number = 0; + $pendingCount$: number = 0; constructor(serverData: Record, locale: string) { this.$serverData$ = serverData; @@ -67,6 +66,13 @@ export abstract class _SharedContainer implements Container { ); } + $checkPendingCount$(): void { + if (this.$pendingCount$ === 0) { + this.$resolveRenderPromise$?.(); + this.$renderPromise$ = null; + } + } + abstract ensureProjectionResolved(host: HostElement): void; abstract handleError(err: any, $host$: HostElement | null): void; abstract getParentHost(host: HostElement): HostElement | null; diff --git a/packages/qwik/src/core/shared/types.ts b/packages/qwik/src/core/shared/types.ts index c7012fd0669..13200d8573a 100644 --- a/packages/qwik/src/core/shared/types.ts +++ b/packages/qwik/src/core/shared/types.ts @@ -26,8 +26,8 @@ export interface Container { $buildBase$: string | null; $renderPromise$: Promise | null; $resolveRenderPromise$: (() => void) | null; - $cursorCount$: number; - $pausedCursorCount$: number; + $pendingCount$: number; + $checkPendingCount$(): void; handleError(err: any, $host$: HostElement | null): void; getParentHost(host: HostElement): HostElement | null; From c9f54fa3b2ffbbd2b179b04a90cd73a9cff51abe Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Sat, 7 Feb 2026 01:03:40 +0100 Subject: [PATCH 09/10] refactor(useAsync$): wait for load on server - simplify logic, one less class attribute - clear invalidate on compute start so that new invalidations are noted - change `.promise()` to return `Promise` - reading `.loading` triggers computation - reading `.loading` during SSR throws the completion promise - try harder to get the container on signal init - serdes: don't send loading state, it's always false - serdes: correctly set invalid flag - serdes: default to always serialize asyncSignals (expensive compute) and never computed (cheap compute) --- .changeset/light-eels-lead.md | 6 + packages/docs/src/routes/api/qwik/api.json | 4 +- packages/docs/src/routes/api/qwik/index.mdx | 22 ++- .../src/runtime/src/error-boundary.tsx | 3 +- packages/qwik/src/core/qwik.core.api.md | 14 +- .../impl/async-signal-impl.ts | 153 ++++++++---------- .../impl/computed-signal-impl.ts | 2 +- .../reactive-primitives/impl/signal-impl.ts | 14 +- .../reactive-primitives/impl/signal.unit.tsx | 5 +- .../core/reactive-primitives/signal-api.ts | 4 +- .../core/reactive-primitives/signal.public.ts | 19 ++- .../qwik/src/core/shared/serdes/inflate.ts | 27 ++-- .../src/core/shared/serdes/serdes.unit.ts | 94 ++++++----- .../qwik/src/core/shared/serdes/serialize.ts | 14 +- .../qwik/src/core/tests/use-async.spec.tsx | 87 +++++++--- 15 files changed, 270 insertions(+), 198 deletions(-) create mode 100644 .changeset/light-eels-lead.md diff --git a/.changeset/light-eels-lead.md b/.changeset/light-eels-lead.md new file mode 100644 index 00000000000..bc4698ac906 --- /dev/null +++ b/.changeset/light-eels-lead.md @@ -0,0 +1,6 @@ +--- +'@qwik.dev/core': major +--- + +BREAKING: the `.promise()` method on `useAsync$` now returns a `Promise` instead of `Promise`, to avoid having to put `.catch()` on every call and to promote using the reactive `result.value` and `result.error` properties for handling async results and errors. +- BREAKING: the default serialization strategy for `useAsync$` is now 'always' instead of 'never', because it is likely to be expensive to get. For similar reasons, the default serialization strategy for `useComputed$` is now 'never' instead of 'always'. diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 4c9cef80490..b9ee12b2987 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 AsyncSignal extends ComputedSignal \n```\n**Extends:** [ComputedSignal](#computedsignal)<T>\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[error](#)\n\n\n\n\n\n\n\nError \\| undefined\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[pollMs](#)\n\n\n\n\n\n\n\nnumber\n\n\n\n\nPoll interval in ms. Writable and immediately effective when the signal has consumers. If set to `0`, polling stops.\n\n\n
\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[promise()](#asyncsignal-promise)\n\n\n\n\nA promise that resolves when the value is computed.\n\n\n
", + "content": "```typescript\nexport interface AsyncSignal extends ComputedSignal \n```\n**Extends:** [ComputedSignal](#computedsignal)<T>\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[error](#)\n\n\n\n\n\n\n\nError \\| undefined\n\n\n\n\nThe error that occurred while computing the signal, if any. This will be cleared when the signal is successfully computed.\n\n\n
\n\n[loading](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\nWhether the signal is currently loading. This will trigger lazy loading of the signal, so you can use it like this:\n\n```tsx\nsignal.loading ? : signal.error ? : \n```\n\n\n
\n\n[pollMs](#)\n\n\n\n\n\n\n\nnumber\n\n\n\n\nPoll interval in ms. Writable and immediately effective when the signal has consumers. If set to `0`, polling stops.\n\n\n
\n\n\n\n\n
\n\nMethod\n\n\n\n\nDescription\n\n\n
\n\n[promise()](#asyncsignal-promise)\n\n\n\n\nA promise that resolves when the value is computed or rejected.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts", "mdFile": "core.asyncsignal.md" }, @@ -1255,7 +1255,7 @@ } ], "kind": "MethodSignature", - "content": "A promise that resolves when the value is computed.\n\n\n```typescript\npromise(): Promise;\n```\n**Returns:**\n\nPromise<T>", + "content": "A promise that resolves when the value is computed or rejected.\n\n\n```typescript\npromise(): Promise;\n```\n**Returns:**\n\nPromise<void>", "mdFile": "core.asyncsignal.promise.md" }, { diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 3c609972d91..f2f56f13332 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -165,7 +165,7 @@ Error \| undefined -The error that occurred while computing the signal. +The error that occurred while computing the signal, if any. This will be cleared when the signal is successfully computed. @@ -180,7 +180,17 @@ boolean -Whether the signal is currently loading. +Whether the signal is currently loading. This will trigger lazy loading of the signal, so you can use it like this: + +```tsx +signal.loading ? ( + +) : signal.error ? ( + +) : ( + +); +``` @@ -215,7 +225,7 @@ Description -A promise that resolves when the value is computed. +A promise that resolves when the value is computed or rejected. @@ -2631,15 +2641,15 @@ opts ## promise -A promise that resolves when the value is computed. +A promise that resolves when the value is computed or rejected. ```typescript -promise(): Promise; +promise(): Promise; ``` **Returns:** -Promise<T> +Promise<void> ## PropFunction diff --git a/packages/qwik-router/src/runtime/src/error-boundary.tsx b/packages/qwik-router/src/runtime/src/error-boundary.tsx index 7ede68ec07b..36a3e053792 100644 --- a/packages/qwik-router/src/runtime/src/error-boundary.tsx +++ b/packages/qwik-router/src/runtime/src/error-boundary.tsx @@ -12,8 +12,7 @@ export const ErrorBoundary = component$((props: ErrorBoundaryProps) => { useOnWindow( 'qerror', $((e: CustomEvent) => { - // we are allowed to write to our "read-only" store - (store.error as any) = e.detail.error; + store.error = e.detail.error; }) ); diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index 3c40f4b2e44..8804da1e896 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -22,7 +22,7 @@ export interface AsyncSignal extends ComputedSignal { error: Error | undefined; loading: boolean; pollMs: number; - promise(): Promise; + promise(): Promise; } // @internal @@ -109,15 +109,15 @@ export interface _Container { // (undocumented) $buildBase$: string | null; // (undocumented) - $currentUniqueId$: number; + $checkPendingCount$(): void; // (undocumented) - $cursorCount$: number; + $currentUniqueId$: number; // (undocumented) readonly $getObjectById$: (id: number | string) => any; // (undocumented) readonly $locale$: string; // (undocumented) - $pausedCursorCount$: number; + $pendingCount$: number; // (undocumented) $renderPromise$: Promise | null; // (undocumented) @@ -990,9 +990,9 @@ export abstract class _SharedContainer implements _Container { // (undocumented) $buildBase$: string | null; // (undocumented) - $currentUniqueId$: number; + $checkPendingCount$(): void; // (undocumented) - $cursorCount$: number; + $currentUniqueId$: number; // (undocumented) readonly $getObjectById$: (id: number | string) => any; // (undocumented) @@ -1000,7 +1000,7 @@ export abstract class _SharedContainer implements _Container { // (undocumented) readonly $locale$: string; // (undocumented) - $pausedCursorCount$: number; + $pendingCount$: number; // (undocumented) $renderPromise$: Promise | null; // (undocumented) diff --git a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts index 608e4627a1a..85c722109ff 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts @@ -1,10 +1,9 @@ -import { isBrowser } from '@qwik.dev/core/build'; +import { isBrowser, isServer } from '@qwik.dev/core/build'; import { qwikDebugToString } from '../../debug'; import { isServerPlatform } from '../../shared/platform/platform'; import type { NoSerialize } from '../../shared/serdes/verify'; import type { Container } from '../../shared/types'; import { isPromise, retryOnPromise } from '../../shared/utils/promises'; -import type { ValueOrPromise } from '../../shared/utils/types'; import { cleanupDestroyable } from '../../use/utils/destroyable'; import { cleanupFn, trackFn } from '../../use/utils/tracker'; import { _EFFECT_BACK_REF, type BackRef } from '../backref'; @@ -40,10 +39,8 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple $loadingEffects$: undefined | Set = undefined; $errorEffects$: undefined | Set = undefined; $destroy$: NoSerialize<() => void> | null; - /** The awaited result or error of the computation */ - $promiseValue$: T | typeof NEEDS_COMPUTATION = NEEDS_COMPUTATION; - /** The currently running computation */ - private $promise$: ValueOrPromise | null = null; + /** A promise for the currently running computation */ + private $promise$: Promise | null = null; $pollMs$: number = 0; $pollTimeoutId$: ReturnType | undefined = undefined; @@ -52,7 +49,8 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple constructor( container: Container | null, fn: AsyncQRL, - flags: SignalFlags | SerializationSignalFlags = SignalFlags.INVALID, + flags: SignalFlags | SerializationSignalFlags = SignalFlags.INVALID | + SerializationSignalFlags.SERIALIZATION_STRATEGY_ALWAYS, options?: AsyncSignalOptions ) { super(container, fn, flags); @@ -64,7 +62,6 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple if (initial !== undefined) { const initialValue = typeof initial === 'function' ? (initial as () => T)() : initial; this.$untrackedValue$ = initialValue; - this.$promiseValue$ = initialValue; } this.pollMs = pollMs; @@ -73,6 +70,9 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple /** * Loading is true if the signal is still waiting for the promise to resolve, false if the promise * has resolved or rejected. + * + * Accessing .loading will trigger computation if needed, since it's often used like + * `signal.loading ? : signal.value`. */ get loading(): boolean { return setupSignalValueAccess(this, '$loadingEffects$', 'untrackedLoading'); @@ -87,6 +87,12 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple } get untrackedLoading() { + this.$computeIfNeeded$(); + // During SSR there's no such thing as loading state, we must render complete results + if ((import.meta.env.TEST ? isServerPlatform() : isServer) && this.$promise$) { + DEBUG && log('Throwing loading promise for SSR'); + throw this.$promise$; + } return this.$untrackedLoading$; } @@ -118,106 +124,92 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple } } + /** Invalidates the signal, causing it to re-compute its value. */ override invalidate() { // clear the promise, we need to get function again this.$promise$ = null; - super.invalidate(); + this.$flags$ |= SignalFlags.INVALID; + if (this.$effects$?.size) { + this.promise(); + } } - async promise(): Promise { - // make sure we get a new promise during the next computation - this.$promise$ = null; - await retryOnPromise(this.$computeIfNeeded$.bind(this)); - return this.$untrackedValue$!; + /** Returns a promise resolves when the signal finished computing. */ + async promise(): Promise { + this.$computeIfNeeded$(); + await this.$promise$; } - $computeIfNeeded$() { - if (!(this.$flags$ & SignalFlags.INVALID)) { + /** Run the computation if needed */ + $computeIfNeeded$(): void { + if (!(this.$flags$ & SignalFlags.INVALID) || this.$promise$) { return; } + DEBUG && log('Starting new async computation'); - const untrackedValue = - // first time - this.$promiseValue$ === NEEDS_COMPUTATION || - // or after invalidation - this.$promise$ === null - ? this.$promiseComputation$() - : this.$promiseValue$; + this.$flags$ &= ~SignalFlags.INVALID; - if (isPromise(untrackedValue)) { - const isFirstComputation = this.$promiseValue$ === NEEDS_COMPUTATION; - this.untrackedLoading = true; - this.untrackedError = undefined; + this.$clearNextPoll$(); - if (this.$promiseValue$ !== NEEDS_COMPUTATION) { - // skip cleanup after resuming - cleanupDestroyable(this); - } + // TODO keep set of cleanups per invocation and clean up when invalidated + // probably use a proxy for the props, lazy create tracker/cleanup/abort + cleanupDestroyable(this); + const [cleanup] = cleanupFn(this, (err) => this.$container$?.handleError(err, null!)); + const args = { + track: trackFn(this, this.$container$), + cleanup, + }; + const fn = this.$computeQrl$.resolved; + // TODO wait for all computations in the container, for SSR and tests + // need to always wait for all computations to resolve before continuing SSR stream + const result = fn + ? retryOnPromise(() => fn(args)) + : this.$computeQrl$.resolve().then((resolvedFn) => retryOnPromise(() => resolvedFn(args))); + + if (isPromise(result)) { + this.untrackedLoading = true; + // we leave error as-is until result - const promise = untrackedValue + this.$promise$ = result .then((promiseValue) => { + this.$promise$ = null; DEBUG && log('Promise resolved', promiseValue); - this.$promiseValue$ = promiseValue; + // Note that these assignments run setters this.untrackedLoading = false; this.untrackedError = undefined; - if (this.setValue(promiseValue)) { - DEBUG && log('Scheduling effects for subscribers', this.$effects$?.size); + this.value = promiseValue; - this.$flags$ &= ~SignalFlags.RUN_EFFECTS; - scheduleEffects(this.$container$, this, this.$effects$); - } this.$scheduleNextPoll$(); }) .catch((err) => { - if (isPromise(err)) { - // ignore promise errors, they will be handled - return; - } + this.$promise$ = null; DEBUG && log('Error caught in promise.catch', err); - this.$promiseValue$ = err; this.untrackedLoading = false; this.untrackedError = err; this.$scheduleNextPoll$(); }); - - if (isFirstComputation) { - // we want to throw only the first time - // the next time we will return stale value - throw promise; - } else { - DEBUG && - log('Returning stale value', this.$untrackedValue$, 'while computing', untrackedValue); - // Return the promise so the scheduler can track it as a running chore - return promise; - } } else { - this.setValue(untrackedValue); + this.untrackedError = undefined; + this.value = result; } } - // TODO it would be nice to wait for all computations in the container, for tests - private async $promiseComputation$(): Promise { - if (!this.$promise$) { - this.$clearNextPoll$(); - - const [cleanup] = cleanupFn(this, (err) => this.$container$?.handleError(err, null!)); - - this.$promise$ = this.$computeQrl$.getFn()({ - track: trackFn(this, this.$container$), - cleanup, - }) as ValueOrPromise; + get untrackedValue() { + this.$computeIfNeeded$(); + if (this.$promise$) { + if (this.$untrackedValue$ === NEEDS_COMPUTATION) { + DEBUG && log('Throwing promise while computing initial value', this); + throw this.$promise$; + } + DEBUG && + log('Returning stale value', this.$untrackedValue$, 'while computing', this.$promise$); + return this.$untrackedValue$; } - return this.$promise$; - } - - private setValue(value: T) { - this.$flags$ &= ~SignalFlags.INVALID; - const didChange = value !== this.$untrackedValue$; - if (didChange) { - this.$untrackedValue$ = value; - this.$flags$ |= SignalFlags.RUN_EFFECTS; + if (this.$untrackedError$) { + DEBUG && log('Throwing error while reading value', this); + throw this.$untrackedError$; } - return didChange; + return this.$untrackedValue$; } private $clearNextPoll$() { @@ -228,19 +220,14 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple } private $scheduleNextPoll$() { if ( - (isBrowser || (import.meta.env.TEST && !isServerPlatform())) && + (import.meta.env.TEST ? !isServerPlatform() : isBrowser) && this.$pollMs$ > 0 && this.$effects$?.size ) { if (this.$pollTimeoutId$ !== undefined) { clearTimeout(this.$pollTimeoutId$); } - this.$pollTimeoutId$ = setTimeout(() => { - this.invalidate(); - if (this.$effects$?.size) { - retryOnPromise(this.$computeIfNeeded$.bind(this)); - } - }, this.$pollMs$); + this.$pollTimeoutId$ = setTimeout(this.invalidate.bind(this), this.$pollMs$); this.$pollTimeoutId$?.unref?.(); } } 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 578a8b68c26..f667fd21590 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 @@ -47,7 +47,7 @@ export class ComputedSignalImpl> ) { // The value is used for comparison when signals trigger, which can only happen // when it was calculated before. Therefore we can pass whatever we like. - super(container, NEEDS_COMPUTATION); + super(container || fn.$container$, NEEDS_COMPUTATION); this.$computeQrl$ = fn; this.$flags$ = flags; } 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 acce4eb6165..b526471e33d 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts +++ b/packages/qwik/src/core/reactive-primitives/impl/signal-impl.ts @@ -29,7 +29,7 @@ export class SignalImpl implements Signal { $wrappedSignal$: WrappedSignalImpl | null = null; constructor(container: Container | null, value: T) { - this.$container$ = container; + this.$container$ = container || tryGetInvokeContext()?.$container$ || null; this.$untrackedValue$ = value; DEBUG && log('new', this); } @@ -54,10 +54,12 @@ export class SignalImpl implements Signal { get value() { const ctx = tryGetInvokeContext(); if (!ctx) { + DEBUG && log('read->no-ctx', pad('\n' + this.toString(), ' ')); return this.untrackedValue; } if (this.$container$ === null) { if (!ctx.$container$) { + DEBUG && log('read->no-container', pad('\n' + this.toString(), ' ')); return this.untrackedValue; } // Grab the container now we have access to it @@ -83,13 +85,21 @@ export class SignalImpl implements Signal { addQrlToSerializationCtx(effectSubscriber, this.$container$); DEBUG && log('read->sub', pad('\n' + this.toString(), ' ')); } + DEBUG && log('read no sub', pad('\n' + this.toString(), ' ')); return this.untrackedValue; } set value(value) { if (value !== this.$untrackedValue$) { DEBUG && - log('Signal.set', this.$untrackedValue$, '->', value, pad('\n' + this.toString(), ' ')); + log( + 'Signal.set', + this.$untrackedValue$, + '->', + value, + pad('\n' + this.toString(), ' '), + this.$effects$ ? 'subs: ' + this.$effects$.size : 'no subs' + ); this.$untrackedValue$ = value; scheduleEffects(this.$container$, this, this.$effects$); } diff --git a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx index 0c753fd4c88..becb83525f6 100644 --- a/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx +++ b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx @@ -364,8 +364,6 @@ describe('signal', () => { // First read should return initial value without throwing expect(signal.value).toBe(10); - // Promise value should be set to initial - expect((signal as any).$promiseValue$).toBe(10); }); }); @@ -446,10 +444,9 @@ describe('signal', () => { expect(signal.value).toBe(10); // Wait for the async promise to resolve - const resolvedValue = await signal.promise(); + await signal.promise(); // After promise resolves, should have computed value - expect(resolvedValue).toBe(42); expect(signal.value).toBe(42); }); }); diff --git a/packages/qwik/src/core/reactive-primitives/signal-api.ts b/packages/qwik/src/core/reactive-primitives/signal-api.ts index 2b4d97ad35f..efcb396b438 100644 --- a/packages/qwik/src/core/reactive-primitives/signal-api.ts +++ b/packages/qwik/src/core/reactive-primitives/signal-api.ts @@ -28,7 +28,7 @@ export const createComputedSignal = ( return new ComputedSignalImpl( options?.container || null, qrl as ComputeQRL, - getComputedSignalFlags(options?.serializationStrategy || 'always') + getComputedSignalFlags(options?.serializationStrategy || 'never') ); }; @@ -40,7 +40,7 @@ export const createAsyncSignal = ( return new AsyncSignalImpl( options?.container || null, qrl as AsyncQRL, - getComputedSignalFlags(options?.serializationStrategy || 'never'), + getComputedSignalFlags(options?.serializationStrategy || 'always'), options ); }; diff --git a/packages/qwik/src/core/reactive-primitives/signal.public.ts b/packages/qwik/src/core/reactive-primitives/signal.public.ts index dd1593b60f9..9eae0ae41f9 100644 --- a/packages/qwik/src/core/reactive-primitives/signal.public.ts +++ b/packages/qwik/src/core/reactive-primitives/signal.public.ts @@ -16,17 +16,28 @@ export interface ReadonlySignal { /** @public */ export interface AsyncSignal extends ComputedSignal { - /** Whether the signal is currently loading. */ + /** + * Whether the signal is currently loading. This will trigger lazy loading of the signal, so you + * can use it like this: + * + * ```tsx + * signal.loading ? : signal.error ? : + * ``` + */ loading: boolean; - /** The error that occurred while computing the signal. */ + /** + * The error that occurred while computing the signal, if any. This will be cleared when the + * signal is successfully computed. + */ error: Error | undefined; /** * Poll interval in ms. Writable and immediately effective when the signal has consumers. If set * to `0`, polling stops. */ pollMs: number; - /** A promise that resolves when the value is computed. */ - promise(): Promise; + /** A promise that resolves when the value is computed or rejected. */ + promise(): Promise; } /** diff --git a/packages/qwik/src/core/shared/serdes/inflate.ts b/packages/qwik/src/core/shared/serdes/inflate.ts index 63c2eee92b1..14f9b437599 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -152,7 +152,6 @@ export const inflate = ( Array | undefined, Array | undefined, Array | undefined, - boolean, Error, unknown?, number?, @@ -162,16 +161,17 @@ export const inflate = ( asyncSignal.$effects$ = new Set(d[2]); asyncSignal.$loadingEffects$ = new Set(d[3]); asyncSignal.$errorEffects$ = new Set(d[4]); - asyncSignal.$untrackedLoading$ = d[5] || false; - asyncSignal.$untrackedError$ = d[6]; - const hasValue = d.length > 7; + asyncSignal.$untrackedError$ = d[5]; + const hasValue = d.length > 6; if (hasValue) { - asyncSignal.$untrackedValue$ = d[7]; - asyncSignal.$promiseValue$ = d[7]; + asyncSignal.$untrackedValue$ = d[6]; + } + if (asyncSignal.$untrackedValue$ !== NEEDS_COMPUTATION) { + // If we have a value after SSR, it will always be mean the signal was not invalid + asyncSignal.$flags$ &= ~SignalFlags.INVALID; } // Note, we use the setter so that it schedules polling if needed - asyncSignal.pollMs = d[8] ?? 0; - asyncSignal.$flags$ |= SignalFlags.INVALID; + asyncSignal.pollMs = d[7] ?? 0; break; } // Inflating a SerializerSignal is the same as inflating a ComputedSignal @@ -201,12 +201,11 @@ export const inflate = ( const hasValue = d.length > 3; if (hasValue) { computed.$untrackedValue$ = d[3]; - // The serialized signal is always invalid so it can recreate the custom object - if (typeId === TypeIds.SerializerSignal) { - computed.$flags$ |= SignalFlags.INVALID; - } - } else { - computed.$flags$ |= SignalFlags.INVALID; + } + if (typeId !== TypeIds.SerializerSignal && computed.$untrackedValue$ !== NEEDS_COMPUTATION) { + // If we have a value after SSR, it will always be mean the signal was not invalid + // The serialized signal is always left invalid so it can recreate the custom object + computed.$flags$ &= ~SignalFlags.INVALID; } break; } diff --git a/packages/qwik/src/core/shared/serdes/serdes.unit.ts b/packages/qwik/src/core/shared/serdes/serdes.unit.ts index 27ee6bb835a..c677f26f2a2 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.unit.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.unit.ts @@ -3,7 +3,7 @@ import { _verifySerializable, componentQrl, createAsync$, - createComputedQrl, + createComputed$, createSerializer$, createSignal, isSignal, @@ -19,7 +19,12 @@ import { type SignalImpl } from '../../reactive-primitives/impl/signal-impl'; import { createStore } from '../../reactive-primitives/impl/store'; import { createAsyncSignal } from '../../reactive-primitives/signal-api'; import { SubscriptionData } from '../../reactive-primitives/subscription-data'; -import { EffectProperty, EffectSubscription, StoreFlags } from '../../reactive-primitives/types'; +import { + EffectProperty, + EffectSubscription, + SignalFlags, + StoreFlags, +} from '../../reactive-primitives/types'; import { createResourceReturn } from '../../use/use-resource'; import { Task } from '../../use/use-task'; import { QError } from '../error/error'; @@ -510,55 +515,51 @@ describe('shared-serialization', () => { `); }); it(title(TypeIds.ComputedSignal), async () => { - const foo = createSignal(1); - const dirty = createComputedQrl(inlinedQrl(() => foo.value + 1, 'dirty', [foo])); - const clean = createComputedQrl(inlinedQrl(() => foo.value + 1, 'clean', [foo])); - const never = createComputedQrl( - inlinedQrl(() => foo.value + 1, 'never', [foo]), - { - serializationStrategy: 'never', - } - ); - const always = createComputedQrl( - inlinedQrl(() => foo.value + 1, 'always', [foo]), - { - serializationStrategy: 'always', - } - ); + const foo = createSignal(0); + const dirty = createComputed$(() => foo.value + 1, { serializationStrategy: 'always' }); + const clean = createComputed$(() => foo.value + 2, { serializationStrategy: 'always' }); + const never = createComputed$(() => foo.value + 3, { serializationStrategy: 'never' }); + const always = createComputed$(() => foo.value + 4, { serializationStrategy: 'always' }); + const noSer = createComputed$(() => noSerialize({ foo })); // note that this won't subscribe because we're not setting up the context + // do not read `dirty` to keep it dirty expect(clean.value).toBe(2); - expect(never.value).toBe(2); - expect(always.value).toBe(2); - const objs = await serialize(dirty, clean, never, always); + expect(never.value).toBe(3); + expect(always.value).toBe(4); + const objs = await serialize(dirty, clean, never, always, noSer); expect(_dumpState(objs)).toMatchInlineSnapshot(` " 0 ComputedSignal [ - QRL "5#6#4" + QRL "6#7#5" ] 1 ComputedSignal [ - QRL "5#7#4" + QRL "6#8#5" Constant undefined Constant undefined {number} 2 ] 2 ComputedSignal [ - QRL "5#8#4" + QRL "6#9#5" ] 3 ComputedSignal [ - QRL "5#9#4" + QRL "6#10#5" Constant undefined Constant undefined - {number} 2 + {number} 4 ] - 4 Signal [ - {number} 1 + 4 ComputedSignal [ + QRL "6#11#5" ] - 5 {string} "mock-chunk" - 6 {string} "dirty" - 7 {string} "clean" - 8 {string} "never" - 9 {string} "always" - (150 chars)" + 5 Signal [ + {number} 0 + ] + 6 {string} "mock-chunk" + 7 {string} "describe_describe_it_dirty_createComputed_ahnh0V4rf6g" + 8 {string} "describe_describe_it_clean_createComputed_0ZTfN4iJ0tg" + 9 {string} "describe_describe_it_never_createComputed_1HbLed7JXyo" + 10 {string} "describe_describe_it_always_createComputed_4nMmgHlUOog" + 11 {string} "describe_describe_it_noSer_createComputed_pXwl00hYYQQ" + (417 chars)" `); }); it(title(TypeIds.SerializerSignal), async () => { @@ -701,6 +702,12 @@ describe('shared-serialization', () => { ] 1 AsyncSignal [ QRL "6#8#5" + Constant undefined + Constant undefined + Constant undefined + Constant undefined + Constant undefined + {number} 2 ] 2 AsyncSignal [ QRL "6#9#5" @@ -712,7 +719,6 @@ describe('shared-serialization', () => { Constant undefined Constant undefined Constant undefined - Constant undefined {number} 2 ] 4 AsyncSignal [ @@ -722,7 +728,6 @@ describe('shared-serialization', () => { Constant undefined Constant undefined Constant undefined - Constant undefined Constant NEEDS_COMPUTATION {number} 100 ] @@ -735,7 +740,7 @@ describe('shared-serialization', () => { 9 {string} "never" 10 {string} "always" 11 {string} "polling" - (217 chars)" + (233 chars)" `); }); it(title(TypeIds.Store), async () => { @@ -1017,15 +1022,26 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.WrappedSignal)); it.todo(title(TypeIds.ComputedSignal)); it.todo(title(TypeIds.SerializerSignal)); - it(title(TypeIds.AsyncSignal), async () => { + it(`${title(TypeIds.AsyncSignal)} valid`, async () => { + const asyncSignal = createAsync$(async () => 123); + expect((asyncSignal as AsyncSignalImpl).$flags$ & SignalFlags.INVALID).toBeTruthy(); + await asyncSignal.promise(); + expect((asyncSignal as AsyncSignalImpl).$untrackedValue$).toBe(123); + const objs = await serialize(asyncSignal); + const restored = deserialize(objs)[0] as AsyncSignal; + expect(isSignal(restored)).toBeTruthy(); + expect((restored as AsyncSignalImpl).$untrackedValue$).toBe(123); + expect((restored as AsyncSignalImpl).$flags$ & SignalFlags.INVALID).toBeFalsy(); + }); + it(`${title(TypeIds.AsyncSignal)} invalid`, async () => { const asyncSignal = createAsync$(async () => 123, { pollMs: 50 }); const objs = await serialize(asyncSignal); const restored = deserialize(objs)[0] as AsyncSignal; expect(isSignal(restored)).toBeTruthy(); expect((restored as AsyncSignalImpl).$pollMs$).toBe(50); + expect((restored as AsyncSignalImpl).$flags$ & SignalFlags.INVALID).toBeTruthy(); await restored.promise(); - // note that this won't subscribe because we're not setting up the context - expect(restored.value).toBe(123); + expect((restored as AsyncSignalImpl).$untrackedValue$).toBe(123); }); // this requires a domcontainer it(title(TypeIds.Store), async () => { diff --git a/packages/qwik/src/core/shared/serdes/serialize.ts b/packages/qwik/src/core/shared/serdes/serialize.ts index 6a46fd694bb..24e199e50a0 100644 --- a/packages/qwik/src/core/shared/serdes/serialize.ts +++ b/packages/qwik/src/core/shared/serdes/serialize.ts @@ -426,12 +426,12 @@ export async function serialize(serializationContext: SerializationContext): Pro const isSkippable = fastSkipSerialize(value.$untrackedValue$); const pollMs = value instanceof AsyncSignalImpl ? value.$pollMs$ : 0; - if (shouldAlwaysSerialize) { + if (isInvalid || isSkippable) { + v = NEEDS_COMPUTATION; + } else if (shouldAlwaysSerialize) { v = value.$untrackedValue$; } else if (shouldNeverSerialize) { v = NEEDS_COMPUTATION; - } else if (isInvalid || isSkippable) { - v = NEEDS_COMPUTATION; } const out: unknown[] = [ @@ -441,12 +441,8 @@ export async function serialize(serializationContext: SerializationContext): Pro ]; const isAsync = value instanceof AsyncSignalImpl; if (isAsync) { - out.push( - value.$loadingEffects$, - value.$errorEffects$, - value.$untrackedLoading$ || undefined, - value.$untrackedError$ - ); + // After SSR, the signal is never loading, so no need to send it + out.push(value.$loadingEffects$, value.$errorEffects$, value.$untrackedError$); } let keepUndefined = false; diff --git a/packages/qwik/src/core/tests/use-async.spec.tsx b/packages/qwik/src/core/tests/use-async.spec.tsx index 585241cd44e..fb4a9ca67eb 100644 --- a/packages/qwik/src/core/tests/use-async.spec.tsx +++ b/packages/qwik/src/core/tests/use-async.spec.tsx @@ -1,11 +1,13 @@ import { $, Fragment as Signal, + Slot, _jsxSorted, _wrapProp, component$, useAsync$, useConstant, + useErrorBoundary, useSignal, useTask$, } from '@qwik.dev/core'; @@ -123,22 +125,43 @@ describe.each([ ); }); - it('should handle error if promise is rejected', async () => { + it('should throw error on value if promise is rejected', async () => { (globalThis as any).log = []; + const ErrorBoundary = component$(() => { + const store = useErrorBoundary(); + (globalThis as any).log.push(`rendering error boundary, ${store.error || 'no error'}`); + return store.error ?
{JSON.stringify(store.error)}
: ; + }); const Counter = component$(() => { - const count = useSignal(1); + (globalThis as any).log.push('rendering counter'); const doubleCount = useAsync$(() => Promise.reject(new Error('test'))); - - useTask$(({ track }) => { - track(doubleCount); - - (globalThis as any).log.push((doubleCount as any).untrackedError.message); - }); - - return ; + return
{doubleCount.value}
; }); - await render(, { debug }); - expect((globalThis as any).log).toEqual(['test']); + let threw = false; + try { + await render( + + , + , + { debug } + ); + } catch (e) { + threw = true; + } + if (render === ssrRenderToDom) { + expect((globalThis as any).log).toEqual([ + 'rendering error boundary, no error', + 'rendering counter', + ]); + expect(threw).toBe(true); + } else { + expect((globalThis as any).log).toEqual([ + 'rendering error boundary, no error', + 'rendering counter', + 'rendering error boundary, Error: test', + ]); + expect(threw).toBe(false); + } }); it('should handle undefined as promise result', async () => { @@ -220,8 +243,10 @@ describe.each([ const count = useSignal(1); const doubleCount = useAsync$(async ({ track }) => { const countValue = track(count); - if (countValue > 1) { + if (countValue === 2) { await (globalThis as any).delay(); + } else { + await delay(10); } return countValue * 2; }); @@ -232,17 +257,33 @@ describe.each([ ); }); const { vNode, container } = await render(, { debug }); - await waitForDrain(container); - expect(vNode).toMatchVDOM( - <> - - - ); + if (render === ssrRenderToDom) { + expect(vNode).toMatchVDOM( + <> + + + ); + } else { + expect(vNode).toMatchVDOM( + <> + + + ); + await delay(20); + expect(vNode).toMatchVDOM( + <> + + + ); + } await trigger(container.element, 'button', 'click'); - expect(vNode).toMatchVDOM( <>