Skip to content

Commit c4dc275

Browse files
committed
feat(async): abort signal support
1 parent 1286ab1 commit c4dc275

File tree

7 files changed

+238
-14
lines changed

7 files changed

+238
-14
lines changed

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,23 @@
220220
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/qrl/qrl.public.ts",
221221
"mdFile": "core._.md"
222222
},
223+
{
224+
"name": "abort",
225+
"id": "asyncsignal-abort",
226+
"hierarchy": [
227+
{
228+
"name": "AsyncSignal",
229+
"id": "asyncsignal-abort"
230+
},
231+
{
232+
"name": "abort",
233+
"id": "asyncsignal-abort"
234+
}
235+
],
236+
"kind": "MethodSignature",
237+
"content": "Abort the current computation and run cleanups if needed.\n\n\n```typescript\nabort(): void;\n```\n**Returns:**\n\nvoid",
238+
"mdFile": "core.asyncsignal.abort.md"
239+
},
223240
{
224241
"name": "AsyncFn",
225242
"id": "asyncfn",
@@ -244,7 +261,7 @@
244261
}
245262
],
246263
"kind": "Interface",
247-
"content": "```typescript\nexport interface AsyncSignal<T = unknown> extends ComputedSignal<T> \n```\n**Extends:** [ComputedSignal](#computedsignal)<!-- -->&lt;T&gt;\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[error](#)\n\n\n</td><td>\n\n\n</td><td>\n\nError \\| undefined\n\n\n</td><td>\n\nThe error that occurred while computing the signal, if any. This will be cleared when the signal is successfully computed.\n\n\n</td></tr>\n<tr><td>\n\n[loading](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\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 ? <Loading /> : signal.error ? <Error /> : <Component\nvalue={signal.value} />\n```\n\n\n</td></tr>\n<tr><td>\n\n[pollMs](#)\n\n\n</td><td>\n\n\n</td><td>\n\nnumber\n\n\n</td><td>\n\nPoll interval in ms. Writable and immediately effective when the signal has consumers. If set to `0`<!-- -->, polling stops.\n\n\n</td></tr>\n</tbody></table>\n\n\n<table><thead><tr><th>\n\nMethod\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[promise()](#asyncsignal-promise)\n\n\n</td><td>\n\nA promise that resolves when the value is computed or rejected.\n\n\n</td></tr>\n</tbody></table>",
264+
"content": "```typescript\nexport interface AsyncSignal<T = unknown> extends ComputedSignal<T> \n```\n**Extends:** [ComputedSignal](#computedsignal)<!-- -->&lt;T&gt;\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[error](#)\n\n\n</td><td>\n\n\n</td><td>\n\nError \\| undefined\n\n\n</td><td>\n\nThe error that occurred while computing the signal, if any. This will be cleared when the signal is successfully computed.\n\n\n</td></tr>\n<tr><td>\n\n[loading](#)\n\n\n</td><td>\n\n\n</td><td>\n\nboolean\n\n\n</td><td>\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 ? <Loading /> : signal.error ? <Error /> : <Component\nvalue={signal.value} />\n```\n\n\n</td></tr>\n<tr><td>\n\n[pollMs](#)\n\n\n</td><td>\n\n\n</td><td>\n\nnumber\n\n\n</td><td>\n\nPoll interval in ms. Writable and immediately effective when the signal has consumers. If set to `0`<!-- -->, polling stops.\n\n\n</td></tr>\n</tbody></table>\n\n\n<table><thead><tr><th>\n\nMethod\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n[abort()](#asyncsignal-abort)\n\n\n</td><td>\n\nAbort the current computation and run cleanups if needed.\n\n\n</td></tr>\n<tr><td>\n\n[promise()](#asyncsignal-promise)\n\n\n</td><td>\n\nA promise that resolves when the value is computed or rejected.\n\n\n</td></tr>\n</tbody></table>",
248265
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts",
249266
"mdFile": "core.asyncsignal.md"
250267
},
@@ -446,7 +463,7 @@
446463
}
447464
],
448465
"kind": "Function",
449-
"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$: <T>(qrl: () => Promise<T>, options?: AsyncSignalOptions<T>) => AsyncSignal<T>\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nqrl\n\n\n</td><td>\n\n() =&gt; Promise&lt;T&gt;\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\noptions\n\n\n</td><td>\n\nAsyncSignalOptions&lt;T&gt;\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n</tbody></table>\n\n**Returns:**\n\n[AsyncSignal](#asyncsignal)<!-- -->&lt;T&gt;",
466+
"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$: <T>(qrl: (arg: AsyncCtx) => Promise<T>, options?: AsyncSignalOptions<T>) => AsyncSignal<T>\n```\n\n\n<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nqrl\n\n\n</td><td>\n\n(arg: AsyncCtx) =&gt; Promise&lt;T&gt;\n\n\n</td><td>\n\n\n</td></tr>\n<tr><td>\n\noptions\n\n\n</td><td>\n\nAsyncSignalOptions&lt;T&gt;\n\n\n</td><td>\n\n_(Optional)_\n\n\n</td></tr>\n</tbody></table>\n\n**Returns:**\n\n[AsyncSignal](#asyncsignal)<!-- -->&lt;T&gt;",
450467
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/reactive-primitives/signal.public.ts",
451468
"mdFile": "core.createasync_.md"
452469
},

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ Expression which should be lazy loaded
120120

121121
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik/src/core/shared/qrl/qrl.public.ts)
122122

123+
## abort
124+
125+
Abort the current computation and run cleanups if needed.
126+
127+
```typescript
128+
abort(): void;
129+
```
130+
131+
**Returns:**
132+
133+
void
134+
123135
## AsyncFn
124136

125137
```typescript
@@ -221,6 +233,15 @@ Description
221233
</th></tr></thead>
222234
<tbody><tr><td>
223235
236+
[abort()](#asyncsignal-abort)
237+
238+
</td><td>
239+
240+
Abort the current computation and run cleanups if needed.
241+
242+
</td></tr>
243+
<tr><td>
244+
224245
[promise()](#asyncsignal-promise)
225246
226247
</td><td>
@@ -847,8 +868,10 @@ Description
847868
Create a signal holding a `.value` which is calculated from the given async function (QRL). The standalone version of `useAsync$`.
848869
849870
```typescript
850-
createAsync$: <T>(qrl: () => Promise<T>, options?: AsyncSignalOptions<T>) =>
851-
AsyncSignal<T>;
871+
createAsync$: <T>(
872+
qrl: (arg: AsyncCtx) => Promise<T>,
873+
options?: AsyncSignalOptions<T>,
874+
) => AsyncSignal<T>;
852875
```
853876
854877
<table><thead><tr><th>
@@ -870,7 +893,7 @@ qrl
870893
871894
</td><td>
872895
873-
() =&gt; Promise&lt;T&gt;
896+
(arg: AsyncCtx) =&gt; Promise&lt;T&gt;
874897
875898
</td><td>
876899

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type AsyncFn<T> = (ctx: AsyncCtx) => Promise<T>;
1919

2020
// @public (undocumented)
2121
export interface AsyncSignal<T = unknown> extends ComputedSignal<T> {
22+
abort(): void;
2223
error: Error | undefined;
2324
loading: boolean;
2425
pollMs: number;
@@ -193,7 +194,7 @@ export interface CorrectedToggleEvent extends Event {
193194
// Warning: (ae-forgotten-export) The symbol "AsyncSignalOptions" needs to be exported by the entry point index.d.ts
194195
//
195196
// @public
196-
export const createAsync$: <T>(qrl: () => Promise<T>, options?: AsyncSignalOptions<T>) => AsyncSignal<T>;
197+
export const createAsync$: <T>(qrl: (arg: AsyncCtx) => Promise<T>, options?: AsyncSignalOptions<T>) => AsyncSignal<T>;
197198

198199
// Warning: (ae-forgotten-export) The symbol "AsyncSignalImpl" needs to be exported by the entry point index.d.ts
199200
// Warning: (ae-internal-missing-underscore) The name "createAsyncQrl" should be prefixed with an underscore because the declaration is marked as @internal

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,6 @@ import { setupSignalValueAccess } from './signal-impl';
2424
*
2525
* - `eagerCleanup`: boolean - whether to run cleanups eagerly when there are no more subscribers, or
2626
* to wait until the next computation/destroy.
27-
* - AbortOnInvalidate: boolean (default false) - whether to abort the current computation when the
28-
* signal is invalidated. This requires the compute function to accept an AbortSignal and handle
29-
* it properly, so it's opt-in. When true, if the signal is invalidated while a computation is
30-
* running, the current computation will be aborted (if possible) and a new computation will be
31-
* started according to the concurrency limit.
32-
* - Abort: the callback receives an AbortSignal which is aborted when the signal is invalidated. The
3327
*/
3428

3529
const DEBUG = false;
@@ -45,13 +39,18 @@ class AsyncJob<T> implements AsyncCtx {
4539
$canWrite$: boolean = true;
4640
$track$: AsyncCtx['track'] | undefined;
4741
$cleanups$: Parameters<AsyncCtx['cleanup']>[0][] | undefined;
42+
$abortController$: AbortController | undefined;
4843

4944
constructor(readonly $signal$: AsyncSignalImpl<T>) {}
5045

5146
get track(): AsyncCtx['track'] {
5247
return (this.$track$ ||= trackFn(this.$signal$, this.$signal$.$container$));
5348
}
5449

50+
get abortSignal(): AbortSignal {
51+
return (this.$abortController$ ||= new AbortController()).signal;
52+
}
53+
5554
cleanup(callback: () => void) {
5655
if (typeof callback === 'function') {
5756
(this.$cleanups$ ||= []).push(callback);
@@ -172,6 +171,13 @@ export class AsyncSignalImpl<T> extends ComputedSignalImpl<T, AsyncQRL<T>> imple
172171
}
173172
}
174173

174+
/** Abort the current computation and run cleanups if needed. */
175+
abort(): void {
176+
if (this.$current$) {
177+
this.$requestCleanups$(this.$current$);
178+
}
179+
}
180+
175181
/** Returns a promise resolves when the signal finished computing. */
176182
async promise(): Promise<void> {
177183
this.$computeIfNeeded$();
@@ -305,6 +311,7 @@ export class AsyncSignalImpl<T> extends ComputedSignalImpl<T, AsyncQRL<T>> imple
305311
}
306312
DEBUG && log('Requesting cleanups for job', job);
307313
job.$cleanupRequested$ = true;
314+
job.$abortController$?.abort();
308315
job.$promise$ = Promise.resolve(job.$promise$).then(
309316
() => (job.$promise$ = this.$runCleanups$(job))
310317
);

packages/qwik/src/core/reactive-primitives/impl/signal.unit.tsx

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,179 @@ describe('signal', () => {
390390
});
391391
});
392392

393+
it('should provide abortSignal that is aborted on cleanup', async () => {
394+
await withContainer(async () => {
395+
const dep = createSignal(0);
396+
const ref = { aborted: false };
397+
398+
const signal = (await retryOnPromise(() =>
399+
createAsyncQrl(
400+
$(async ({ track, abortSignal }: AsyncCtx) => {
401+
track(() => dep.value);
402+
abortSignal.addEventListener('abort', () => {
403+
ref.aborted = true;
404+
});
405+
return dep.value;
406+
})
407+
)
408+
)) as AsyncSignalImpl<number>;
409+
410+
await retryOnPromise(() => {
411+
effect$(() => signal.value);
412+
});
413+
414+
expect(signal.value).toBe(0);
415+
expect(ref.aborted).toBe(false);
416+
417+
// Trigger re-computation which should run cleanup and abort
418+
dep.value = 1;
419+
await signal.promise();
420+
421+
expect(signal.value).toBe(1);
422+
expect(ref.aborted).toBe(true);
423+
});
424+
});
425+
426+
it('should lazily create abortController only when accessed', async () => {
427+
await withContainer(async () => {
428+
const signal = (await retryOnPromise(() =>
429+
createAsyncQrl(
430+
$(async () => {
431+
return 42;
432+
})
433+
)
434+
)) as AsyncSignalImpl<number>;
435+
436+
await retryOnPromise(() => {
437+
effect$(() => signal.value);
438+
});
439+
440+
// AbortController should not be created if abortSignal is never accessed
441+
const job = signal.$current$;
442+
expect(job).toBeTruthy();
443+
expect(job?.$abortController$).toBeUndefined();
444+
});
445+
});
446+
447+
it('should create abortController when abortSignal is accessed', async () => {
448+
await withContainer(async () => {
449+
const ref = { capturedSignal: undefined as AbortSignal | undefined };
450+
451+
const signal = (await retryOnPromise(() =>
452+
createAsyncQrl(
453+
$(async ({ abortSignal }: AsyncCtx) => {
454+
ref.capturedSignal = abortSignal;
455+
return 42;
456+
})
457+
)
458+
)) as AsyncSignalImpl<number>;
459+
460+
await retryOnPromise(() => {
461+
effect$(() => signal.value);
462+
});
463+
464+
// AbortController should be created when abortSignal is accessed
465+
const job = signal.$current$;
466+
expect(job).toBeTruthy();
467+
expect(job?.$abortController$).toBeInstanceOf(AbortController);
468+
expect(ref.capturedSignal).toBeInstanceOf(AbortSignal);
469+
expect(ref.capturedSignal?.aborted).toBe(false);
470+
});
471+
});
472+
473+
it('should abort current computation via signal.abort()', async () => {
474+
await withContainer(async () => {
475+
const ref = {
476+
aborted: false,
477+
resolve: undefined as ((value: number) => void) | undefined,
478+
};
479+
480+
const signal = createAsync$(
481+
async ({ abortSignal }) => {
482+
abortSignal.addEventListener('abort', () => {
483+
ref.aborted = true;
484+
});
485+
return new Promise<number>((resolve) => {
486+
ref.resolve = resolve;
487+
});
488+
},
489+
{ initial: 0 }
490+
) as AsyncSignalImpl<number>;
491+
492+
effect$(() => signal.value);
493+
494+
await new Promise((resolve) => setTimeout(resolve, 10));
495+
expect(ref.resolve).toBeDefined();
496+
497+
signal.abort();
498+
499+
expect(ref.aborted).toBe(true);
500+
501+
ref.resolve?.(1);
502+
await delay(0);
503+
});
504+
});
505+
506+
it('should abort immediately in $requestCleanups$ before waiting for task promise', async () => {
507+
await withContainer(async () => {
508+
const ref = {
509+
abortedBeforeTaskComplete: false,
510+
taskResolve: undefined as ((value: number) => void) | undefined,
511+
};
512+
513+
const signal = createAsync$(async ({ abortSignal }) => {
514+
// Listen for abort
515+
abortSignal.addEventListener('abort', () => {
516+
// Check if task is still running (taskResolve exists)
517+
if (ref.taskResolve) {
518+
ref.abortedBeforeTaskComplete = true;
519+
}
520+
});
521+
522+
// Create a long-running task
523+
return new Promise<number>((resolve) => {
524+
ref.taskResolve = resolve;
525+
});
526+
}) as AsyncSignalImpl<number>;
527+
// Subscribe with initial value to avoid promise throw
528+
const signal2 = (await retryOnPromise(() =>
529+
createAsync$(async () => 0, { initial: 0 })
530+
)) as AsyncSignalImpl<number>;
531+
532+
effect$(() => {
533+
// Read signal2 to establish effect without throwing
534+
return signal2.value;
535+
});
536+
537+
// Manually trigger computation for signal
538+
signal.$computeIfNeeded$();
539+
540+
// Wait for task to start using a simple timeout
541+
await new Promise((resolve) => setTimeout(resolve, 10));
542+
543+
// Verify task is running
544+
expect(ref.taskResolve).toBeDefined();
545+
546+
// Request cleanup while task is still running
547+
const job = signal.$current$;
548+
expect(job).toBeTruthy();
549+
if (job) {
550+
signal.$requestCleanups$(job);
551+
}
552+
553+
// Abort should be called immediately, before task completes
554+
expect(ref.abortedBeforeTaskComplete).toBe(true);
555+
556+
// Clean up by resolving the task
557+
if (ref.taskResolve) {
558+
ref.taskResolve(99);
559+
}
560+
561+
// Wait for cleanup to complete
562+
await delay(10);
563+
});
564+
});
565+
393566
it('should allow concurrent computations and apply newest completed value', async () => {
394567
await withContainer(async () => {
395568
const ref = {

packages/qwik/src/core/reactive-primitives/signal.public.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { implicit$FirstArg } from '../shared/qrl/implicit_dollar';
2-
import type { AsyncSignalOptions, ComputedOptions, SerializerArg } from './types';
2+
import type { AsyncCtx, AsyncSignalOptions, ComputedOptions, SerializerArg } from './types';
33
import {
44
createSignal as _createSignal,
55
createComputedSignal as createComputedQrl,
@@ -38,6 +38,8 @@ export interface AsyncSignal<T = unknown> extends ComputedSignal<T> {
3838
pollMs: number;
3939
/** A promise that resolves when the value is computed or rejected. */
4040
promise(): Promise<void>;
41+
/** Abort the current computation and run cleanups if needed. */
42+
abort(): void;
4143
}
4244

4345
/**
@@ -123,7 +125,7 @@ export { createComputedQrl };
123125
* @public
124126
*/
125127
export const createAsync$: <T>(
126-
qrl: () => Promise<T>,
128+
qrl: (arg: AsyncCtx) => Promise<T>,
127129
options?: AsyncSignalOptions<T>
128130
) => AsyncSignal<T> = /*#__PURE__*/ implicit$FirstArg(createAsyncQrl as any);
129131
export { createAsyncQrl };

packages/qwik/src/core/reactive-primitives/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type ComputeQRL<T> = QRLInternal<ComputedFn<T>>;
3737
export type AsyncCtx = {
3838
track: Tracker;
3939
cleanup: (callback: () => void | Promise<void>) => void;
40+
abortSignal: AbortSignal;
4041
};
4142
export type AsyncQRL<T> = QRLInternal<AsyncFn<T>>;
4243

0 commit comments

Comments
 (0)