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/.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. diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 44bc8cb52f7..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\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, 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" }, @@ -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" }, @@ -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" }, { @@ -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 25f183b258f..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,32 @@ 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 ? ( + +) : ( + +); +``` + + + + +[pollMs](#) + + + + + +number + + + +Poll interval in ms. Writable and immediately effective when the signal has consumers. If set to `0`, polling stops. @@ -200,7 +225,7 @@ Description -A promise that resolves when the value is computed. +A promise that resolves when the value is computed or rejected. @@ -819,12 +844,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 +881,7 @@ options -[ComputedOptions](#computedoptions) +AsyncSignalOptions<T> @@ -879,7 +902,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) => @@ -2618,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 @@ -9012,7 +9035,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; ``` @@ -9046,7 +9069,7 @@ options -[ComputedOptions](#computedoptions) \| undefined +AsyncSignalOptions<T> \| undefined 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-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/LLM-context.md b/packages/qwik/LLM-context.md new file mode 100644 index 00000000000..8bec58250d7 --- /dev/null +++ b/packages/qwik/LLM-context.md @@ -0,0 +1,609 @@ +# LLM Context Guide for Qwik Core Development + +**Purpose**: This document captures lessons learned and important patterns for LLMs (and developers) working on the Qwik core package (`packages/qwik`). Update this file as you learn new patterns or encounter challenges. + +--- + +## Architecture Overview + +### Signal System + +- **Hierarchy**: `SignalImpl` → `ComputedSignalImpl` → `AsyncSignalImpl` +- **Key Components**: + - `SignalImpl`: Base reactive signal with value and subscribers + - `ComputedSignalImpl`: Signal computed from other signals or QRLs + - `AsyncSignalImpl`: Signal for async operations with `loading` and `error` properties + - Each signal type has impl and public interface files + +### File Organization + +``` +packages/qwik/src/core/ +├── reactive-primitives/ +│ ├── impl/ # Implementation classes +│ │ ├── signal-impl.ts +│ │ ├── computed-signal-impl.ts +│ │ ├── async-signal-impl.ts +│ │ └── signal.unit.tsx # Tests for all signal types +│ ├── signal.public.ts # Public interfaces and exports +│ ├── signal-api.ts # Factory functions (createSignal, createAsync$, etc) +│ └── types.ts # Core type definitions +└── ... +``` + +--- + +## Key Patterns & Best Practices + +### 0. ALWAYS test + +**Always add or update unit tests** when modifying or adding features. Make sure the test fails before your change and passes after. +Unit tests go in `*.unit.tsx?` files, and tests related to SSR and DOM rendering go in `*.spec.tsx?` files. + +### 1. Constructor Parameters Flow + +When adding new parameters to signal constructors: + +- **Don't break the parent constructor call**: The constructor parameter order matters +- **Pattern**: `constructor(container, fn, flags, newParam = defaultValue)` +- **Extraction**: Extract options in factory functions before passing to constructor + ```typescript + // In signal-api.ts + export const createAsyncSignal = ( + qrl: QRL<...>, + options?: ComputedOptions + ): AsyncSignalImpl => { + return new AsyncSignalImpl( + options?.container || null, + qrl as AsyncQRL, + getComputedSignalFlags(options?.serializationStrategy || 'never'), + (options as any)?.pollMs || 0 // Extract custom option here + ); + }; + ``` + +### 2. Async Signal Implementation Pattern + +When modifying AsyncSignalImpl: + +- **Promise handlers**: Both `.then()` and `.catch()` need the same side effects +- **Cleanup on invalidate**: Clear all timeouts/resources in the `invalidate()` override BEFORE clearing cached data + ```typescript + override invalidate() { + // Clean up resources first + if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); + this.$pollTimeoutId$ = undefined; + } + // Then clear cached data + this.$promise$ = null; + super.invalidate(); + } + ``` + +### 3. Platform Detection (SSR vs Browser) + +- **Import**: `import { isBrowser } from '@qwik.dev/core/build'` +- **Pattern**: Always store configuration on instance, only execute browser-specific code when needed + + ```typescript + // Store poll interval always (for SSR hydration) + this.pollMs = pollMs; + + // But only schedule timeouts on browser + if (isBrowser && this.$pollMs$ > 0 && this.$effects$?.size) { + this.$pollTimeoutId$ = setTimeout(...); + } + ``` + +### 4. Testing Async Signals + +**Always use `$`-suffixed functions** (like `$()`) in tests whenever possible. The optimizer will handle them correctly before test execution. + +```typescript +// ✅ CORRECT - use $() for QRL functions in tests +const signal = await retryOnPromise(() => + createAsync$( + $<() => Promise>(async () => { + return 42; + }), + { pollMs: 50 } as any + ) +); +``` + +**Important**: If you get serialization errors when using `$()`, it means the closure is capturing variables that cannot be serialized. In that case: + +- Extract the logic to a top-level function +- Use a different approach that doesn't require closure capture +- Check what variable is causing the issue (error message will indicate it) + +**Test Pattern**: + +```typescript +it('description', async () => { + await withContainer(async () => { + const signal: AsyncSignal = await retryOnPromise(() => + createAsync$( + $<() => Promise>(async () => { + return 42; + }), + { pollMs: 50 } as any + ) + ); + + // Subscribe to trigger computation + await retryOnPromise(() => { + effect$(() => signal.value); + }); + + // Verify internal state (use as any for private members) + expect((signal as any).$pollMs$).toBe(50); + }); +}); +``` + +**Avoid**: Creating QRLs explicitly with `inlinedQrl()` or other manual QRL construction. Let the `$` optimizer do the work. + +--- + +## Lessons Learned + +### Lesson 1: Promise Throws on First Computation + +**Context**: When a signal first computes and returns a promise, it throws the promise to trigger Suspense-like behavior. + +**Pattern**: + +```typescript +if (isFirstComputation) { + // throw the promise on first read so component waits for it + throw promise; +} else { + // on subsequent computations, return stale value while computing + return currentValue; +} +``` + +**Implication**: Tests must use `await retryOnPromise()` when first reading a signal, or wrap creation in it. + +### Lesson 2: Effect Subscriptions Control Polling + +**Context**: The `$effects$` Set tracks all subscribers to a signal. + +**Pattern**: + +```typescript +// Only poll when there are active subscribers +if (isBrowser && this.$pollMs$ > 0 && this.$effects$?.size) { + // Schedule poll +} +``` + +**Implication**: Polling automatically stops when subscribers drop to zero—no explicit cleanup needed for that case. + +### Lesson 2b: Use the `pollMs` Accessor for Poll Updates + +**Context**: `AsyncSignalImpl` exposes a public `pollMs` property that clears/reset polling and re-schedules immediately when there are consumers. + +**Pattern**: + +```typescript +// Clears any existing timeout, updates value, and schedules only if there are effects +signal.pollMs = 100; +``` + +**Implication**: Use the setter during hydration (`inflate`) or construction to ensure polling starts as soon as consumers are present. + +### Lesson 3: Timeout Cleanup is Critical + +**Context**: Setting timeouts without cleanup causes memory leaks and test interference. + +**Pattern**: + +```typescript +// Always clear before setting new timeout +if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); +} +this.$pollTimeoutId$ = setTimeout(...); + +// Clear in cleanup paths too +override invalidate() { + if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); + this.$pollTimeoutId$ = undefined; + } + // ... rest of cleanup +} +``` + +### Lesson 4: Options Passing Strategy + +**Context**: Qwik uses `ComputedOptions` for configuration, but `AsyncSignalOptions` extends it with more options. + +**Challenge**: TypeScript won't recognize custom options on `ComputedOptions`. + +**Solution**: Extract in factory function using `(options as any)?.customOption`: + +```typescript +export const createAsyncSignal = ( + qrl: QRL<...>, + options?: ComputedOptions // Use base type +): AsyncSignalImpl => { + return new AsyncSignalImpl( + options?.container || null, + qrl as AsyncQRL, + getComputedSignalFlags(options?.serializationStrategy || 'never'), + (options as any)?.poll || 0 // Access custom property with 'as any' + ); +}; +``` + +### Lesson 5: Both Promise Branches Need Handler + +**Context**: Async functions can either resolve or reject. + +**Pattern**: Both `.then()` and `.catch()` must schedule next poll: + +```typescript +const promise = untrackedValue + .then((promiseValue) => { + // ... handle success + this.$scheduleNextPoll$(); // Schedule next poll + }) + .catch((err) => { + // ... handle error + this.$scheduleNextPoll$(); // Still schedule next poll! + }); +``` + +**Implication**: Errors don't stop polling—polls continue on the configured interval. + +### Lesson 6: Test Helper Patterns + +**Key Helpers in signal.unit.tsx**: + +- `withContainer(fn)`: Wraps test code with proper invoke context +- `effect$(qrl)`: Creates a reactive effect that subscribes to signals +- `retryOnPromise(fn)`: Waits for all pending promises to resolve +- `flushSignals()`: Flushes the container's render promise + +**Pattern**: + +```typescript +await withContainer(async () => { + // Create signal + const signal = await retryOnPromise(() => createAsync$(...)); + + // Subscribe + const log: number[] = []; + effect$(() => log.push(signal.value)); + + // Verify + expect(log).toEqual([1]); +}); +``` + +### Lesson 7: SSR vs Client Regressions are Distinct + +**Context**: When implementing a feature with SSR+client code paths, test failures may only appear on one side. + +**Pattern**: When SSR tests pass but client tests fail: + +1. The core feature logic is likely correct (SSR validates it) +2. The regression is in client-side render scheduling or execution +3. Don't modify SSR code to fix client-side failures—fix the client path instead + +**Specific Learning**: During `useAsync$` polling implementation: + +- SSR tests (12/12) passed immediately ✅ +- DOM tests (14/14) timed out on first `domRender()` call 🔴 +- This pattern meant AsyncSignalImpl logic was sound, but client render queue had an issue +- The root cause was NOT in async signal compute, but in render queue draining + +### Lesson 8: Client Render Queue Must Resolve Promptly + +**Context**: The client-side render queue (`cursor-queue.ts`, `cursor-walker.ts`) manages async rendering via promises. + +**Pattern**: + +```typescript +// In cursor-queue.ts - Promise created when cursors added +container.$renderPromise$ ||= new Promise((resolve) => { + container.$resolveRenderPromise$ = resolve; +}); + +// In cursor-walker.ts - Must resolve when queue drains +if (container.$cursorCount$ === 0) { + container.$resolveRenderPromise$?.(); +} +``` + +**Critical Issue**: If `$resolveRenderPromise$` is never called, the promise hangs indefinitely, blocking all renders. + +**Debugging Signal**: All domRender tests timing out at exactly 5000ms = promise never resolves. + +### Lesson 9: Conditional Promise Await Can Break Render Flow + +**Context**: When conditionally awaiting `$renderPromise$`, ensure the condition properly reflects render state. + +**Anti-Pattern**: + +```typescript +// DON'T unconditionally await +await container.$renderPromise$; + +// DON'T await only when cursors exist (this may miss final resolution) +if (container.$cursorCount$ > 0) { + await container.$renderPromise$; +} +``` + +**Better Pattern**: Let the render walker manage promise resolution: + +```typescript +// Only explicitly await if you're coordinating final render completion +// Otherwise, cursor-walker.ts will resolve automatically +if (container.$renderPromise$ && someSpecialCondition) { + await container.$renderPromise$; +} +``` + +**Implication**: The cursor system is designed for automatic resolution. Additional await points can interfere with timing. + +### Lesson 10: Always run required validations + +**Context**: After implementing features, missing required validation steps can hide regressions and break CI. + +**Required validations for core changes**: + +1. `pnpm build --qwik --qwikrouter --dev` +2. `pnpm run test.e2e.chromium` +3. `pnpm api.update` + +**Recent Example**: `pnpm api.update` surfaced a TypeScript error in `allocate.ts` after changing `AsyncSignalImpl` constructor parameters. The fix was to pass an options object instead of a number. + +**Implication**: Always run all three validations before finishing and report their results. + +--- + +## Common Pitfalls & Solutions + +| Pitfall | Solution | +| ------------------------------- | ------------------------------------------------------------------------------- | +| Serialization errors in `$()` | The closure is capturing non-serializable variables; refactor to avoid capture | +| Tests hang on promises | Use `await retryOnPromise()` and `await withContainer()` | +| Timeout leaks in tests | Clear timeouts in `invalidate()` and cleanup methods | +| Poll runs without subscribers | Check `this.$effects$?.size > 0` before scheduling | +| Polling on SSR breaks | Check `isBrowser` before `setTimeout()` | +| Can't access `$private` members | Use `(signal as any).$private$` in tests | +| Effects don't run | Ensure `effect$()` is called within `withContainer()` and after signal creation | +| API extractor complains | Add `@internal` JSDoc tag to ALL function overloads, not just one | +| DOM render hangs | Check cursor queue promise resolution in cursor-walker.ts and cursor-queue.ts | +| Conditional await blocks render | Let cursor system resolve automatically; avoid competing await points | + +--- + +## Implementation Checklist for Signal Features + +When adding a feature to AsyncSignalImpl (like `poll`): + +- [ ] Add instance properties for storing the feature state +- [ ] Add constructor parameter with sensible default +- [ ] Import `isBrowser` from `@qwik.dev/core/build` if browser-only +- [ ] Handle both `.then()` and `.catch()` promise paths +- [ ] Add cleanup in `invalidate()` override +- [ ] Update `createAsyncSignal()` to extract options +- [ ] Add unit tests covering: + - [ ] Instance stores the option + - [ ] Cleanup works correctly + - [ ] SSR hydration compatibility +- [ ] Consider error handling paths +- [ ] Verify no memory leaks in tests + +--- + +## Build & Test Commands + +```bash +# Run tests for a specific file +pnpm vitest run packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx + +# After changes, always run the relevant unit tests using: +# pnpm vitest + +# End-to-end validation (run when task is complete) +pnpm build --qwik --qwikrouter --dev +pnpm run test.e2e.chromium + +# API/type verification (slow; run when necessary or when task is complete) +pnpm api.update + +# Build full project (slow, ~2-3 min) +pnpm build.full + +# Type check +pnpm tsc.check + +# Run linter +pnpm lint +``` + +--- + +## File Modification Patterns + +### When Modifying Constructor Signatures + +- Always check parent class constructor +- Keep defaults to not break existing callers +- Update factory functions to pass through new parameters + +### When Adding Promise-Based Logic + +- Handle both resolve and reject cases +- Avoid blocking operations (use setTimeout for deferred work) +- Clean up timers/promises in invalidate() and cleanup methods + +### When Adding Tests + +- Use `inlinedQrl()` for simple inline functions +- Use `delayQrl()` for testing lazy-loaded QRLs +- Always wrap in `withContainer()` and `retryOnPromise()` for async +- Test both success and error paths + +--- + +## Recent Changes & Context + +### Poll Feature Implementation (Feb 2026) + +**What**: Added `pollMs?: number` option to `createAsync$()` that reruns the async function at regular intervals, plus a public writable `pollMs` property on `AsyncSignal`. + +**Files Modified**: + +- `packages/qwik/src/core/reactive-primitives/impl/async-signal-impl.ts` +- `packages/qwik/src/core/reactive-primitives/signal-api.ts` +- `packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx` (added tests) + +**Key Decisions**: + +1. Store poll interval on instance always (even SSR) for hydration +2. Only schedule timeouts on browser (`isBrowser` check) +3. Only poll when subscribers exist (`$effects$?.size > 0`) +4. Don't stop polling on errors—continue at configured interval +5. Clear poll timeout in `invalidate()` before clearing cached promise +6. Use the `pollMs` setter for construction and hydration so polling starts immediately when consumers exist +7. The `pollMs` property is public, writable, and immediately effective only when consumers are present +8. Assume negative values are not provided (no normalization required) + +**Patterns Established**: + +- How to add time-based features to async signals +- How to properly clean up browser-only resources +- How to test async signal behavior without flaking + +--- + +### Eager Resume for Polling AsyncSignals (Feb 2026) + +**What**: During SSR serialization, automatically track polling AsyncSignals with effects and emit a `q-d:qidle` attribute on the state script tag containing a no-op QRL that captures these signals. On document idle, this triggers automatic resumption of polling. + +**Files Modified**: + +- `packages/qwik/src/core/shared/jsx/bind-handlers.ts` - Added `_res` handler (no-op for resumption) +- `packages/qwik/src/core/handlers.mjs` - Exported `_res` handler +- `packages/qwik/src/core/shared/serdes/serialization-context.ts` - Added `$eagerResume$: Set` +- `packages/qwik/src/core/shared/serdes/serialize.ts` - Track AsyncSignals with `pollMs > 0 && effects` +- `packages/qwik/src/core/shared/manifest.ts` - Added `_res` to `extraSymbols` +- `packages/qwik/src/core/shared/ssr/ssr-container.ts` - Emit q-d:qidle attribute with QRL +- `packages/qwik/src/core/internal.ts` - Exported `_res`, `_createQRL`, `_qrlToString` +- `packages/qwik/src/core/shared/serdes/qrl-to-string.ts` - Added `@internal` JSDoc tags to all overloads +- `packages/qwik/src/core/tests/use-async.spec.tsx` - Added integration test + +**Key Learnings**: + +1. **Handler Pattern**: The `_res` handler follows the same pattern as `_chk` and `_val`, using `maybeScopeFromQL()` to deserialize captures +2. **Serialization Tracking**: During serialization, we track root indices of AsyncSignals with polling enabled and effects in a Set +3. **Root Promotion**: Must promote non-root signals to root before adding to `$eagerResume$` using `$promoteToRoot$()` +4. **JSDoc Tags**: API extractor requires `@internal` JSDoc tags on ALL overloads of exported functions, not just the main one +5. **QRL Creation**: Creating a QRL from internal requires: + - `_createQRL(null, '_res', _res, null, [...captureIndices])` + - Converting QRL to string with `_qrlToString(serializationContext, qrl)` +6. **Event Registration**: Must add `'d:qidle'` to `$eventNames$` Set when emitting the attribute +7. **Attribute Format**: The `q-d:qidle` attribute (document idle with colon notation) follows Qwik's event attribute convention + +**Testing Patterns**: + +For SSR-specific features like serialization tracking: + +- Use `ssrRenderToDom` to test SSR rendering path +- Use `domRender` to test client-side rendering path +- Check for attributes in rendered HTML with `.querySelector()` +- Use `await render()` and check container state for integration tests + +--- + +### Debugging AsyncSignal Client Render Regression (Feb 5, 2026) + +**What Happened**: After implementing polling AsyncSignals with eager resume: + +- SSR tests passed: 12/12 ✅ +- DOM tests hung: 14/14 timing out at 5000ms ⏱️ +- Regression was specifically in `domRender()` path, not SSR + +**Root Causes Found**: + +1. **Promise Handling in ssr-render-jsx.ts**: + - `MaybeAsyncSignal` logic was pushing promises directly to JSX stack + - Fixed: `const trackedValue = await retryOnPromise(trackFn); stack.push(trackedValue)` + - Status: This was an SSR fix (SSR tests already passed), not the main issue + +2. **Cursor Queue Promise Not Resolving**: + - Client-side cursor queue creates a promise in `cursor-queue.ts` + - Promise must be resolved by `cursor-walker.ts` when queue drains + - If `$resolveRenderPromise$` is never called, all renders hang indefinitely + - Symptom: Exact 5000ms timeout = promise never resolves + +3. **Conditional Await in ssr-render.ts**: + - Added condition: `if ($renderPromise$ && $cursorCount$ === 0)` + - This interfered with cursor walker's promise resolution flow + - The cursor system is designed for automatic resolution, not manual awaits + +**Diagnostic Pattern**: +When client render hangs but SSR works → check cursor queue and promise resolution in walker, not async signal logic. + +**Key Insight for Future Work**: + +- Test both `ssrRenderToDom` and `domRender` always when implementing async features +- SSR tests passing doesn't mean client path works +- Promise hangs in domRender usually point to cursor queue issues +- Don't add extra await points—let the cursor system manage scheduling + +--- + +## Qwik Serialization & Event Patterns + +### Event Attribute Naming Convention + +Qwik uses specific prefixes for event attributes: + +- `q-e:` - Element events (e.g., `q-e:click`, `q-e:qvisible`) +- `q-d:` - Document/global events (e.g., `q-d:qidle`) +- Both use colons to separate the scope from the event name + +### Creating QRLs in SSR + +To create a QRL during SSR and emit it in an attribute: + +```typescript +const qrl = createQRL( + null, // chunk - null for internal QRL creation + '_res', // symbol name + _res, // actual handler function + null, // chunk name - null for static imports + [...] // captures array +); + +const qrlStr = qrlToString(serializationContext, qrl); +attrs.push('q-d:qidle', qrlStr); +``` + +--- + +## Questions for Future LLMs + +When working on qwik/src/core: + +1. Is the change in a signal-related class? Use existing signal patterns. +2. Does it involve promises/async? Handle both resolve and reject paths. +3. Does it use browser APIs? Check `isBrowser` before executing. +4. Adding to constructor? Update factory functions too. +5. Browser cleanup needed? Add to `invalidate()` override. + +--- + +**Last Updated**: February 5, 2026 (added Lessons 7-9 and debugging insights) +**Last Editor**: LLM Assistant +**Next Steps**: Update this file as new patterns are discovered or lessons learned from future implementations. 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/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/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 b99524936e0..8804da1e896 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -21,7 +21,8 @@ export type AsyncFn = (ctx: AsyncCtx) => Promise; export interface AsyncSignal extends ComputedSignal { error: Error | undefined; loading: boolean; - promise(): Promise; + pollMs: number; + promise(): Promise; } // @internal @@ -108,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) @@ -189,14 +190,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; @@ -210,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 // @@ -714,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; @@ -883,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; @@ -973,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) @@ -983,7 +1000,7 @@ export abstract class _SharedContainer implements _Container { // (undocumented) readonly $locale$: string; // (undocumented) - $pausedCursorCount$: number; + $pendingCount$: number; // (undocumented) $renderPromise$: Promise | null; // (undocumented) @@ -1737,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 9a5542a6741..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,8 +1,9 @@ +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'; @@ -13,6 +14,7 @@ import { NEEDS_COMPUTATION, SerializationSignalFlags, SignalFlags, + type AsyncSignalOptions, } from '../types'; import { scheduleEffects } from '../utils'; import { ComputedSignalImpl } from './computed-signal-impl'; @@ -37,29 +39,43 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple $loadingEffects$: undefined | Set = undefined; $errorEffects$: undefined | Set = undefined; $destroy$: NoSerialize<() => void> | null; - $promiseValue$: T | typeof NEEDS_COMPUTATION = NEEDS_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; [_EFFECT_BACK_REF]: Map | undefined = undefined; 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); + 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.pollMs = pollMs; } /** * 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, - () => (this.$loadingEffects$ ||= new Set()), - () => this.untrackedLoading - ); + return setupSignalValueAccess(this, '$loadingEffects$', 'untrackedLoading'); } set untrackedLoading(value: boolean) { @@ -71,16 +87,18 @@ 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$; } /** 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) { @@ -94,136 +112,123 @@ export class AsyncSignalImpl extends ComputedSignalImpl> imple return this.$untrackedError$; } + get pollMs() { + return this.$pollMs$; + } + + set pollMs(value: number) { + this.$clearNextPoll$(); + this.$pollMs$ = value; + if (this.$pollMs$ > 0 && this.$effects$?.size) { + this.$scheduleNextPoll$(); + } + } + + /** 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.$clearNextPoll$(); + + // 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; - this.untrackedError = undefined; + // we leave error as-is until result - if (this.$promiseValue$ !== NEEDS_COMPUTATION) { - // skip cleanup after resuming - cleanupDestroyable(this); - } - - 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; } } - private async $promiseComputation$(): Promise { - if (!this.$promise$) { - 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; + 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$; + if (this.$untrackedError$) { + DEBUG && log('Throwing error while reading value', this); + throw this.$untrackedError$; + } + return this.$untrackedValue$; } - private setValue(value: T) { - this.$flags$ &= ~SignalFlags.INVALID; - const didChange = value !== this.$untrackedValue$; - if (didChange) { - this.$untrackedValue$ = value; - this.$flags$ |= SignalFlags.RUN_EFFECTS; + private $clearNextPoll$() { + if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); + this.$pollTimeoutId$ = undefined; + } + } + private $scheduleNextPoll$() { + if ( + (import.meta.env.TEST ? !isServerPlatform() : isBrowser) && + this.$pollMs$ > 0 && + this.$effects$?.size + ) { + if (this.$pollTimeoutId$ !== undefined) { + clearTimeout(this.$pollTimeoutId$); + } + this.$pollTimeoutId$ = setTimeout(this.invalidate.bind(this), this.$pollMs$); + this.$pollTimeoutId$?.unref?.(); } - return didChange; } } diff --git a/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts b/packages/qwik/src/core/reactive-primitives/impl/computed-signal-impl.ts index 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 3e8be2ab952..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$); } @@ -119,40 +129,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/reactive-primitives/impl/signal.unit.tsx b/packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx index 4248ceeb541..becb83525f6 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) {} @@ -37,6 +39,12 @@ class Foo { } } +let computeInitialCalls = 0; +const computeInitialFn = async () => { + computeInitialCalls++; + return 42; +}; + describe('signal types', () => { it('Signal', () => () => { const signal = createSignal(1); @@ -270,6 +278,179 @@ 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.$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; + 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); + }); + }); + + 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); + }); + }); + + 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 + await signal.promise(); + + // After promise resolves, should have computed value + 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 220af14538f..efcb396b438 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, @@ -27,19 +28,20 @@ export const createComputedSignal = ( return new ComputedSignalImpl( options?.container || null, qrl as ComputeQRL, - getComputedSignalFlags(options?.serializationStrategy || 'always') + getComputedSignalFlags(options?.serializationStrategy || 'never') ); }; /** @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 || '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 1181f632c11..9eae0ae41f9 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, @@ -16,12 +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; - /** A promise that resolves when the value is computed. */ - promise(): Promise; + /** + * 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 or rejected. */ + promise(): Promise; } /** @@ -90,7 +106,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 +117,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..a0921cf6177 100644 --- a/packages/qwik/src/core/reactive-primitives/types.ts +++ b/packages/qwik/src/core/reactive-primitives/types.ts @@ -48,35 +48,32 @@ 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 * 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/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); 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/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/allocate.ts b/packages/qwik/src/core/shared/serdes/allocate.ts index 3580cdd6b0b..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!); + return new AsyncSignalImpl(container as any, null!, undefined, { pollMs: 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 6ab5f85f290..14f9b437599 100644 --- a/packages/qwik/src/core/shared/serdes/inflate.ts +++ b/packages/qwik/src/core/shared/serdes/inflate.ts @@ -152,23 +152,26 @@ export const inflate = ( Array | undefined, Array | undefined, Array | undefined, - 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.$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]; } - asyncSignal.$flags$ |= SignalFlags.INVALID; + 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[7] ?? 0; break; } // Inflating a SerializerSignal is the same as inflating a ComputedSignal @@ -198,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/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/serdes.unit.ts b/packages/qwik/src/core/shared/serdes/serdes.unit.ts index 584e07afc0c..c677f26f2a2 100644 --- a/packages/qwik/src/core/shared/serdes/serdes.unit.ts +++ b/packages/qwik/src/core/shared/serdes/serdes.unit.ts @@ -2,13 +2,15 @@ import { $, _verifySerializable, componentQrl, - createComputedQrl, + createAsync$, + createComputed$, createSerializer$, createSignal, isSignal, noSerialize, NoSerializeSymbol, SerializerSymbol, + type AsyncSignal, } from '@qwik.dev/core'; import { describe, expect, it, vi } from 'vitest'; import { _fnSignal, _wrapProp } from '../../internal'; @@ -17,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'; @@ -31,6 +38,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; @@ -507,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 Signal [ + {number} 0 ] - 5 {string} "mock-chunk" - 6 {string} "dirty" - 7 {string} "clean" - 8 {string} "never" - 9 {string} "always" - (150 chars)" + 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 () => { @@ -674,6 +678,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 +694,53 @@ 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" + QRL "6#8#5" Constant undefined Constant undefined Constant undefined Constant undefined - Constant false + Constant undefined + {number} 2 ] 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 + {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 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" + (233 chars)" `); }); it(title(TypeIds.Store), async () => { @@ -1009,6 +1022,27 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.WrappedSignal)); it.todo(title(TypeIds.ComputedSignal)); it.todo(title(TypeIds.SerializerSignal)); + 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(); + expect((restored as AsyncSignalImpl).$untrackedValue$).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/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/shared/serdes/serialize.ts b/packages/qwik/src/core/shared/serdes/serialize.ts index 19dcd0fca60..24e199e50a0 100644 --- a/packages/qwik/src/core/shared/serdes/serialize.ts +++ b/packages/qwik/src/core/shared/serdes/serialize.ts @@ -424,13 +424,14 @@ 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) { + 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[] = [ @@ -440,17 +441,13 @@ export async function serialize(serializationContext: SerializationContext): Pro ]; const isAsync = value instanceof AsyncSignalImpl; if (isAsync) { - out.push( - value.$loadingEffects$, - value.$errorEffects$, - value.$untrackedLoading$, - 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; - if (v !== NEEDS_COMPUTATION) { + if (v !== NEEDS_COMPUTATION || pollMs) { out.push(v); if (!isAsync && v === undefined) { @@ -462,6 +459,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/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; 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..fb4a9ca67eb 100644 --- a/packages/qwik/src/core/tests/use-async.spec.tsx +++ b/packages/qwik/src/core/tests/use-async.spec.tsx @@ -1,15 +1,18 @@ import { $, Fragment as Signal, + Slot, _jsxSorted, _wrapProp, component$, + useAsync$, + useConstant, + useErrorBoundary, 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; @@ -122,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 () => { @@ -219,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; }); @@ -231,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( <> + + ); + }); + + 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/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 */ // 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(); 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(); }); diff --git a/todo.md b/todo.md new file mode 100644 index 00000000000..7d9e97911cf --- /dev/null +++ b/todo.md @@ -0,0 +1,65 @@ +### Champion + +@varixo and @wmertens + +### What's the motivation for this proposal? + +**Summary** +Replace `useAsyncComputed$` with a new `useAsync$` that unifies async computation, observable values, polling, and eager cleanup, while aligning closer to `useSignal` semantics. + +**Motivation** +Current `useAsyncComputed$` overlaps with `useResource$` but neither are great. +A more powerful, writable async primitive would better support data fetching, streaming, auto-updating values, and background calculations. + +**Proposal** + +Rename `useAsyncComputed$` to `useAsync$`: + +```ts +useAsync$( + callback: (ctx: { track: TrackFn, cleanup: CleanupFn, poll: PollFn }) => Promise | T, + options?: { + initial?: T | (() => T); // like useSignal; prevents throw on read when uninitialized + eagerCleanup?: boolean; // run cleanup next tick when subscribers drop to 0 + awaitPrevious?: boolean; // wait for previous invocation to complete before running again + pollMs?: number; // re-run after N ms if subscribers exist + // abortable? + } +): AsyncSignal; +``` + +Key changes: + +1. **Writable result** – `result.value = …` updates subscribers directly; clears loading/error state. +2. **Error handling** – `result.error = …` propagates error on read. +3. **Initial value** – optional `initial` avoids throw when reading before first resolution. +4. **Eager cleanup** – cleanup runs immediately (next tick) when no subscribers remain. +5. **Polling** – via `pollMs: number` option or `signal.pollMs` property for periodic re-execution; use `.invalidate()` as before to do it manually. + +**Example** + +```tsx +const data = useAsync$( + ({ cleanup }) => { + const channel = await makeChannel(); + channel.on('data', (d) => (data.value = d)); // push updates + channel.on('error', (e) => (data.error = e)); // propagate errors + cleanup(() => channel.close()); + return 'PENDING'; + }, + { eagerCleanup: true } +); +``` + +**Benefits** + +- Single primitive for async data, observables, polling, and background tasks. +- Consistent with `useSignal` mental model. +- Reduces need for separate `useResource$` or custom observables. +- Enables streaming/server-sent events, WebSocket handling, and auto-refetching patterns out of the box. + +## Addition: generators + +The callback should also be able to be a generator function, it should be wrapped so that every invocation updates the value. +This could work without `track` but since there's no way to know if the callback is a generator, we keep `track` for now. +Maybe we should add `autoTrack` to the qwik plugin.