diff --git a/.changeset/green-days-give.md b/.changeset/green-days-give.md new file mode 100644 index 00000000000..e9c0cd1dd11 --- /dev/null +++ b/.changeset/green-days-give.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': minor +--- + +feat: introduce deferUpdates option for useTask$ diff --git a/packages/docs/src/routes/api/qwik/api.json b/packages/docs/src/routes/api/qwik/api.json index 5487ba2d910..d571ffec20f 100644 --- a/packages/docs/src/routes/api/qwik/api.json +++ b/packages/docs/src/routes/api/qwik/api.json @@ -2294,6 +2294,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts", "mdFile": "core.taskfn.md" }, + { + "name": "TaskOptions", + "id": "taskoptions", + "hierarchy": [ + { + "name": "TaskOptions", + "id": "taskoptions" + } + ], + "kind": "Interface", + "content": "```typescript\nexport interface TaskOptions \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[deferUpdates?](#)\n\n\n\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_ Block the rendering of the component until the task completes. Default is `true`\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts", + "mdFile": "core.taskoptions.md" + }, { "name": "Tracker", "id": "tracker", @@ -2626,7 +2640,7 @@ } ], "kind": "Function", - "content": "Reruns the `taskFn` when the observed inputs change.\n\nUse `useTask` to observe changes on a set of inputs, and then re-execute the `taskFn` when those inputs change.\n\nThe `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun.\n\n\n```typescript\nuseTask$: (fn: TaskFn) => void\n```\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfn\n\n\n\n\n[TaskFn](#taskfn)\n\n\n\n\n\n
\n\n**Returns:**\n\nvoid", + "content": "Reruns the `taskFn` when the observed inputs change.\n\nUse `useTask` to observe changes on a set of inputs, and then re-execute the `taskFn` when those inputs change.\n\nThe `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun.\n\n\n```typescript\nuseTask$: (fn: TaskFn, opts?: TaskOptions) => void\n```\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfn\n\n\n\n\n[TaskFn](#taskfn)\n\n\n\n\n\n
\n\nopts\n\n\n\n\n[TaskOptions](#taskoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nvoid", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task-dollar.ts", "mdFile": "core.usetask_.md" }, diff --git a/packages/docs/src/routes/api/qwik/index.mdx b/packages/docs/src/routes/api/qwik/index.mdx index 38e93c0b23f..e2e25d509a0 100644 --- a/packages/docs/src/routes/api/qwik/index.mdx +++ b/packages/docs/src/routes/api/qwik/index.mdx @@ -8825,6 +8825,48 @@ export type TaskFn = (ctx: TaskCtx) => ValueOrPromise void)>; [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts) +## TaskOptions + +```typescript +export interface TaskOptions +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +[deferUpdates?](#) + + + + + +boolean + + + +_(Optional)_ Block the rendering of the component until the task completes. Default is `true` + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/use/use-task.ts) + ## Tracker Used to signal to Qwik which state should be watched for changes. @@ -9955,7 +9997,7 @@ Use `useTask` to observe changes on a set of inputs, and then re-execute the `ta The `taskFn` only executes if the observed inputs change. To observe the inputs, use the `obs` function to wrap property reads. This creates subscriptions that will trigger the `taskFn` to rerun. ```typescript -useTask$: (fn: TaskFn) => void +useTask$: (fn: TaskFn, opts?: TaskOptions) => void ``` +
@@ -9981,6 +10023,19 @@ fn +
+ +opts + + + +[TaskOptions](#taskoptions) + + + +_(Optional)_ +
diff --git a/packages/docs/src/routes/docs/(qwik)/core/tasks/index.mdx b/packages/docs/src/routes/docs/(qwik)/core/tasks/index.mdx index 0981c062752..1d81127a217 100644 --- a/packages/docs/src/routes/docs/(qwik)/core/tasks/index.mdx +++ b/packages/docs/src/routes/docs/(qwik)/core/tasks/index.mdx @@ -19,7 +19,8 @@ contributors: - adamdbradley - aendel - jemsco -updated_at: '2023-10-18T07:33:22Z' + - varixo +updated_at: '2025-11-08T03:33:22Z' created_at: '2023-03-31T02:40:50Z' --- @@ -33,15 +34,17 @@ Tasks are meant for running asynchronous operations as part of component initial > **Note**: Tasks are similar to `useEffect()` in React, but there are enough differences that we did not want to call them the same so as not to bring preexisting expectations about how they work. The main differences are: > > - Tasks are asynchronous. -> - Task run on server and browser. +> - Tasks run on server and browser. > - Tasks run before rendering and can block rendering. +> - Subsequent task re-executions (when tracked state changes) block rendering by default, but can be configured to not block DOM updates with `deferUpdates: false`. `useTask$()` should be your default go-to API for running either synchronous or asynchronous work as part of component initialization or state change. It is only when you can't achieve what you need with `useTask$()` that you should consider using `useVisibleTask$()` or `useResource$()`. The basic use case for `useTask$()` is to perform work on component initialization. `useTask$()` has these properties: - It can run on either the server or in the browser. -- It runs before rendering and blocks rendering. +- It runs before the initial rendering and blocks the initial render. - If multiple tasks are running then they are run sequentially in the order they were registered. An asynchronous task will block the next task from running until it completes. +- By default, subsequent re-executions (when tracked state changes) do block DOM updates unless `deferUpdates: false` is specified. Tasks can also be used to perform work when a component state changes. In this case, the task will rerun every time the tracked state changes. See: [`track()`](#track). @@ -59,7 +62,7 @@ When the user interacts with the application, it resumes on the client-side, con **In Qwik, there are only 3 lifecycle stages:** -- `Task` - run before rendering and when tracked state changes. `Tasks` run sequentially, and block rendering. +- `Task` - run before rendering and when tracked state changes. `Tasks` run sequentially, and block each other. They also can block rendering. - `Render` - runs after `TASK` and before `VisibleTask` - `VisibleTask` - runs after `Render` and when the component becomes visible @@ -93,6 +96,28 @@ When the user interacts with the application, it resumes on the client-side, con `useTask$()` registers a hook to be executed upon component creation, it will run at least once either in the server or in the browser, depending on where the component is initially rendered. +### Task options + +`useTask$()` accepts an optional second parameter of type `TaskOptions` to configure the task behavior. + +```typescript +interface TaskOptions { + deferUpdates?: boolean; +} +``` + +#### `deferUpdates` + +The `deferUpdates` option controls whether subsequent task re-executions (when tracked state changes) should block DOM updates. + +**Default behavior (`deferUpdates: false` or not specified):** +- **Initial render**: The task blocks rendering (same as default) +- **Subsequent runs**: The task blocks DOM updates until it completes - the journal flush is deferred until the task finishes + +**With `deferUpdates: true`:** +- **Initial render**: The task blocks rendering (runs before the component renders for the first time) +- **Subsequent runs**: The task runs asynchronously without blocking DOM updates + Additionally, this task can be reactive and will re-execute when **tracked** [state](/docs/(qwik)/core/state/index.mdx) changes. **Notice that any subsequent re-execution of the task will always happen in the browser**, because reactivity is a browser-only thing. @@ -110,7 +135,7 @@ Additionally, this task can be reactive and will re-execute when **tracked** [st > If `useTask$()` does not track any state, it will run **exactly once**, either in the server **or** in the browser (**not both**), depending where the component is initially rendered. Effectively behaving like an "on-mount" hook. -`useTask$()` will block the rendering of the component until after its async callback resolves, in other words, tasks execute sequentially even if they are asynchronous. (Only one task executes at a time). +`useTask$()` will block the rendering of the component until after its async callback resolves. Tasks execute sequentially even if they are asynchronous (only one task executes at a time within a component). Subsequent re-executions (when tracking state changes) run asynchronously by default and block rendering unless `deferUpdates: false` is set. Take a look at the simplest use case of the task to run some asynchronous work on component initialization: @@ -151,7 +176,7 @@ const delay = (time: number) => new Promise((res) => setTimeout(res, time)); Use `useTask$()` when you need to: - Run async tasks before rendering -- Run code only once before the component is first rendered +- Run code only once before the component is first rendered - Programmatically run side-effect code when state changes > Note, if you're thinking about loading data using `fetch()` inside of `useTask$`, consider using [`useResource$()`](/docs/core/state/#useresource) instead. This API is more efficient in terms of leveraging SSR streaming and parallel data fetching. diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts index 578244e9fdf..0ae8ad5dfa2 100644 --- a/packages/docs/vite.config.ts +++ b/packages/docs/vite.config.ts @@ -144,6 +144,7 @@ export default defineConfig(() => { 'qwik-image', // optimizing breaks the wasm import '@rolldown/browser', + '@qwik.dev/devtools', ], }, preview: { diff --git a/packages/qwik/src/core/index.ts b/packages/qwik/src/core/index.ts index 240b3aadb65..86b3d904824 100644 --- a/packages/qwik/src/core/index.ts +++ b/packages/qwik/src/core/index.ts @@ -143,7 +143,7 @@ export { useComputedQrl } from './use/use-computed'; export { useSerializerQrl, useSerializer$ } from './use/use-serializer'; export type { OnVisibleTaskOptions, VisibleTaskStrategy } from './use/use-visible-task'; export { useVisibleTaskQrl } from './use/use-visible-task'; -export type { TaskCtx, TaskFn, Tracker } from './use/use-task'; +export type { TaskCtx, TaskFn, Tracker, TaskOptions } from './use/use-task'; export type { ResourceProps, ResourceOptions, diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index e4667021665..536b668d225 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -1662,6 +1662,11 @@ export interface TaskCtx { // @public (undocumented) export type TaskFn = (ctx: TaskCtx) => ValueOrPromise void)>; +// @public (undocumented) +export interface TaskOptions { + deferUpdates?: boolean; +} + // @internal (undocumented) export class _TextVNode extends _VNode { constructor(flags: _VNodeFlags, parent: _ElementVNode | _VirtualVNode | null, previousSibling: _VNode | null | undefined, nextSibling: _VNode | null | undefined, textNode: Text | null, text: string | undefined); @@ -1803,12 +1808,12 @@ export interface UseStylesScoped { export const useStylesScopedQrl: (styles: QRL) => UseStylesScoped; // @public -export const useTask$: (fn: TaskFn) => void; +export const useTask$: (fn: TaskFn, opts?: TaskOptions) => void; // Warning: (ae-internal-missing-underscore) The name "useTaskQrl" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) -export const useTaskQrl: (qrl: QRL) => void; +export const useTaskQrl: (qrl: QRL, opts?: TaskOptions) => void; // @public export const useVisibleTask$: (fn: TaskFn, opts?: OnVisibleTaskOptions) => void; diff --git a/packages/qwik/src/core/shared/scheduler.ts b/packages/qwik/src/core/shared/scheduler.ts index 13d580a08d1..266f0cba866 100644 --- a/packages/qwik/src/core/shared/scheduler.ts +++ b/packages/qwik/src/core/shared/scheduler.ts @@ -737,11 +737,16 @@ This is often caused by modifying a signal in an already rendered component duri host ) as ValueOrPromise>; } else { - returnValue = runTask( - payload as Task, - container, - host - ) as ValueOrPromise>; + const task = payload as Task; + returnValue = runTask(task, container, host) as ValueOrPromise< + ChoreReturnValue + >; + if (task.$flags$ & TaskFlags.RENDER_BLOCKING) { + blockingChoresCount++; + returnValue = maybeThen(returnValue, () => { + blockingChoresCount--; + }); + } } } break; diff --git a/packages/qwik/src/core/tests/use-task.spec.tsx b/packages/qwik/src/core/tests/use-task.spec.tsx index 24033f45d2e..b69a036bdac 100644 --- a/packages/qwik/src/core/tests/use-task.spec.tsx +++ b/packages/qwik/src/core/tests/use-task.spec.tsx @@ -12,7 +12,7 @@ import { type Signal as SignalType, } from '@qwik.dev/core'; import { domRender, getTestPlatform, ssrRenderToDom, trigger } from '@qwik.dev/core/testing'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ErrorProvider } from '../../testing/rendering.unit-util'; import { delay } from '../shared/utils/promises'; import { WrappedSignalImpl } from '../reactive-primitives/impl/wrapped-signal-impl'; @@ -653,6 +653,164 @@ describe.each([ ); }); + describe('blockRender', () => { + it('should execute task and block render until finish', async () => { + vi.useFakeTimers(); + (global as any).counter = 0; + const Counter = component$(() => { + const count = useSignal(0); + const text = useSignal('val1'); + + useTask$( + async ({ track }) => { + const c = track(count); + // skip initial render + if ((global as any).counter > 0) { + text.value = 'val' + (c + 1); + await delay(100); + } + (global as any).counter++; + }, + { + deferUpdates: true, + } + ); + return ( + + ); + }); + + // Start rendering + const renderPromise = render(, { debug }); + // Advance timers to complete rendering + await vi.advanceTimersToNextTimerAsync(); + const { document } = await renderPromise; + + // Initial render + await expect(document.body.firstChild).toMatchDOM(); + + // FIRST CLICK + + // Trigger task by clicking + let triggerPromise = trigger(document.body, 'button', 'click'); + + // Advance timers but not enough to complete the delay + await vi.advanceTimersByTimeAsync(99); + // Should be still old value + await expect(document.body.firstChild).toMatchDOM(); + // Advance timers to complete the delay + await vi.advanceTimersByTimeAsync(1); + // Wait for the trigger to complete + await triggerPromise; + + // Should have the new value + await expect(document.body.firstChild).toMatchDOM(); + + // SECOND CLICK + + // Trigger task by clicking + triggerPromise = trigger(document.body, 'button', 'click'); + + // Advance timers but not enough to complete the delay + await vi.advanceTimersByTimeAsync(99); + // Should be still old value + await expect(document.body.firstChild).toMatchDOM(); + // Advance timers to complete the delay + await vi.advanceTimersByTimeAsync(1); + // Wait for the trigger to complete + await triggerPromise; + + // Should have the new value + await expect(document.body.firstChild).toMatchDOM(); + + vi.useRealTimers(); + }); + + it('should execute task and not block render until finish', async () => { + vi.useFakeTimers(); + (global as any).counter = 0; + const Counter = component$(() => { + const count = useSignal(0); + const text = useSignal('val1'); + + useTask$( + async ({ track }) => { + const c = track(count); + // skip initial render + if ((global as any).counter > 0) { + text.value = 'val' + (c + 1); + await delay(100); + } + (global as any).counter++; + }, + { + deferUpdates: false, + } + ); + return ( + + ); + }); + + // Start rendering + const renderPromise = render(, { debug }); + // Advance timers to complete rendering + await vi.advanceTimersToNextTimerAsync(); + const { document } = await renderPromise; + + // Initial render + await expect(document.body.firstChild).toMatchDOM(); + + // FIRST CLICK + + // Trigger task by clicking + let triggerPromise = trigger(document.body, 'button', 'click'); + + // Advance timers but not enough to complete the delay + await vi.advanceTimersByTimeAsync(99); + // Should have the new value + await expect(document.body.firstChild).toMatchDOM(); + // Advance timers to complete the delay + await vi.advanceTimersByTimeAsync(1); + // Wait for the trigger to complete + await triggerPromise; + + // Should have the new value + await expect(document.body.firstChild).toMatchDOM(); + + // SECOND CLICK + + // Trigger task by clicking + triggerPromise = trigger(document.body, 'button', 'click'); + + // Advance timers but not enough to complete the delay + await vi.advanceTimersByTimeAsync(99); + // Should have the new value + await expect(document.body.firstChild).toMatchDOM(); + // Advance timers to complete the delay + await vi.advanceTimersByTimeAsync(1); + // Wait for the trigger to complete + await triggerPromise; + + // Should have the new value + await expect(document.body.firstChild).toMatchDOM(); + + vi.useRealTimers(); + }); + }); + describe('regression', () => { it('#5782', async () => { const Child = component$(({ sig }: { sig: SignalType> }) => { @@ -897,7 +1055,7 @@ describe.each([ ); }); - it('catch the ', async () => { + it('should catch an server side error', async () => { const error = new Error('HANDLE ME'); const Cmp = component$(() => { useTask$(() => { diff --git a/packages/qwik/src/core/use/use-task-dollar.ts b/packages/qwik/src/core/use/use-task-dollar.ts index e26ad32c3bb..b9ba8b0fd42 100644 --- a/packages/qwik/src/core/use/use-task-dollar.ts +++ b/packages/qwik/src/core/use/use-task-dollar.ts @@ -1,5 +1,5 @@ import { implicit$FirstArg } from '../shared/qrl/implicit_dollar'; -import { useTaskQrl, type TaskFn } from './use-task'; +import { useTaskQrl, type TaskFn, type TaskOptions } from './use-task'; // // !!DO NOT EDIT THIS COMMENT DIRECTLY!!! @@ -63,4 +63,7 @@ import { useTaskQrl, type TaskFn } from './use-task'; */ // // We need to cast to help out the api extractor -export const useTask$ = /*#__PURE__*/ implicit$FirstArg(useTaskQrl) as (fn: TaskFn) => void; +export const useTask$ = /*#__PURE__*/ implicit$FirstArg(useTaskQrl) as ( + fn: TaskFn, + opts?: TaskOptions +) => void; diff --git a/packages/qwik/src/core/use/use-task.ts b/packages/qwik/src/core/use/use-task.ts index 076b8487dfb..70868a2f497 100644 --- a/packages/qwik/src/core/use/use-task.ts +++ b/packages/qwik/src/core/use/use-task.ts @@ -22,6 +22,7 @@ export const enum TaskFlags { TASK = 1 << 1, RESOURCE = 1 << 2, DIRTY = 1 << 3, + RENDER_BLOCKING = 1 << 4, } // @@ -124,7 +125,6 @@ export interface TaskCtx { /** @public */ export type TaskFn = (ctx: TaskCtx) => ValueOrPromise void)>; -/** @public */ export interface DescriptorBase extends BackRef { $flags$: number; $index$: number; @@ -134,8 +134,14 @@ export interface DescriptorBase extends BackRef { $destroy$: NoSerialize<() => void> | null; } +/** @public */ +export interface TaskOptions { + /** Block the rendering of the component until the task completes. Default is `true` */ + deferUpdates?: boolean; +} + /** @internal */ -export const useTaskQrl = (qrl: QRL): void => { +export const useTaskQrl = (qrl: QRL, opts?: TaskOptions): void => { const { val, set, iCtx, i } = useSequentialScope<1 | Task>(); if (val) { return; @@ -143,8 +149,12 @@ export const useTaskQrl = (qrl: QRL): void => { assertQrl(qrl); set(1); + const taskFlags = + // enabled by default + opts?.deferUpdates === false ? 0 : TaskFlags.RENDER_BLOCKING; + const task = new Task( - TaskFlags.DIRTY | TaskFlags.TASK, + TaskFlags.DIRTY | TaskFlags.TASK | taskFlags, i, iCtx.$hostElement$, qrl,